mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 13:12:10 -03:00
Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b0d4b3149 | ||
|
|
f0f3d419f8 | ||
|
|
26e2036388 | ||
|
|
22f5d028a2 | ||
|
|
0b817411b7 | ||
|
|
f755507974 | ||
|
|
d65dc4349a | ||
|
|
04033a48cb | ||
|
|
05c0b91ecc | ||
|
|
4de1812370 | ||
|
|
e3cef041c9 | ||
|
|
03950b1787 | ||
|
|
3d60c6aafa | ||
|
|
fcb5565a28 | ||
|
|
fb5bbdd187 | ||
|
|
7662a501a4 | ||
|
|
fc4c343418 | ||
|
|
b09f9789de | ||
|
|
0de9aabb72 | ||
|
|
3941104bd5 | ||
|
|
6b04e3891b | ||
|
|
257bada28d | ||
|
|
7f39cfc8ed | ||
|
|
d2b7b396aa | ||
|
|
0bae8c9c9d | ||
|
|
f57b9f6b58 | ||
|
|
bb687a768b | ||
|
|
5e9869f827 | ||
|
|
bfea0cdbab | ||
|
|
7701ceda56 | ||
|
|
9e4e618955 | ||
|
|
19d1f9aa52 | ||
|
|
8d6cd783ec | ||
|
|
7fc49d72f5 | ||
|
|
e68fc7e2cb | ||
|
|
058a1c4d67 | ||
|
|
64ee2c6abb | ||
|
|
f36f91487f | ||
|
|
207bacc1f8 | ||
|
|
9d0c946e22 | ||
|
|
7e71d3ec3e | ||
|
|
25d07767f1 | ||
|
|
35f8a85c8b | ||
|
|
4019a8a88f | ||
|
|
0d6bfb01d6 | ||
|
|
6491d80225 | ||
|
|
6121403460 | ||
|
|
03c841380e | ||
|
|
46e92f30e8 | ||
|
|
796a65d251 | ||
|
|
f28783348e | ||
|
|
f329a6ded5 | ||
|
|
ca9e1890c4 | ||
|
|
14c5f291a6 | ||
|
|
1fc06f65a2 | ||
|
|
ccfa2b6cfb | ||
|
|
4c4856f9e7 | ||
|
|
24ef702f16 | ||
|
|
764e802311 | ||
|
|
58720a8eca | ||
|
|
3b1a69041c | ||
|
|
2778b8df9f | ||
|
|
b655b68412 | ||
|
|
d7838d565f | ||
|
|
3990ed1605 | ||
|
|
ea35f2a405 | ||
|
|
d25ec6b25c | ||
|
|
2997be536d | ||
|
|
3309cae337 | ||
|
|
60b6a9f932 | ||
|
|
ffbd5bfe43 | ||
|
|
da75a427fa | ||
|
|
4e1be7c1a3 | ||
|
|
bccb9da641 | ||
|
|
5235f7b961 | ||
|
|
ab4a8f7ca7 | ||
|
|
472f8768a5 | ||
|
|
1d520eca01 | ||
|
|
784e3d9296 | ||
|
|
eaf9c28ef0 | ||
|
|
133b009086 | ||
|
|
fe75968e13 | ||
|
|
0f8db35d52 | ||
|
|
ef4e65cb78 | ||
|
|
8e38ec98dd |
32
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
32
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: ✨ Feature Request
|
||||
description: Suggest an idea for this project
|
||||
title: '[Feature Request]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
*Please fill this form with as much information as possible, provide screenshots and/or illustrations of the feature if possible*
|
||||
- type: textarea
|
||||
id: feature
|
||||
attributes:
|
||||
label: What would your feature do ?
|
||||
description: Tell me about your feature in a very clear and simple way, and what problem it would solve
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: workflow
|
||||
attributes:
|
||||
label: Proposed workflow
|
||||
description: Please provide me with step by step information on how you'd like the feature to be accessed and used
|
||||
value: |
|
||||
1. Go to ....
|
||||
2. Press ....
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: misc
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
5
.github/workflows/publish.yml
vendored
5
.github/workflows/publish.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
@@ -19,10 +18,8 @@ jobs:
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@v1
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
## Add your own personal access token to your Github Repository secrets and reference it here.
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
49
.github/workflows/release.yml
vendored
49
.github/workflows/release.yml
vendored
@@ -35,44 +35,37 @@ jobs:
|
||||
run: |
|
||||
echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT
|
||||
|
||||
# ZMIANA: Zamiast tylko ostatniego commita, pobierz historię commitów od ostatniego tagu
|
||||
- name: Get commit history since last tag
|
||||
# ZMIANA: Poprawione obsługa multi-line output (z delimiterem EOF, bez zastępowania \n)
|
||||
- name: Get commit history since last version tag
|
||||
id: commit_history
|
||||
run: |
|
||||
# Znajdź ostatni tag (jeśli istnieje)
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
|
||||
# Jeśli nie ma ostatniego tagu, użyj pustego (pobierz od początku repo)
|
||||
if [ -z "$LAST_TAG" ]; then
|
||||
RANGE="HEAD"
|
||||
VERSION_TAG="v${{ steps.version.outputs.base_version }}"
|
||||
git fetch --tags
|
||||
|
||||
if git rev-parse "$VERSION_TAG" >/dev/null 2>&1; then
|
||||
RANGE="$VERSION_TAG..HEAD"
|
||||
else
|
||||
RANGE="$LAST_TAG..HEAD"
|
||||
RANGE="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ść
|
||||
|
||||
HISTORY=$(git log --pretty=format:"%s" $RANGE | \
|
||||
grep -vE '^\s*(add|update|fix|change|edit|mod|modify|cleanup|misc|typo|readme|temp|test|debug)\b' | \
|
||||
grep -vE '^(\s*Update|Add|Fix|Change|Edit|Refactor|Bump|Minor|Misc|Readme|Test)[^a-zA-Z0-9]*$' | \
|
||||
sed 's/^/- /')
|
||||
|
||||
if [ -z "$HISTORY" ]; then
|
||||
HISTORY="No changes since last release."
|
||||
HISTORY="No significant changes since last release."
|
||||
fi
|
||||
|
||||
echo "commit_history=$HISTORY" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "commit_history<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$HISTORY" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.unique_tag.outputs.final_tag }}
|
||||
name: Release ${{ steps.unique_tag.outputs.final_tag }}
|
||||
body: |
|
||||
📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}`
|
||||
|
||||
📝 Changes since last release:
|
||||
```
|
||||
${{ steps.commit_history.outputs.commit_history }}
|
||||
```
|
||||
name: Release ${{ steps.unique_tag.outputs.final_tag }}
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 tanglup
|
||||
Copyright (c) 2025 Azornes
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
86
README.md
86
README.md
@@ -28,10 +28,13 @@
|
||||
|
||||
---
|
||||
|
||||
https://github.com/user-attachments/assets/0f557d87-fd5e-422b-ab7e-dbdd4cab156c
|
||||
https://github.com/user-attachments/assets/90fffb9a-dae2-4d19-aca2-5d47600f0a01
|
||||
|
||||
https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
- **Freeform Inpainting Area:** Draw any custom (like a polygonal lasso tool) area directly inside the image for inpainting. The tool generates content that is coherent with the rest of the image, without requiring a brush.
|
||||
- **Persistent & Stateful:** Your work is automatically saved to the browser's IndexedDB, preserving your full canvas
|
||||
state (layers, positions, etc.) even after a page reload.
|
||||
- **Multi-Layer Editing:** Add, arrange, and manage multiple image layers with z-ordering.
|
||||
@@ -68,19 +71,75 @@ https://github.com/user-attachments/assets/0f557d87-fd5e-422b-ab7e-dbdd4cab156c
|
||||
3. Start up ComfyUI.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Polygonal Lasso Inpainting Workflow
|
||||
|
||||
LayerForge's newest feature allows you to draw custom polygonal selection areas and run inpainting directly within ComfyUI. This brings Photoshop-like lasso tool functionality to your AI workflows.
|
||||
|
||||
### Setup Requirements
|
||||
|
||||
1. **Enable Auto-Refresh:** In LayerForge's settings, enable `auto_refresh_after_generation`. Without this setting, the new generation output won't update automatically in the canvas.
|
||||
|
||||
2. **Configure Auto-Apply (Optional):** If you want the mask to be automatically applied after drawing the shape, enable the `auto-apply shape mask` option in the Custom Output Area menu (appears on the left when a custom shape is active).
|
||||
|
||||
### How to Use Polygonal Selection
|
||||
|
||||
1. **Start Drawing:** Hold `Shift + S` and left-click to place the first point of your polygonal selection.
|
||||
|
||||
2. **Add Points:** Continue left-clicking to place additional points. Each click adds a new vertex to your polygon.
|
||||
|
||||
3. **Close Selection:** Click back on the first point (or close to it) to complete and close the polygonal selection.
|
||||
|
||||
4. **Run Inpainting:** Once your selection is complete, run your inpainting workflow as usual. The generated content will seamlessly integrate with the existing image.
|
||||
|
||||
### Advanced Shape Mask Options
|
||||
|
||||
When using custom shapes, LayerForge provides several options to fine-tune the mask quality:
|
||||
|
||||
- **Mask Expansion/Contraction:** Adjust the mask boundary by -300 to +300 pixels to ensure better blending
|
||||
- **Edge Feathering:** Apply 0-300px feathering to create smooth transitions and reduce visible seams
|
||||
- **Output Area Extension:** Extend the output area in all directions for more context during generation
|
||||
- **Manual Blend Menu:** Right-click to access manual color adjustment tools for perfect edge blending
|
||||
|
||||
### Tips for Best Results
|
||||
|
||||
* Use **feathering (10–50px)** depending on the **size of the image** to create smooth transitions between the inpainted area and existing content. Larger images generally benefit from more feathering.
|
||||
* Experiment with **mask expansion** (e.g., 10–20px) if you notice hard edges or visible seams.
|
||||
* Use **Output Area Extension** based on image size:
|
||||
|
||||
* **Extend the output area in all directions** to give the model more **context during generation**, especially for larger or more complex images.
|
||||
* If **visible seams** still appear in the inpainting results:
|
||||
|
||||
* Use the **Manual Blend Menu** (right-click on the mask area) to access **color and edge adjustment tools** for precise fine-tuning and seamless integration.
|
||||
* **Image placement behavior:**
|
||||
|
||||
* The generated or pasted image is automatically inserted into the area defined by the **blue shape** you draw.
|
||||
* The model uses the area within the **dashed white preview outline** as the **full context** during generation.
|
||||
* Make sure the dashed region covers enough surrounding content to preserve lighting, texture, and scene coherence.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Workflow Example
|
||||
|
||||
For a quick test of **LayerForge**, you can try the example workflow provided below. It demonstrates a basic compositing setup using the node.
|
||||
|
||||
**🔗 Download Example Workflow**
|
||||

|
||||
|
||||
### 🔹 Simple Test Workflow
|
||||
This workflow allows **quick testing** of node behavior and output structures **without requiring additional models or complex dependencies**. Useful for inspecting how basic outputs are generated and connected.
|
||||

|
||||
|
||||
### 🔹 Flux Inpainting Workflow
|
||||
This example shows a typical **inpainting setup using the Flux model**. It demonstrates how to integrate model-based fill with contextual generation for seamless content restoration.
|
||||

|
||||
|
||||
|
||||
|
||||
**How to load the workflow:**
|
||||
Click on the image above, then drag and drop it into your ComfyUI workflow window in your browser. The workflow should load automatically.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🎮 Controls & Shortcuts
|
||||
|
||||
### Canvas Control
|
||||
@@ -91,6 +150,9 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
|
||||
| `Mouse Wheel` | Zoom view in/out |
|
||||
| `Shift + Click (background)` | Start resizing canvas area |
|
||||
| `Shift + Ctrl + Click` | Start moving entire canvas |
|
||||
| `Shift + S + Left Click` | Draw custom polygonal shape for output area |
|
||||
| `Single Click (background)` | Deselect all layers |
|
||||
| `Esc` | Close fullscreen editor mode |
|
||||
| `Double Click (background)` | Deselect all layers |
|
||||
|
||||
### Clipboard & I/O
|
||||
@@ -108,10 +170,11 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
|
||||
| `Click + Drag` | Move selected layer(s) |
|
||||
| `Ctrl + Click` | Add/Remove layer from selection |
|
||||
| `Alt + Drag` | Clone selected layer(s) |
|
||||
| `Shift + Click` | Show blend mode & opacity menu |
|
||||
| `Right Click` | Show blend mode & opacity menu |
|
||||
| `Mouse Wheel` | Scale layer (snaps to grid) |
|
||||
| `Ctrl + Mouse Wheel` | Fine-scale layer |
|
||||
| `Shift + Mouse Wheel` | Rotate layer by 5° |
|
||||
| `Shift + Ctrl + Mouse Wheel` | Snap rotation to 5° increments |
|
||||
| `Arrow Keys` | Nudge layer by 1px |
|
||||
| `Shift + Arrow Keys` | Nudge layer by 10px |
|
||||
| `[` or `]` | Rotate by 1° |
|
||||
@@ -138,6 +201,14 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
|
||||
| **Clear Mask** | Remove the entire mask |
|
||||
| **Exit Mode** | Click the "Draw Mask" button again |
|
||||
|
||||
---
|
||||
|
||||
## 🤖 Model Compatibility
|
||||
|
||||
LayerForge is designed to work with **any ComfyUI-compatible model**. The node outputs standard image and mask data that can be used with any model or workflow. LayerForge automatically inserts the generated image into the exact shape and position you draw with the blue polygon tool — but only if the generated image is saved properly, for example via a Save Image node.
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Optional: Matting Model (for image cutout)
|
||||
|
||||
The "Matting" feature allows you to automatically generate a cutout (alpha mask) for a selected layer. This is an
|
||||
@@ -152,7 +223,8 @@ optional feature and requires a model.
|
||||
|
||||
---
|
||||
|
||||
## 🐞 Known Issue:
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### `node_id` not auto-filled → black output
|
||||
|
||||
In some cases, **ComfyUI doesn't auto-fill the `node_id`** when adding a node.
|
||||
@@ -176,5 +248,9 @@ This project is licensed under the MIT License. Feel free to use, modify, and di
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
Based on the original [**Comfyui-Ycanvas**](https://github.com/yichengup/Comfyui-Ycanvas) by yichengup. This fork
|
||||
significantly enhances the editing capabilities for practical compositing workflows inside ComfyUI.
|
||||
|
||||
Special thanks to the ComfyUI community for feedback, bug reports, and feature suggestions that help make LayerForge better.
|
||||
|
||||
542
css_test.html
Normal file
542
css_test.html
Normal file
@@ -0,0 +1,542 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Canvas View CSS Test - All Button Styles</title>
|
||||
<link rel="stylesheet" href="src/css/canvas_view.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #2a2a2a;
|
||||
color: #ffffff;
|
||||
margin: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.test-section {
|
||||
margin: 30px 0;
|
||||
padding: 20px;
|
||||
background: #353535;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
.test-section h2 {
|
||||
color: #4a90e2;
|
||||
margin-top: 0;
|
||||
border-bottom: 1px solid #555;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.test-section h3 {
|
||||
color: #ffffff;
|
||||
margin: 20px 0 10px 0;
|
||||
}
|
||||
.demo-row {
|
||||
margin: 15px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.demo-label {
|
||||
min-width: 150px;
|
||||
color: #cccccc;
|
||||
font-size: 14px;
|
||||
}
|
||||
.test-controls {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.test-controls button {
|
||||
margin: 5px;
|
||||
padding: 8px 16px;
|
||||
background: #4a6cd4;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.test-controls button:hover {
|
||||
background: #5a7ce4;
|
||||
}
|
||||
|
||||
/* Custom Slider Styles to match ComfyUI aesthetic */
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 80px; /* Default width from painter-slider-container */
|
||||
height: 7px;
|
||||
background: #222;
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #cccccc;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #555;
|
||||
cursor: pointer;
|
||||
margin-top: -4.5px; /* Center thumb on track */
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #cccccc;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #555;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 7px;
|
||||
background: #222;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-track {
|
||||
width: 100%;
|
||||
height: 7px;
|
||||
background: #222;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Canvas View CSS Test Page</h1>
|
||||
<p>This page demonstrates all button styles from <code>src/css/canvas_view.css</code></p>
|
||||
|
||||
<div class="test-controls">
|
||||
<button onclick="toggleLoadingState()">Toggle Loading State</button>
|
||||
<button onclick="toggleDisabledState()">Toggle Disabled State</button>
|
||||
<button onclick="toggleClipboardSwitch()">Toggle Clipboard Switch</button>
|
||||
</div>
|
||||
|
||||
<!-- CanvasView Full Control Panel -->
|
||||
<div class="test-section">
|
||||
<h2>CanvasView Control Panel Layout</h2>
|
||||
<div class="painter-controls" style="position: relative; flex-wrap: wrap; padding: 10px; background: #383838;">
|
||||
|
||||
<!-- Group 1: Main Controls -->
|
||||
<div class="painter-button-group">
|
||||
<button class="painter-button icon-button" title="Open in Editor">⛶</button>
|
||||
<button class="painter-button icon-button" title="Show shortcuts">?</button>
|
||||
<button class="painter-button primary">Add Image</button>
|
||||
<button class="painter-button primary">Import Input</button>
|
||||
<div class="painter-clipboard-group">
|
||||
<button class="painter-button primary">Paste Image</button>
|
||||
<label class="clipboard-switch" id="test-clipboard-switch">
|
||||
<input type="checkbox">
|
||||
<span class="switch-track"></span>
|
||||
<span class="switch-labels">
|
||||
<span class="text-clipspace">Clipspace</span>
|
||||
<span class="text-system">System</span>
|
||||
</span>
|
||||
<span class="switch-knob">
|
||||
<span class="switch-icon">
|
||||
<img src="" alt="clipboard icon" style="width: 20px; height: 20px;">
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="painter-separator"></div>
|
||||
|
||||
<!-- Group 2: Layer Management -->
|
||||
<div class="painter-button-group">
|
||||
<button class="painter-button">Output Area Size</button>
|
||||
<button class="painter-button requires-selection">Remove Layer</button>
|
||||
<button class="painter-button requires-selection">Layer Up</button>
|
||||
<button class="painter-button requires-selection">Layer Down</button>
|
||||
<button class="painter-button requires-selection">Fuse</button>
|
||||
</div>
|
||||
|
||||
<div class="painter-separator"></div>
|
||||
|
||||
<!-- Group 3: Transformations -->
|
||||
<div class="painter-button-group">
|
||||
<button class="painter-button requires-selection">Rotate +90°</button>
|
||||
<button class="painter-button requires-selection">Scale +5%</button>
|
||||
<button class="painter-button requires-selection">Scale -5%</button>
|
||||
<button class="painter-button requires-selection">Mirror H</button>
|
||||
<button class="painter-button requires-selection">Mirror V</button>
|
||||
</div>
|
||||
|
||||
<div class="painter-separator"></div>
|
||||
|
||||
<!-- Group 4: Tools -->
|
||||
<div class="painter-button-group">
|
||||
<button class="painter-button matting-button requires-selection">Matting<div class="matting-spinner"></div></button>
|
||||
<button class="painter-button">Undo</button>
|
||||
<button class="painter-button">Redo</button>
|
||||
</div>
|
||||
|
||||
<div class="painter-separator"></div>
|
||||
|
||||
<!-- Group 5: Masking -->
|
||||
<div class="painter-button-group" id="test-mask-controls">
|
||||
<label class="clipboard-switch mask-switch" title="Toggle mask overlay visibility" style="min-width: 56px; max-width: 56px; width: 56px;">
|
||||
<input type="checkbox" checked="">
|
||||
<span class="switch-track"></span>
|
||||
<span class="switch-labels" style="font-size: 11px;">
|
||||
<span class="text-clipspace" style="padding-right: 22px;">On</span>
|
||||
<span class="text-system" style="padding-left: 20px;">Off</span>
|
||||
</span>
|
||||
<span class="switch-knob">
|
||||
<span class="switch-icon" style="display: flex; align-items: center; justify-content: center; width: 16px; height: 16px;">
|
||||
<!-- Icon would be loaded here by JS -->
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<button class="painter-button">Edit Mask</button>
|
||||
<button class="painter-button" id="test-mask-mode-btn" onclick="toggleTestMaskControls()">Draw Mask</button>
|
||||
<div class="painter-slider-container mask-control" style="display: none;">
|
||||
<label>Size:</label>
|
||||
<input type="range" min="1" max="200" value="20">
|
||||
<div class="slider-value">20px</div>
|
||||
</div>
|
||||
<div class="painter-slider-container mask-control" style="display: none;">
|
||||
<label>Strength:</label>
|
||||
<input type="range" min="0" max="1" step="0.05" value="0.5">
|
||||
<div class="slider-value">50%</div>
|
||||
</div>
|
||||
<div class="painter-slider-container mask-control" style="display: none;">
|
||||
<label>Hardness:</label>
|
||||
<input type="range" min="0" max="1" step="0.05" value="0.5">
|
||||
<div class="slider-value">50%</div>
|
||||
</div>
|
||||
<button class="painter-button mask-control" style="display: none;">Clear Mask</button>
|
||||
</div>
|
||||
|
||||
<div class="painter-separator"></div>
|
||||
|
||||
<!-- Group 6: Dev/Debug -->
|
||||
<div class="painter-button-group">
|
||||
<button class="painter-button success">Run GC</button>
|
||||
<button class="painter-button danger">Clear Cache</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Basic Buttons -->
|
||||
<div class="test-section">
|
||||
<h2>Basic Painter Buttons</h2>
|
||||
|
||||
<div class="demo-row">
|
||||
<span class="demo-label">Normal Button:</span>
|
||||
<button class="painter-button">Normal Button</button>
|
||||
<button class="painter-button">Another Button</button>
|
||||
<button class="painter-button">Third Button</button>
|
||||
</div>
|
||||
|
||||
<div class="demo-row">
|
||||
<span class="demo-label">Primary Button:</span>
|
||||
<button class="painter-button primary">Primary Button</button>
|
||||
<button class="painter-button primary">Save</button>
|
||||
<button class="painter-button primary">Apply</button>
|
||||
</div>
|
||||
|
||||
<div class="demo-row">
|
||||
<span class="demo-label">Disabled Buttons:</span>
|
||||
<button class="painter-button" disabled>Disabled Normal</button>
|
||||
<button class="painter-button primary" disabled>Disabled Primary</button>
|
||||
</div>
|
||||
|
||||
<div class="demo-row">
|
||||
<span class="demo-label">Matting Buttons:</span>
|
||||
<button class="painter-button matting-button" id="mattingBtn1">
|
||||
Matting Tool
|
||||
<div class="matting-spinner"></div>
|
||||
</button>
|
||||
<button class="painter-button matting-button" id="mattingBtn2">
|
||||
Process Image
|
||||
<div class="matting-spinner"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button Groups -->
|
||||
<div class="test-section">
|
||||
<h2>Button Groups and Controls</h2>
|
||||
|
||||
<h3>Painter Controls Container</h3>
|
||||
<div class="painter-controls">
|
||||
<button class="painter-button">Tool 1</button>
|
||||
<button class="painter-button">Tool 2</button>
|
||||
<button class="painter-button primary">Active Tool</button>
|
||||
<div class="painter-separator"></div>
|
||||
<button class="painter-button">Option A</button>
|
||||
<button class="painter-button">Option B</button>
|
||||
</div>
|
||||
|
||||
<h3>Button Group</h3>
|
||||
<div class="painter-button-group">
|
||||
<button class="painter-button">Group 1</button>
|
||||
<button class="painter-button">Group 2</button>
|
||||
<button class="painter-button primary">Group 3</button>
|
||||
</div>
|
||||
|
||||
<h3>Clipboard Group</h3>
|
||||
<div class="painter-clipboard-group">
|
||||
<button class="painter-button">Copy</button>
|
||||
<button class="painter-button">Paste</button>
|
||||
<button class="painter-button primary">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clipboard Switch -->
|
||||
<div class="test-section">
|
||||
<h2>Clipboard Switch</h2>
|
||||
|
||||
<div class="demo-row">
|
||||
<span class="demo-label">Clipboard Switch:</span>
|
||||
<label class="clipboard-switch" id="clipboardSwitch">
|
||||
<input type="checkbox">
|
||||
<span class="switch-track"></span>
|
||||
<span class="switch-knob">
|
||||
<span class="switch-icon">
|
||||
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTkgMTZIMTVWMTBIOVYxNloiIGZpbGw9IiM0YTkwZTIiLz4KPHBhdGggZD0iTTcgMTZIMTdWMTBIN1YxNloiIHN0cm9rZT0iIzRhOTBlMiIgc3Ryb2tlLXdpZHRoPSIyIiBmaWxsPSJub25lIi8+Cjwvc3ZnPgo=" alt="clipboard">
|
||||
</span>
|
||||
</span>
|
||||
<span class="switch-labels">
|
||||
<span class="text-system">System</span>
|
||||
<span class="text-clipspace">Clipspace</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sliders -->
|
||||
<div class="test-section">
|
||||
<h2>Sliders and Controls</h2>
|
||||
|
||||
<div class="painter-controls">
|
||||
<div class="painter-slider-container">
|
||||
<label>Opacity:</label>
|
||||
<input type="range" min="0" max="100" value="50">
|
||||
<span>50%</span>
|
||||
</div>
|
||||
<div class="painter-separator"></div>
|
||||
<div class="painter-slider-container">
|
||||
<label>Size:</label>
|
||||
<input type="range" min="1" max="100" value="25">
|
||||
<span>25px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container Examples -->
|
||||
<div class="test-section">
|
||||
<h2>Container Examples</h2>
|
||||
|
||||
<h3>Normal Container</h3>
|
||||
<div class="painter-container" style="padding: 20px; margin: 10px 0;">
|
||||
<p>This is a normal painter container</p>
|
||||
<button class="painter-button">Button inside container</button>
|
||||
</div>
|
||||
|
||||
<h3>Container with Focus</h3>
|
||||
<div class="painter-container has-focus" style="padding: 20px; margin: 10px 0;">
|
||||
<p>This container has focus (white border)</p>
|
||||
<button class="painter-button primary">Focused container button</button>
|
||||
</div>
|
||||
|
||||
<h3>Drag Over Container</h3>
|
||||
<div class="painter-container drag-over" style="padding: 20px; margin: 10px 0;">
|
||||
<p>This container is in drag-over state (green dashed border)</p>
|
||||
<button class="painter-button">Drag target button</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Example -->
|
||||
<div class="test-section">
|
||||
<h2>Modal and Dialog</h2>
|
||||
|
||||
<button onclick="showModal()" class="painter-button primary">Show Modal Example</button>
|
||||
|
||||
<h3>Dialog Example</h3>
|
||||
<div class="painter-dialog" style="display: inline-block; margin: 10px 0;">
|
||||
<h4>Sample Dialog</h4>
|
||||
<p>Enter values:</p>
|
||||
<input type="text" placeholder="Width" value="100">
|
||||
<input type="text" placeholder="Height" value="100">
|
||||
<br>
|
||||
<button>OK</button>
|
||||
<button>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Hidden Modal -->
|
||||
<div class="painter-modal-backdrop" id="testModal" style="display: none;">
|
||||
<div class="painter-modal-content">
|
||||
<div class="painterMainContainer">
|
||||
<div class="painter-controls">
|
||||
<button class="painter-button" onclick="hideModal()">Close Modal</button>
|
||||
<button class="painter-button primary">Save</button>
|
||||
<div class="painter-separator"></div>
|
||||
<button class="painter-button">Option 1</button>
|
||||
<button class="painter-button">Option 2</button>
|
||||
</div>
|
||||
<div class="painterCanvasContainer" style="padding: 20px; display: flex; align-items: center; justify-content: center;">
|
||||
<div>
|
||||
<h3>Modal Content Area</h3>
|
||||
<p>This is the main content area of the modal.</p>
|
||||
<div class="painter-button-group">
|
||||
<button class="painter-button">Action 1</button>
|
||||
<button class="painter-button">Action 2</button>
|
||||
<button class="painter-button primary">Primary Action</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- This is where the tooltip will be rendered -->
|
||||
<div class="painter-tooltip" id="live-tooltip" style="display: none;"></div>
|
||||
|
||||
<script>
|
||||
// Toggle loading state for matting buttons
|
||||
function toggleLoadingState() {
|
||||
const mattingBtns = document.querySelectorAll('.matting-button');
|
||||
mattingBtns.forEach(btn => {
|
||||
btn.classList.toggle('loading');
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle disabled state for all buttons
|
||||
function toggleDisabledState() {
|
||||
const buttons = document.querySelectorAll('.painter-button:not(.matting-button)');
|
||||
buttons.forEach(btn => {
|
||||
btn.disabled = !btn.disabled;
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle clipboard switch
|
||||
function toggleClipboardSwitch() {
|
||||
const checkbox = document.querySelector('#clipboardSwitch input[type="checkbox"]');
|
||||
checkbox.checked = !checkbox.checked;
|
||||
}
|
||||
|
||||
// Show modal
|
||||
function showModal() {
|
||||
document.getElementById('testModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
// Hide modal
|
||||
function hideModal() {
|
||||
document.getElementById('testModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// --- Live Tooltip Logic ---
|
||||
const tooltipButton = document.querySelector('button[title="Show shortcuts"]');
|
||||
const tooltipContainer = document.getElementById('live-tooltip');
|
||||
let shortcutsHtml = '';
|
||||
|
||||
// Pre-fetch the shortcuts content
|
||||
fetch('src/templates/standard_shortcuts.html')
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
shortcutsHtml = html;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching shortcuts:', error);
|
||||
shortcutsHtml = '<p>Error loading shortcuts.</p>';
|
||||
});
|
||||
|
||||
tooltipButton.addEventListener('mouseenter', (e) => {
|
||||
if (!shortcutsHtml || !tooltipContainer) return;
|
||||
|
||||
tooltipContainer.innerHTML = shortcutsHtml;
|
||||
tooltipContainer.style.visibility = 'hidden';
|
||||
tooltipContainer.style.display = 'block';
|
||||
|
||||
const buttonRect = e.target.getBoundingClientRect();
|
||||
const tooltipRect = tooltipContainer.getBoundingClientRect();
|
||||
|
||||
let left = buttonRect.left;
|
||||
let top = buttonRect.bottom + 5;
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
if (left + tooltipRect.width > window.innerWidth) {
|
||||
left = window.innerWidth - tooltipRect.width - 10;
|
||||
}
|
||||
if (top + tooltipRect.height > window.innerHeight) {
|
||||
top = buttonRect.top - tooltipRect.height - 5;
|
||||
}
|
||||
if (left < 10) left = 10;
|
||||
if (top < 10) top = 10;
|
||||
|
||||
tooltipContainer.style.left = `${left}px`;
|
||||
tooltipContainer.style.top = `${top}px`;
|
||||
tooltipContainer.style.visibility = 'visible';
|
||||
});
|
||||
|
||||
tooltipButton.addEventListener('mouseleave', () => {
|
||||
if (tooltipContainer) {
|
||||
tooltipContainer.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal when clicking backdrop
|
||||
document.getElementById('testModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
hideModal();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Icon Loading and Interactive Logic ---
|
||||
|
||||
const icons = {
|
||||
system: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>')}`,
|
||||
clipspace: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 7H7c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2zm0 12H7V9h10v10z"/><path d="M19 3H9c-1.1 0-2 .9-2 2v2h2V5h10v10h-2v2h2c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>')}`
|
||||
};
|
||||
|
||||
function updateClipboardIcon(switchElement) {
|
||||
const isChecked = switchElement.querySelector('input').checked;
|
||||
const iconImg = switchElement.querySelector('.switch-icon img');
|
||||
iconImg.src = isChecked ? icons.clipspace : icons.system;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const clipboardSwitch = document.getElementById('test-clipboard-switch');
|
||||
if (clipboardSwitch) {
|
||||
updateClipboardIcon(clipboardSwitch);
|
||||
clipboardSwitch.querySelector('input').addEventListener('change', () => {
|
||||
updateClipboardIcon(clipboardSwitch);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Function to toggle mask controls in the test layout
|
||||
function toggleTestMaskControls() {
|
||||
const maskBtn = document.getElementById('test-mask-mode-btn');
|
||||
const maskControlsContainer = document.getElementById('test-mask-controls');
|
||||
const controls = maskControlsContainer.querySelectorAll('.mask-control');
|
||||
|
||||
const isActive = maskBtn.classList.toggle('primary');
|
||||
|
||||
controls.forEach(control => {
|
||||
control.style.display = isActive ? 'flex' : 'none';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
example_workflows/LayerForge_flux_fill_inpaint_example.jpg
Normal file
BIN
example_workflows/LayerForge_flux_fill_inpaint_example.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
797
example_workflows/LayerForge_flux_fill_inpaint_example.json
Normal file
797
example_workflows/LayerForge_flux_fill_inpaint_example.json
Normal file
@@ -0,0 +1,797 @@
|
||||
{
|
||||
"id": "d26732fd-91ea-4503-8d0d-383544823cec",
|
||||
"revision": 0,
|
||||
"last_node_id": 49,
|
||||
"last_link_id": 112,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
307,
|
||||
282
|
||||
],
|
||||
"size": [
|
||||
425.2799987792969,
|
||||
180.61000061035156
|
||||
],
|
||||
"flags": {
|
||||
"collapsed": true
|
||||
},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 63
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
81
|
||||
]
|
||||
}
|
||||
],
|
||||
"title": "CLIP Text Encode (Negative Prompt)",
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
],
|
||||
"color": "#322",
|
||||
"bgcolor": "#533"
|
||||
},
|
||||
{
|
||||
"id": 26,
|
||||
"type": "FluxGuidance",
|
||||
"pos": [
|
||||
593,
|
||||
44
|
||||
],
|
||||
"size": [
|
||||
317.3999938964844,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "conditioning",
|
||||
"type": "CONDITIONING",
|
||||
"link": 41
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
80
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"Node name for S&R": "FluxGuidance",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
30
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 39,
|
||||
"type": "DifferentialDiffusion",
|
||||
"pos": [
|
||||
1001,
|
||||
-68
|
||||
],
|
||||
"size": [
|
||||
277.20001220703125,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 85
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
86
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"Node name for S&R": "DifferentialDiffusion",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 45,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [
|
||||
-225,
|
||||
255
|
||||
],
|
||||
"size": [
|
||||
225,
|
||||
88
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"🛈 [Learn more about this workflow](https://comfyanonymous.github.io/ComfyUI_examples/flux/#fill-inpainting-model)"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
},
|
||||
{
|
||||
"id": 34,
|
||||
"type": "DualCLIPLoader",
|
||||
"pos": [
|
||||
-237,
|
||||
79
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
130
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
62,
|
||||
63
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"Node name for S&R": "DualCLIPLoader",
|
||||
"models": [
|
||||
{
|
||||
"name": "clip_l.safetensors",
|
||||
"url": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/clip_l.safetensors?download=true",
|
||||
"directory": "text_encoders"
|
||||
},
|
||||
{
|
||||
"name": "t5xxl_fp16.safetensors",
|
||||
"url": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp16.safetensors?download=true",
|
||||
"directory": "text_encoders"
|
||||
}
|
||||
],
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"SDV3\\clip_l.safetensors",
|
||||
"FLUX\\t5xxl_fp16.safetensors",
|
||||
"flux",
|
||||
"default"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 31,
|
||||
"type": "UNETLoader",
|
||||
"pos": [
|
||||
602,
|
||||
-120
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
82
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
85
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"Node name for S&R": "UNETLoader",
|
||||
"models": [
|
||||
{
|
||||
"name": "flux1-fill-dev.safetensors",
|
||||
"url": "https://huggingface.co/black-forest-labs/FLUX.1-Fill-dev/blob/main/flux1-fill-dev.safetensors",
|
||||
"directory": "diffusion_models"
|
||||
}
|
||||
],
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"FLUX\\flux1-fill-dev.safetensors",
|
||||
"default"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1685.0001220703125,
|
||||
69.49121856689453
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 12,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 112
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 60
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
95
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"Node name for S&R": "VAEDecode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 49,
|
||||
"type": "CR Latent Batch Size",
|
||||
"pos": [
|
||||
959.2691040039062,
|
||||
354.2141418457031
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 10,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "latent",
|
||||
"type": "LATENT",
|
||||
"link": 110
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
111
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "ComfyUI_Comfyroll_CustomNodes",
|
||||
"ver": "d78b780ae43fcf8c6b7c6505e6ffb4584281ceca",
|
||||
"Node name for S&R": "CR Latent Batch Size",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"type": "InpaintModelConditioning",
|
||||
"pos": [
|
||||
916.876953125,
|
||||
124.41231536865234
|
||||
],
|
||||
"size": [
|
||||
302.3999938964844,
|
||||
138
|
||||
],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 80
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 81
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 82
|
||||
},
|
||||
{
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 106
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 107
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
77
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 1,
|
||||
"links": [
|
||||
78
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "latent",
|
||||
"type": "LATENT",
|
||||
"slot_index": 2,
|
||||
"links": [
|
||||
110
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"Node name for S&R": "InpaintModelConditioning",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
false
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
1280,
|
||||
100
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 11,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 86
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 77
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 78
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 111
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
112
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"Node name for S&R": "KSampler",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
858769863184862,
|
||||
"randomize",
|
||||
20,
|
||||
1,
|
||||
"euler",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 32,
|
||||
"type": "VAELoader",
|
||||
"pos": [
|
||||
1329.6622314453125,
|
||||
479.7701416015625
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
60,
|
||||
82
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"Node name for S&R": "VAELoader",
|
||||
"models": [
|
||||
{
|
||||
"name": "ae.safetensors",
|
||||
"url": "https://huggingface.co/black-forest-labs/FLUX.1-schnell/resolve/main/ae.safetensors?download=true",
|
||||
"directory": "vae"
|
||||
}
|
||||
],
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"FLUX\\ae.safetensors"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1936.2982177734375,
|
||||
82.75439453125
|
||||
],
|
||||
"size": [
|
||||
828.9500122070312,
|
||||
893.8499755859375
|
||||
],
|
||||
"flags": {},
|
||||
"order": 13,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 95
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"Node name for S&R": "SaveImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 23,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
-835.4583129882812,
|
||||
878.8148193359375
|
||||
],
|
||||
"size": [
|
||||
311.0955810546875,
|
||||
108.43277740478516
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 62
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"slot_index": 0,
|
||||
"links": [
|
||||
41
|
||||
]
|
||||
}
|
||||
],
|
||||
"title": "CLIP Text Encode (Positive Prompt)",
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"Node name for S&R": "CLIPTextEncode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"grass"
|
||||
],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 48,
|
||||
"type": "CanvasNode",
|
||||
"pos": [
|
||||
-514.2837524414062,
|
||||
543.1272583007812
|
||||
],
|
||||
"size": [
|
||||
1862.893798828125,
|
||||
1237.79638671875
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
106
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
107
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "layerforge",
|
||||
"ver": "22f5d028a2d4c3163014eba4896ef86810d81616",
|
||||
"Node name for S&R": "CanvasNode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
963,
|
||||
"48",
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
41,
|
||||
23,
|
||||
0,
|
||||
26,
|
||||
0,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
60,
|
||||
32,
|
||||
0,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
62,
|
||||
34,
|
||||
0,
|
||||
23,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
63,
|
||||
34,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
77,
|
||||
38,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
78,
|
||||
38,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
80,
|
||||
26,
|
||||
0,
|
||||
38,
|
||||
0,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
81,
|
||||
7,
|
||||
0,
|
||||
38,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
82,
|
||||
32,
|
||||
0,
|
||||
38,
|
||||
2,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
85,
|
||||
31,
|
||||
0,
|
||||
39,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
86,
|
||||
39,
|
||||
0,
|
||||
3,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
95,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
106,
|
||||
48,
|
||||
0,
|
||||
38,
|
||||
3,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
107,
|
||||
48,
|
||||
1,
|
||||
38,
|
||||
4,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
110,
|
||||
38,
|
||||
2,
|
||||
49,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
111,
|
||||
49,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
112,
|
||||
3,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.6588450000000008,
|
||||
"offset": [
|
||||
1318.77716124466,
|
||||
-32.39290875553955
|
||||
]
|
||||
},
|
||||
"ue_links": [],
|
||||
"links_added_by_ue": [],
|
||||
"frontendVersion": "1.23.4",
|
||||
"VHS_latentpreview": false,
|
||||
"VHS_latentpreviewrate": 0,
|
||||
"VHS_MetadataImage": true,
|
||||
"VHS_KeepIntermediate": true
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
BIN
example_workflows/LayerForge_flux_fill_inpaint_example.png
Normal file
BIN
example_workflows/LayerForge_flux_fill_inpaint_example.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
example_workflows/LayerForge_test_simple_workflow.jpg
Normal file
BIN
example_workflows/LayerForge_test_simple_workflow.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -1,64 +1,144 @@
|
||||
{
|
||||
"id": "c7ba7096-c52c-4978-8843-e87ce219b6a8",
|
||||
"revision": 0,
|
||||
"last_node_id": 705,
|
||||
"last_link_id": 1497,
|
||||
"last_node_id": 707,
|
||||
"last_link_id": 1499,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 368,
|
||||
"type": "Mask To Image (mtb)",
|
||||
"id": 369,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-1913.9735107421875,
|
||||
-3351.5126953125
|
||||
-1699.1021728515625,
|
||||
-3355.60498046875
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
130
|
||||
660.91162109375,
|
||||
400.2092590332031
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 1496
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
612
|
||||
]
|
||||
"link": 1499
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-mtb",
|
||||
"ver": "7e36007933f42c29cca270ae55e0e6866e323633",
|
||||
"Node name for S&R": "Mask To Image (mtb)",
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "PreviewImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 606,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-1911.126708984375,
|
||||
-2916.072998046875
|
||||
],
|
||||
"size": [
|
||||
551.7399291992188,
|
||||
546.8018798828125
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1495
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "PreviewImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 603,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-1344.1650390625,
|
||||
-2915.117919921875
|
||||
],
|
||||
"size": [
|
||||
601.4136962890625,
|
||||
527.1531372070312
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1236
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "PreviewImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 680,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
-1025.9984130859375,
|
||||
-3357.975341796875
|
||||
],
|
||||
"size": [
|
||||
278.8309020996094,
|
||||
395.84002685546875
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1465
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "SaveImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"#ff0000",
|
||||
"#000000",
|
||||
false
|
||||
"ComfyUI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 442,
|
||||
"type": "JoinImageWithAlpha",
|
||||
"pos": [
|
||||
-1907.2977294921875,
|
||||
-3180.562744140625
|
||||
-1902.5858154296875,
|
||||
-3187.159423828125
|
||||
],
|
||||
"size": [
|
||||
176.86483764648438,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -91,152 +171,41 @@
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 369,
|
||||
"type": "PreviewImage",
|
||||
"id": 706,
|
||||
"type": "MaskToImage",
|
||||
"pos": [
|
||||
-1699.1021728515625,
|
||||
-3355.60498046875
|
||||
-1901.433349609375,
|
||||
-3332.2021484375
|
||||
],
|
||||
"size": [
|
||||
660.91162109375,
|
||||
400.2092590332031
|
||||
184.62362670898438,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 612
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 1498
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "PreviewImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 606,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-1911.126708984375,
|
||||
-2916.072998046875
|
||||
],
|
||||
"size": [
|
||||
551.7399291992188,
|
||||
546.8018798828125
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
"outputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"link": 1495
|
||||
"links": [
|
||||
1499
|
||||
]
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "PreviewImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 603,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-1344.1650390625,
|
||||
-2915.117919921875
|
||||
],
|
||||
"size": [
|
||||
601.4136962890625,
|
||||
527.1531372070312
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1236
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "PreviewImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 680,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
-1025.9984130859375,
|
||||
-3357.975341796875
|
||||
],
|
||||
"size": [
|
||||
278.8309020996094,
|
||||
395.84002685546875
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1465
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "SaveImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"ComfyUI"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 701,
|
||||
"type": "MarkdownNote",
|
||||
"pos": [
|
||||
-3330.08984375,
|
||||
-3347.998291015625
|
||||
],
|
||||
"size": [
|
||||
347.055419921875,
|
||||
217.8630828857422
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"title": "Known Issue",
|
||||
"properties": {
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
"### `node_id` not auto-filled → black output\n\nIn some cases, **ComfyUI doesn't auto-fill the `node_id`** when adding a node.\nAs a result, the node may produce a **completely black image** or not work at all.\n\n**Workaround:**\n\n* Search node ID in ComfyUI settings.\n* In NodesMap check \"Enable node ID display\"\n* Manually enter the correct `node_id` (match the ID shown in the UI).\n\n⚠️ This is a known issue and not yet fixed.\nPlease follow the steps above if your output is black or broken."
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
"ver": "0.3.44",
|
||||
"widget_ue_connectable": {},
|
||||
"Node name for S&R": "MaskToImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 697,
|
||||
@@ -250,7 +219,7 @@
|
||||
980.680908203125
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
@@ -266,34 +235,28 @@
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
1496,
|
||||
1497
|
||||
1497,
|
||||
1498
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "Comfyui-Ycanvas",
|
||||
"ver": "f6a491e83bab9481a2cac3367541a3b7803df9ab",
|
||||
"cnr_id": "layerforge",
|
||||
"ver": "22f5d028a2d4c3163014eba4896ef86810d81616",
|
||||
"Node name for S&R": "CanvasNode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
true,
|
||||
17,
|
||||
false,
|
||||
"697",
|
||||
15,
|
||||
"697",
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
612,
|
||||
368,
|
||||
0,
|
||||
369,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1236,
|
||||
442,
|
||||
@@ -326,14 +289,6 @@
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1496,
|
||||
697,
|
||||
1,
|
||||
368,
|
||||
0,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
1497,
|
||||
697,
|
||||
@@ -341,16 +296,32 @@
|
||||
442,
|
||||
1,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
1498,
|
||||
697,
|
||||
1,
|
||||
706,
|
||||
0,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
1499,
|
||||
706,
|
||||
0,
|
||||
369,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.7972024500000005,
|
||||
"scale": 0.9646149645000008,
|
||||
"offset": [
|
||||
3957.401300495613,
|
||||
3455.1487103849176
|
||||
3002.5649125522764,
|
||||
3543.443319064718
|
||||
]
|
||||
},
|
||||
"ue_links": [],
|
||||
BIN
example_workflows/LayerForge_test_simple_workflow.png
Normal file
BIN
example_workflows/LayerForge_test_simple_workflow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 939 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 335 KiB |
@@ -126,7 +126,10 @@ export class BatchPreviewManager {
|
||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.remove('primary');
|
||||
toggleBtn.textContent = "Hide Mask";
|
||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container');
|
||||
if (iconContainer) {
|
||||
iconContainer.style.opacity = '0.5';
|
||||
}
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
@@ -143,6 +146,10 @@ export class BatchPreviewManager {
|
||||
this.worldX -= menuWidthInWorld / 2;
|
||||
this.worldY += paddingInWorld;
|
||||
}
|
||||
// Hide all batch layers initially, then show only the first one
|
||||
this.layers.forEach((layer) => {
|
||||
layer.visible = false;
|
||||
});
|
||||
this._update();
|
||||
}
|
||||
hide() {
|
||||
@@ -161,11 +168,21 @@ export class BatchPreviewManager {
|
||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.add('primary');
|
||||
toggleBtn.textContent = "Show Mask";
|
||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container');
|
||||
if (iconContainer) {
|
||||
iconContainer.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
this.maskWasVisible = false;
|
||||
this.canvas.layers.forEach((l) => l.visible = true);
|
||||
// Only make visible the layers that were part of the batch preview
|
||||
this.layers.forEach((layer) => {
|
||||
layer.visible = true;
|
||||
});
|
||||
// Update the layers panel to reflect visibility changes
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
navigate(direction) {
|
||||
@@ -203,11 +220,22 @@ export class BatchPreviewManager {
|
||||
_focusOnLayer(layer) {
|
||||
if (!layer)
|
||||
return;
|
||||
log.debug(`Focusing on layer ${layer.id}`);
|
||||
// Move the selected layer to the top of the layer stack
|
||||
this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 });
|
||||
this.canvas.updateSelection([layer]);
|
||||
// Render is called by moveLayers, but we call it again to be safe
|
||||
log.debug(`Focusing on layer ${layer.id} using visibility toggle`);
|
||||
// Hide all batch layers first
|
||||
this.layers.forEach((l) => {
|
||||
l.visible = false;
|
||||
});
|
||||
// Show only the current layer
|
||||
layer.visible = true;
|
||||
// Deselect only this layer if it is selected
|
||||
const selected = this.canvas.canvasSelection.selectedLayers;
|
||||
if (selected && selected.includes(layer)) {
|
||||
this.canvas.updateSelection(selected.filter((l) => l !== layer));
|
||||
}
|
||||
// Update the layers panel to reflect visibility changes
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
|
||||
86
js/Canvas.js
86
js/Canvas.js
@@ -1,6 +1,8 @@
|
||||
// @ts-ignore
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { MaskTool } from "./MaskTool.js";
|
||||
import { ShapeTool } from "./ShapeTool.js";
|
||||
import { CustomShapeMenu } from "./CustomShapeMenu.js";
|
||||
import { CanvasState } from "./CanvasState.js";
|
||||
import { CanvasInteractions } from "./CanvasInteractions.js";
|
||||
import { CanvasLayers } from "./CanvasLayers.js";
|
||||
@@ -10,8 +12,8 @@ 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 { debounce, createCanvas } from "./utils/CommonUtils.js";
|
||||
import { MaskEditorIntegration } from "./MaskEditorIntegration.js";
|
||||
import { CanvasSelection } from "./CanvasSelection.js";
|
||||
const useChainCallback = (original, next) => {
|
||||
if (original === undefined || original === null) {
|
||||
@@ -36,32 +38,57 @@ export class Canvas {
|
||||
constructor(node, widget, callbacks = {}) {
|
||||
this.node = node;
|
||||
this.widget = widget;
|
||||
this.canvas = document.createElement('canvas');
|
||||
const ctx = this.canvas.getContext('2d', { willReadFrequently: true });
|
||||
const { canvas, ctx } = createCanvas(0, 0, '2d', { willReadFrequently: true });
|
||||
if (!ctx)
|
||||
throw new Error("Could not create canvas context");
|
||||
this.canvas = canvas;
|
||||
this.ctx = ctx;
|
||||
this.width = 512;
|
||||
this.height = 512;
|
||||
this.layers = [];
|
||||
this.onStateChange = callbacks.onStateChange;
|
||||
this.onHistoryChange = callbacks.onHistoryChange;
|
||||
this.onViewportChange = null;
|
||||
this.lastMousePosition = { x: 0, y: 0 };
|
||||
this.viewport = {
|
||||
x: -(this.width / 4),
|
||||
y: -(this.height / 4),
|
||||
x: -(this.width / 1.5),
|
||||
y: -(this.height / 2),
|
||||
zoom: 0.8,
|
||||
};
|
||||
this.offscreenCanvas = document.createElement('canvas');
|
||||
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
|
||||
alpha: false
|
||||
const { canvas: offscreenCanvas, ctx: offscreenCtx } = createCanvas(0, 0, '2d', {
|
||||
alpha: false,
|
||||
willReadFrequently: true
|
||||
});
|
||||
this.offscreenCanvas = offscreenCanvas;
|
||||
this.offscreenCtx = offscreenCtx;
|
||||
this.canvasContainer = null;
|
||||
this.dataInitialized = false;
|
||||
this.pendingDataCheck = null;
|
||||
this.imageCache = new Map();
|
||||
this.requestSaveState = () => { };
|
||||
this.outputAreaShape = null;
|
||||
this.autoApplyShapeMask = false;
|
||||
this.shapeMaskExpansion = false;
|
||||
this.shapeMaskExpansionValue = 0;
|
||||
this.shapeMaskFeather = false;
|
||||
this.shapeMaskFeatherValue = 0;
|
||||
this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
this.outputAreaExtensionEnabled = false;
|
||||
this.outputAreaExtensionPreview = null;
|
||||
this.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
this.originalCanvasSize = { width: this.width, height: this.height };
|
||||
this.originalOutputAreaPosition = { x: -(this.width / 4), y: -(this.height / 4) };
|
||||
// Initialize outputAreaBounds centered in viewport, similar to how canvas resize/move work
|
||||
this.outputAreaBounds = {
|
||||
x: -(this.width / 4),
|
||||
y: -(this.height / 4),
|
||||
width: this.width,
|
||||
height: this.height
|
||||
};
|
||||
this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange });
|
||||
this.canvasMask = new CanvasMask(this);
|
||||
this.shapeTool = new ShapeTool(this);
|
||||
this.customShapeMenu = new CustomShapeMenu(this);
|
||||
this.maskEditorIntegration = new MaskEditorIntegration(this);
|
||||
this.canvasState = new CanvasState(this);
|
||||
this.canvasSelection = new CanvasSelection(this);
|
||||
this.canvasInteractions = new CanvasInteractions(this);
|
||||
@@ -274,12 +301,6 @@ export class Canvas {
|
||||
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.
|
||||
@@ -294,6 +315,9 @@ export class Canvas {
|
||||
updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) {
|
||||
return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||
}
|
||||
defineOutputAreaWithShape(shape) {
|
||||
this.canvasInteractions.defineOutputAreaWithShape(shape);
|
||||
}
|
||||
/**
|
||||
* Zmienia rozmiar obszaru wyjściowego
|
||||
* @param {number} width - Nowa szerokość
|
||||
@@ -301,7 +325,17 @@ export class Canvas {
|
||||
* @param {boolean} saveHistory - Czy zapisać w historii
|
||||
*/
|
||||
updateOutputAreaSize(width, height, saveHistory = true) {
|
||||
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
|
||||
const result = this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
|
||||
// Update mask canvas to ensure it covers the new output area
|
||||
this.maskTool.updateMaskCanvasForOutputArea();
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Ustawia nowy rozmiar output area zgodnie z nowym systemem (resetuje rozszerzenia, pozycję, rozmiar)
|
||||
* (Fasada: deleguje do CanvasLayers)
|
||||
*/
|
||||
setOutputAreaSize(width, height) {
|
||||
this.canvasLayers.setOutputAreaSize(width, height);
|
||||
}
|
||||
/**
|
||||
* Eksportuje spłaszczony canvas jako blob
|
||||
@@ -333,17 +367,17 @@ export class Canvas {
|
||||
lastExecutionStartTime = Date.now();
|
||||
// Store a snapshot of the context for the upcoming batch
|
||||
this.pendingBatchContext = {
|
||||
// For the menu position
|
||||
// For the menu position - position relative to outputAreaBounds, not canvas center
|
||||
spawnPosition: {
|
||||
x: this.width / 2,
|
||||
y: this.height
|
||||
x: this.outputAreaBounds.x + this.outputAreaBounds.width / 2,
|
||||
y: this.outputAreaBounds.y + this.outputAreaBounds.height
|
||||
},
|
||||
// For the image placement
|
||||
// For the image placement - use actual outputAreaBounds instead of hardcoded (0,0)
|
||||
outputArea: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.width,
|
||||
height: this.height
|
||||
x: this.outputAreaBounds.x,
|
||||
y: this.outputAreaBounds.y,
|
||||
width: this.outputAreaBounds.width,
|
||||
height: this.outputAreaBounds.height
|
||||
}
|
||||
};
|
||||
log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext);
|
||||
@@ -385,7 +419,7 @@ export class Canvas {
|
||||
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
|
||||
*/
|
||||
async startMaskEditor(predefinedMask = null, sendCleanImage = true) {
|
||||
return this.canvasMask.startMaskEditor(predefinedMask, sendCleanImage);
|
||||
return this.maskEditorIntegration.startMaskEditor(predefinedMask, sendCleanImage);
|
||||
}
|
||||
/**
|
||||
* Inicjalizuje podstawowe właściwości canvas
|
||||
|
||||
299
js/CanvasIO.js
299
js/CanvasIO.js
@@ -1,5 +1,6 @@
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||
import { webSocketManager } from "./utils/WebSocketManager.js";
|
||||
const log = createModuleLogger('CanvasIO');
|
||||
export class CanvasIO {
|
||||
@@ -49,10 +50,9 @@ export class CanvasIO {
|
||||
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 });
|
||||
const originalShape = this.canvas.outputAreaShape;
|
||||
this.canvas.outputAreaShape = null;
|
||||
const { canvas: visibilityCanvas, ctx: visibilityCtx } = createCanvas(this.canvas.width, this.canvas.height, '2d', { alpha: true });
|
||||
if (!visibilityCtx)
|
||||
throw new Error("Could not create visibility context");
|
||||
if (!maskCtx)
|
||||
@@ -74,43 +74,37 @@ export class CanvasIO {
|
||||
maskData.data[i + 3] = 255;
|
||||
}
|
||||
maskCtx.putImageData(maskData, 0, 0);
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||
this.canvas.outputAreaShape = originalShape;
|
||||
// Use optimized getMaskForOutputArea() instead of getMask() for better performance
|
||||
// This only processes chunks that overlap with the output area
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea();
|
||||
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
|
||||
);
|
||||
log.debug(`Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height}) instead of full mask`);
|
||||
// The optimized mask is already sized and positioned for the output area
|
||||
// So we can draw it directly without complex positioning calculations
|
||||
const tempMaskData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height);
|
||||
if (tempMaskData) {
|
||||
// Ensure the mask data is in the correct format (white with alpha)
|
||||
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;
|
||||
}
|
||||
// Create a temporary canvas to hold the processed mask
|
||||
const { canvas: tempMaskCanvas, ctx: tempMaskCtx } = createCanvas(this.canvas.width, this.canvas.height, '2d', { willReadFrequently: true });
|
||||
if (!tempMaskCtx)
|
||||
throw new Error("Could not create temp mask context");
|
||||
// Put the processed mask data into a canvas that matches the output area size
|
||||
const { canvas: outputMaskCanvas, ctx: outputMaskCtx } = createCanvas(toolMaskCanvas.width, toolMaskCanvas.height, '2d', { willReadFrequently: true });
|
||||
if (!outputMaskCtx)
|
||||
throw new Error("Could not create output mask context");
|
||||
outputMaskCtx.putImageData(tempMaskData, 0, 0);
|
||||
// Draw the optimized mask at the correct position (output area bounds)
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
tempMaskCtx.drawImage(outputMaskCanvas, bounds.x, bounds.y);
|
||||
maskCtx.globalCompositeOperation = 'source-over';
|
||||
maskCtx.drawImage(tempMaskCanvas, 0, 0);
|
||||
}
|
||||
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');
|
||||
@@ -201,67 +195,32 @@ export class CanvasIO {
|
||||
});
|
||||
}
|
||||
async _renderOutputData() {
|
||||
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 });
|
||||
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
|
||||
// Użyj zunifikowanych funkcji z CanvasLayers
|
||||
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
|
||||
if (!imageBlob || !maskBlob) {
|
||||
throw new Error("Failed to generate canvas or mask blobs");
|
||||
}
|
||||
// Konwertuj blob na data URL
|
||||
const imageDataUrl = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(imageBlob);
|
||||
});
|
||||
const maskDataUrl = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(maskBlob);
|
||||
});
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
log.info(`=== OUTPUT DATA GENERATED ===`);
|
||||
log.info(`Image size: ${bounds.width}x${bounds.height}`);
|
||||
log.info(`Image data URL length: ${imageDataUrl.length}`);
|
||||
log.info(`Mask data URL length: ${maskDataUrl.length}`);
|
||||
return { image: imageDataUrl, mask: maskDataUrl };
|
||||
}
|
||||
async sendDataViaWebSocket(nodeId) {
|
||||
log.info(`Preparing to send data for node ${nodeId} via WebSocket.`);
|
||||
@@ -296,10 +255,11 @@ export class CanvasIO {
|
||||
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 bounds = this.canvas.outputAreaBounds;
|
||||
const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.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,
|
||||
x: bounds.x + (bounds.width - inputImage.width * scale) / 2,
|
||||
y: bounds.y + (bounds.height - inputImage.height * scale) / 2,
|
||||
width: inputImage.width * scale,
|
||||
height: inputImage.height * scale,
|
||||
});
|
||||
@@ -320,12 +280,9 @@ export class CanvasIO {
|
||||
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 });
|
||||
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '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) => {
|
||||
@@ -445,7 +402,8 @@ export class CanvasIO {
|
||||
originalWidth: image.width,
|
||||
originalHeight: image.height,
|
||||
blendMode: 'normal',
|
||||
opacity: 1
|
||||
opacity: 1,
|
||||
visible: true
|
||||
};
|
||||
this.canvas.layers.push(layer);
|
||||
this.canvas.updateSelection([layer]);
|
||||
@@ -498,10 +456,7 @@ export class CanvasIO {
|
||||
}
|
||||
async createImageFromData(imageData) {
|
||||
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 });
|
||||
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
|
||||
if (!ctx)
|
||||
throw new Error("Could not create canvas context");
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
@@ -511,21 +466,6 @@ export class CanvasIO {
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
async retryDataLoad(maxRetries = 3, delay = 1000) {
|
||||
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) {
|
||||
try {
|
||||
if (!maskData)
|
||||
@@ -548,72 +488,6 @@ export class CanvasIO {
|
||||
log.error("Error processing mask data:", error);
|
||||
}
|
||||
}
|
||||
async loadImageFromCache(base64Data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = base64Data;
|
||||
});
|
||||
}
|
||||
async importImage(cacheData) {
|
||||
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 = {
|
||||
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() {
|
||||
try {
|
||||
log.info("Fetching latest image from server...");
|
||||
@@ -637,7 +511,7 @@ export class CanvasIO {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error importing latest image:", error);
|
||||
alert(`Failed to import latest image: ${error.message}`);
|
||||
showErrorNotification(`Failed to import latest image: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -656,7 +530,12 @@ export class CanvasIO {
|
||||
img.onerror = reject;
|
||||
img.src = imageData;
|
||||
});
|
||||
const newLayer = await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit', targetArea);
|
||||
let processedImage = img;
|
||||
// If there's a custom shape, clip the image to that shape
|
||||
if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) {
|
||||
processedImage = await this.clipImageToShape(img, this.canvas.outputAreaShape);
|
||||
}
|
||||
const newLayer = await this.canvas.canvasLayers.addLayerWithImage(processedImage, {}, 'fit', targetArea);
|
||||
newLayers.push(newLayer);
|
||||
}
|
||||
log.info("All new images imported and placed on canvas successfully.");
|
||||
@@ -672,8 +551,38 @@ export class CanvasIO {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error importing latest images:", error);
|
||||
alert(`Failed to import latest images: ${error.message}`);
|
||||
showErrorNotification(`Failed to import latest images: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
async clipImageToShape(image, shape) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { canvas, ctx } = createCanvas(image.width, image.height);
|
||||
if (!ctx) {
|
||||
reject(new Error("Could not create canvas context for clipping"));
|
||||
return;
|
||||
}
|
||||
// Draw the image first
|
||||
ctx.drawImage(image, 0, 0);
|
||||
// Calculate custom shape position accounting for extensions
|
||||
// Custom shape should maintain its relative position within the original canvas area
|
||||
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
||||
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
||||
// Create a clipping mask using the shape with extension offset
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
||||
for (let i = 1; i < shape.points.length; i++) {
|
||||
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
// Create a new image from the clipped canvas
|
||||
const clippedImage = new Image();
|
||||
clippedImage.onload = () => resolve(clippedImage);
|
||||
clippedImage.onerror = () => reject(new Error("Failed to create clipped image"));
|
||||
clippedImage.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export class CanvasInteractions {
|
||||
canvasResizeStart: { x: 0, y: 0 },
|
||||
isCtrlPressed: false,
|
||||
isAltPressed: false,
|
||||
isShiftPressed: false,
|
||||
isSPressed: false,
|
||||
hasClonedInDrag: false,
|
||||
lastClickTime: 0,
|
||||
transformingLayer: null,
|
||||
@@ -23,6 +25,44 @@ export class CanvasInteractions {
|
||||
};
|
||||
this.originalLayerPositions = new Map();
|
||||
}
|
||||
// Helper functions to eliminate code duplication
|
||||
getMouseCoordinates(e) {
|
||||
return {
|
||||
world: this.canvas.getMouseWorldCoordinates(e),
|
||||
view: this.canvas.getMouseViewCoordinates(e)
|
||||
};
|
||||
}
|
||||
preventEventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
performZoomOperation(worldCoords, zoomFactor) {
|
||||
const rect = this.canvas.canvas.getBoundingClientRect();
|
||||
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
|
||||
this.canvas.viewport.zoom = 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.onViewportChange?.();
|
||||
}
|
||||
renderAndSave(shouldSave = false) {
|
||||
this.canvas.render();
|
||||
if (shouldSave) {
|
||||
this.canvas.saveState();
|
||||
this.canvas.canvasState.saveStateToDB();
|
||||
}
|
||||
}
|
||||
setDragDropStyling(active) {
|
||||
if (active) {
|
||||
this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)';
|
||||
this.canvas.canvas.style.border = '2px dashed #2d5aa0';
|
||||
}
|
||||
else {
|
||||
this.canvas.canvas.style.backgroundColor = '';
|
||||
this.canvas.canvas.style.border = '';
|
||||
}
|
||||
}
|
||||
setupEventListeners() {
|
||||
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
|
||||
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
|
||||
@@ -31,6 +71,8 @@ export class CanvasInteractions {
|
||||
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this), { passive: false });
|
||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
|
||||
// Add a blur event listener to the window to reset key states
|
||||
window.addEventListener('blur', this.handleBlur.bind(this));
|
||||
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
||||
this.canvas.canvas.addEventListener('mouseenter', (e) => {
|
||||
this.canvas.isMouseOver = true;
|
||||
@@ -46,6 +88,29 @@ export class CanvasInteractions {
|
||||
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
|
||||
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
|
||||
}
|
||||
/**
|
||||
* Sprawdza czy punkt znajduje się w obszarze któregokolwiek z zaznaczonych layerów
|
||||
*/
|
||||
isPointInSelectedLayers(worldX, worldY) {
|
||||
for (const layer of this.canvas.canvasSelection.selectedLayers) {
|
||||
if (!layer.visible)
|
||||
continue;
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
// Przekształć punkt do lokalnego układu współrzędnych layera
|
||||
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);
|
||||
// Sprawdź czy punkt jest wewnątrz prostokąta layera
|
||||
if (Math.abs(rotatedX) <= layer.width / 2 &&
|
||||
Math.abs(rotatedY) <= layer.height / 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
resetInteractionState() {
|
||||
this.interaction.mode = 'none';
|
||||
this.interaction.resizeHandle = null;
|
||||
@@ -58,29 +123,43 @@ export class CanvasInteractions {
|
||||
}
|
||||
handleMouseDown(e) {
|
||||
this.canvas.canvas.focus();
|
||||
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
|
||||
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
if (this.canvas.shapeTool.isActive) {
|
||||
this.canvas.shapeTool.addPoint(coords.world);
|
||||
return;
|
||||
}
|
||||
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||
if (e.shiftKey && e.ctrlKey) {
|
||||
this.startCanvasMove(worldCoords);
|
||||
this.startCanvasMove(coords.world);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
this.startCanvasResize(worldCoords);
|
||||
// Clear custom shape when starting canvas resize
|
||||
if (this.canvas.outputAreaShape) {
|
||||
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
log.info("Removing shape mask before clearing custom shape for canvas resize");
|
||||
this.canvas.maskTool.removeShapeMask();
|
||||
}
|
||||
this.canvas.outputAreaShape = null;
|
||||
this.canvas.render();
|
||||
}
|
||||
this.startCanvasResize(coords.world);
|
||||
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);
|
||||
this.preventEventDefaults(e);
|
||||
// Sprawdź czy kliknięto w obszarze któregokolwiek z zaznaczonych layerów (niezależnie od przykrycia)
|
||||
if (this.isPointInSelectedLayers(coords.world.x, coords.world.y)) {
|
||||
// Nowa logika przekazuje tylko współrzędne świata, menu pozycjonuje się samo
|
||||
this.canvas.canvasLayers.showBlendModeMenu(coords.world.x, coords.world.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -89,27 +168,26 @@ export class CanvasInteractions {
|
||||
return;
|
||||
}
|
||||
// 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(coords.world.x, coords.world.y);
|
||||
if (transformTarget) {
|
||||
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
|
||||
this.startLayerTransform(transformTarget.layer, transformTarget.handle, coords.world);
|
||||
return;
|
||||
}
|
||||
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
||||
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y);
|
||||
if (clickedLayerResult) {
|
||||
this.prepareForDrag(clickedLayerResult.layer, worldCoords);
|
||||
this.prepareForDrag(clickedLayerResult.layer, coords.world);
|
||||
return;
|
||||
}
|
||||
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||
this.startPanningOrClearSelection(e);
|
||||
}
|
||||
handleMouseMove(e) {
|
||||
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||
this.canvas.lastMousePosition = worldCoords; // Zawsze aktualizuj ostatnią pozycję myszy
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
this.canvas.lastMousePosition = coords.world; // 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;
|
||||
const dx = coords.world.x - this.interaction.dragStart.x;
|
||||
const dy = coords.world.y - this.interaction.dragStart.y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli
|
||||
this.interaction.mode = 'dragging';
|
||||
this.originalLayerPositions.clear();
|
||||
@@ -120,36 +198,40 @@ export class CanvasInteractions {
|
||||
}
|
||||
switch (this.interaction.mode) {
|
||||
case 'drawingMask':
|
||||
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
|
||||
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
||||
this.canvas.render();
|
||||
break;
|
||||
case 'panning':
|
||||
this.panViewport(e);
|
||||
break;
|
||||
case 'dragging':
|
||||
this.dragLayers(worldCoords);
|
||||
this.dragLayers(coords.world);
|
||||
break;
|
||||
case 'resizing':
|
||||
this.resizeLayerFromHandle(worldCoords, e.shiftKey);
|
||||
this.resizeLayerFromHandle(coords.world, e.shiftKey);
|
||||
break;
|
||||
case 'rotating':
|
||||
this.rotateLayerFromHandle(worldCoords, e.shiftKey);
|
||||
this.rotateLayerFromHandle(coords.world, e.shiftKey);
|
||||
break;
|
||||
case 'resizingCanvas':
|
||||
this.updateCanvasResize(worldCoords);
|
||||
this.updateCanvasResize(coords.world);
|
||||
break;
|
||||
case 'movingCanvas':
|
||||
this.updateCanvasMove(worldCoords);
|
||||
this.updateCanvasMove(coords.world);
|
||||
break;
|
||||
default:
|
||||
this.updateCursor(worldCoords);
|
||||
this.updateCursor(coords.world);
|
||||
break;
|
||||
}
|
||||
// --- DYNAMICZNY PODGLĄD LINII CUSTOM SHAPE ---
|
||||
if (this.canvas.shapeTool.isActive && !this.canvas.shapeTool.shape.isClosed) {
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
handleMouseUp(e) {
|
||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseUp(viewCoords);
|
||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
@@ -159,22 +241,40 @@ export class CanvasInteractions {
|
||||
if (this.interaction.mode === 'movingCanvas') {
|
||||
this.finalizeCanvasMove();
|
||||
}
|
||||
// Log layer positions when dragging ends
|
||||
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
this.logDragCompletion(coords);
|
||||
}
|
||||
// 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.renderAndSave(true);
|
||||
}
|
||||
this.resetInteractionState();
|
||||
this.canvas.render();
|
||||
}
|
||||
logDragCompletion(coords) {
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
log.info("=== LAYER DRAG COMPLETED ===");
|
||||
log.info(`Mouse position: world(${coords.world.x.toFixed(1)}, ${coords.world.y.toFixed(1)}) view(${coords.view.x.toFixed(1)}, ${coords.view.y.toFixed(1)})`);
|
||||
log.info(`Output Area Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`);
|
||||
log.info(`Viewport: x=${this.canvas.viewport.x.toFixed(1)}, y=${this.canvas.viewport.y.toFixed(1)}, zoom=${this.canvas.viewport.zoom.toFixed(2)}`);
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer, index) => {
|
||||
const relativeToOutput = {
|
||||
x: layer.x - bounds.x,
|
||||
y: layer.y - bounds.y
|
||||
};
|
||||
log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_output(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`);
|
||||
});
|
||||
log.info("=== END LAYER DRAG ===");
|
||||
}
|
||||
handleMouseLeave(e) {
|
||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
this.canvas.maskTool.handleMouseLeave();
|
||||
if (this.canvas.maskTool.isDrawing) {
|
||||
this.canvas.maskTool.handleMouseUp(viewCoords);
|
||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||
}
|
||||
this.canvas.render();
|
||||
return;
|
||||
@@ -194,109 +294,119 @@ export class CanvasInteractions {
|
||||
}
|
||||
}
|
||||
handleContextMenu(e) {
|
||||
// Always prevent browser context menu - we handle all right-click interactions ourselves
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
handleWheel(e) {
|
||||
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);
|
||||
this.preventEventDefaults(e);
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
if (this.canvas.maskTool.isActive || this.canvas.canvasSelection.selectedLayers.length === 0) {
|
||||
// Zoom operation for mask tool or when no layers selected
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.performZoomOperation(coords.world, zoomFactor);
|
||||
}
|
||||
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);
|
||||
// Layer transformation when layers are selected
|
||||
this.handleLayerWheelTransformation(e);
|
||||
}
|
||||
this.canvas.render();
|
||||
if (!this.canvas.maskTool.isActive) {
|
||||
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
|
||||
this.canvas.requestSaveState();
|
||||
}
|
||||
}
|
||||
handleLayerWheelTransformation(e) {
|
||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||
if (e.shiftKey) {
|
||||
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
|
||||
}
|
||||
else {
|
||||
this.handleLayerScaling(layer, e.ctrlKey, e.deltaY);
|
||||
}
|
||||
});
|
||||
}
|
||||
handleLayerRotation(layer, isCtrlPressed, direction, rotationStep) {
|
||||
if (isCtrlPressed) {
|
||||
// Snap to absolute values
|
||||
const snapAngle = 5;
|
||||
if (direction > 0) {
|
||||
layer.rotation = Math.ceil((layer.rotation + 0.1) / snapAngle) * snapAngle;
|
||||
}
|
||||
else {
|
||||
layer.rotation = Math.floor((layer.rotation - 0.1) / snapAngle) * snapAngle;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Fixed step rotation
|
||||
layer.rotation += rotationStep;
|
||||
}
|
||||
}
|
||||
handleLayerScaling(layer, isCtrlPressed, deltaY) {
|
||||
const oldWidth = layer.width;
|
||||
const oldHeight = layer.height;
|
||||
let scaleFactor;
|
||||
if (isCtrlPressed) {
|
||||
const direction = deltaY > 0 ? -1 : 1;
|
||||
const baseDimension = Math.max(layer.width, layer.height);
|
||||
const newBaseDimension = baseDimension + direction;
|
||||
if (newBaseDimension < 10)
|
||||
return;
|
||||
scaleFactor = newBaseDimension / baseDimension;
|
||||
}
|
||||
else {
|
||||
scaleFactor = this.calculateGridBasedScaling(oldHeight, deltaY);
|
||||
}
|
||||
if (scaleFactor && isFinite(scaleFactor)) {
|
||||
layer.width *= scaleFactor;
|
||||
layer.height *= scaleFactor;
|
||||
layer.x += (oldWidth - layer.width) / 2;
|
||||
layer.y += (oldHeight - layer.height) / 2;
|
||||
}
|
||||
}
|
||||
calculateGridBasedScaling(oldHeight, deltaY) {
|
||||
const gridSize = 64;
|
||||
const direction = 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 0;
|
||||
}
|
||||
return targetHeight / oldHeight;
|
||||
}
|
||||
handleKeyDown(e) {
|
||||
if (e.key === 'Control')
|
||||
this.interaction.isCtrlPressed = true;
|
||||
if (e.key === 'Shift')
|
||||
this.interaction.isShiftPressed = true;
|
||||
if (e.key === 'Alt') {
|
||||
this.interaction.isAltPressed = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key.toLowerCase() === 's') {
|
||||
this.interaction.isSPressed = true;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
// Check if Shift+S is being held down
|
||||
if (this.interaction.isShiftPressed && this.interaction.isSPressed && !this.interaction.isCtrlPressed && !this.canvas.shapeTool.isActive) {
|
||||
this.canvas.shapeTool.activate();
|
||||
return;
|
||||
}
|
||||
// Globalne skróty (Undo/Redo/Copy/Paste)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
let handled = true;
|
||||
@@ -365,14 +475,45 @@ export class CanvasInteractions {
|
||||
handleKeyUp(e) {
|
||||
if (e.key === 'Control')
|
||||
this.interaction.isCtrlPressed = false;
|
||||
if (e.key === 'Shift')
|
||||
this.interaction.isShiftPressed = false;
|
||||
if (e.key === 'Alt')
|
||||
this.interaction.isAltPressed = false;
|
||||
if (e.key.toLowerCase() === 's')
|
||||
this.interaction.isSPressed = false;
|
||||
// Deactivate shape tool when Shift or S is released
|
||||
if (this.canvas.shapeTool.isActive && (!this.interaction.isShiftPressed || !this.interaction.isSPressed)) {
|
||||
this.canvas.shapeTool.deactivate();
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
handleBlur() {
|
||||
log.debug('Window lost focus, resetting key states.');
|
||||
this.interaction.isCtrlPressed = false;
|
||||
this.interaction.isAltPressed = false;
|
||||
this.interaction.isShiftPressed = false;
|
||||
this.interaction.isSPressed = false;
|
||||
this.interaction.keyMovementInProgress = false;
|
||||
// Deactivate shape tool when window loses focus
|
||||
if (this.canvas.shapeTool.isActive) {
|
||||
this.canvas.shapeTool.deactivate();
|
||||
}
|
||||
// Also reset any interaction that relies on a key being held down
|
||||
if (this.interaction.mode === 'dragging' && this.interaction.hasClonedInDrag) {
|
||||
// If we were in the middle of a cloning drag, finalize it
|
||||
this.canvas.saveState();
|
||||
this.canvas.canvasState.saveStateToDB();
|
||||
}
|
||||
// Reset interaction mode if it's something that can get "stuck"
|
||||
if (this.interaction.mode !== 'none' && this.interaction.mode !== 'drawingMask') {
|
||||
this.resetInteractionState();
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
updateCursor(worldCoords) {
|
||||
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||
if (transformTarget) {
|
||||
@@ -456,60 +597,34 @@ export class CanvasInteractions {
|
||||
startCanvasMove(worldCoords) {
|
||||
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) {
|
||||
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);
|
||||
// Po prostu przesuwamy outputAreaBounds
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
this.interaction.canvasMoveRect = {
|
||||
x: snapToGrid(bounds.x + dx),
|
||||
y: snapToGrid(bounds.y + dy),
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
};
|
||||
this.canvas.render();
|
||||
}
|
||||
finalizeCanvasMove() {
|
||||
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.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) => {
|
||||
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;
|
||||
if (moveRect) {
|
||||
// Po prostu aktualizujemy outputAreaBounds na nową pozycję
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: moveRect.x,
|
||||
y: moveRect.y,
|
||||
width: moveRect.width,
|
||||
height: moveRect.height
|
||||
};
|
||||
// Update mask canvas to ensure it covers the new output area position
|
||||
this.canvas.maskTool.updateMaskCanvasForOutputArea();
|
||||
}
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
@@ -528,6 +643,7 @@ export class CanvasInteractions {
|
||||
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||
this.canvas.render();
|
||||
this.canvas.onViewportChange?.();
|
||||
}
|
||||
dragLayers(worldCoords) {
|
||||
if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
@@ -665,71 +781,46 @@ export class CanvasInteractions {
|
||||
const newHeight = Math.round(this.interaction.canvasResizeRect.height);
|
||||
const finalX = this.interaction.canvasResizeRect.x;
|
||||
const finalY = this.interaction.canvasResizeRect.y;
|
||||
// Po prostu aktualizujemy outputAreaBounds na nowy obszar
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
this.canvas.updateOutputAreaSize(newWidth, newHeight);
|
||||
this.canvas.layers.forEach((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) => {
|
||||
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();
|
||||
}
|
||||
handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||
this.preventEventDefaults(e);
|
||||
if (e.dataTransfer)
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
handleDragEnter(e) {
|
||||
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';
|
||||
this.preventEventDefaults(e);
|
||||
this.setDragDropStyling(true);
|
||||
}
|
||||
handleDragLeave(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||
this.preventEventDefaults(e);
|
||||
if (!this.canvas.canvas.contains(e.relatedTarget)) {
|
||||
this.canvas.canvas.style.backgroundColor = '';
|
||||
this.canvas.canvas.style.border = '';
|
||||
this.setDragDropStyling(false);
|
||||
}
|
||||
}
|
||||
async handleDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow
|
||||
this.preventEventDefaults(e);
|
||||
log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading");
|
||||
this.canvas.canvas.style.backgroundColor = '';
|
||||
this.canvas.canvas.style.border = '';
|
||||
this.setDragDropStyling(false);
|
||||
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})`);
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
log.info(`Dropped ${files.length} file(s) onto canvas at position (${coords.world.x}, ${coords.world.y})`);
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
try {
|
||||
await this.loadDroppedImageFile(file, worldCoords);
|
||||
await this.loadDroppedImageFile(file, coords.world);
|
||||
log.info(`Successfully loaded dropped image: ${file.name}`);
|
||||
}
|
||||
catch (error) {
|
||||
@@ -762,6 +853,68 @@ export class CanvasInteractions {
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
defineOutputAreaWithShape(shape) {
|
||||
const boundingBox = this.canvas.shapeTool.getBoundingBox();
|
||||
if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) {
|
||||
this.canvas.saveState();
|
||||
// If there's an existing custom shape and auto-apply shape mask is enabled, remove the previous mask
|
||||
if (this.canvas.outputAreaShape && this.canvas.autoApplyShapeMask) {
|
||||
log.info("Removing previous shape mask before defining new custom shape");
|
||||
this.canvas.maskTool.removeShapeMask();
|
||||
}
|
||||
this.canvas.outputAreaShape = {
|
||||
...shape,
|
||||
points: shape.points.map((p) => ({
|
||||
x: p.x - boundingBox.x,
|
||||
y: p.y - boundingBox.y
|
||||
}))
|
||||
};
|
||||
const newWidth = Math.round(boundingBox.width);
|
||||
const newHeight = Math.round(boundingBox.height);
|
||||
const newX = Math.round(boundingBox.x);
|
||||
const newY = Math.round(boundingBox.y);
|
||||
// Store the original canvas size for extension calculations
|
||||
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
|
||||
// Store the original position where custom shape was drawn for extension calculations
|
||||
this.canvas.originalOutputAreaPosition = { x: newX, y: newY };
|
||||
// If extensions are enabled, we need to recalculate outputAreaBounds with current extensions
|
||||
if (this.canvas.outputAreaExtensionEnabled) {
|
||||
const ext = this.canvas.outputAreaExtensions;
|
||||
const extendedWidth = newWidth + ext.left + ext.right;
|
||||
const extendedHeight = newHeight + ext.top + ext.bottom;
|
||||
// Update canvas size with extensions
|
||||
this.canvas.updateOutputAreaSize(extendedWidth, extendedHeight, false);
|
||||
// Set outputAreaBounds accounting for extensions
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: newX - ext.left, // Adjust position by left extension
|
||||
y: newY - ext.top, // Adjust position by top extension
|
||||
width: extendedWidth,
|
||||
height: extendedHeight
|
||||
};
|
||||
log.info(`New custom shape with extensions: original(${newX}, ${newY}) extended(${newX - ext.left}, ${newY - ext.top}) size(${extendedWidth}x${extendedHeight})`);
|
||||
}
|
||||
else {
|
||||
// No extensions - use original size and position
|
||||
this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: newX,
|
||||
y: newY,
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
log.info(`New custom shape without extensions: position(${newX}, ${newY}) size(${newWidth}x${newHeight})`);
|
||||
}
|
||||
// Update mask canvas to ensure it covers the new output area position
|
||||
this.canvas.maskTool.updateMaskCanvasForOutputArea();
|
||||
// If auto-apply shape mask is enabled, automatically apply the mask with current settings
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
log.info("Auto-applying shape mask to new custom shape with current settings");
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
}
|
||||
this.canvas.saveState();
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
async handlePasteEvent(e) {
|
||||
const shouldHandle = this.canvas.isMouseOver ||
|
||||
this.canvas.canvas.contains(document.activeElement) ||
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { saveImage } from "./db.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { generateUUID, generateUniqueFileName } from "./utils/CommonUtils.js";
|
||||
import { generateUUID, generateUniqueFileName, createCanvas } from "./utils/CommonUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "./ErrorHandler.js";
|
||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||
// @ts-ignore
|
||||
import { app } from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import { ComfyApp } from "../../scripts/app.js";
|
||||
import { ClipboardManager } from "./utils/ClipboardManager.js";
|
||||
import { createDistanceFieldMaskSync } from "./utils/ImageAnalysis.js";
|
||||
const log = createModuleLogger('CanvasLayers');
|
||||
export class CanvasLayers {
|
||||
constructor(canvas) {
|
||||
this.blendMenuElement = null;
|
||||
this.blendMenuWorldX = 0;
|
||||
this.blendMenuWorldY = 0;
|
||||
this.addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default', targetArea = null) => {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required for layer creation");
|
||||
@@ -21,8 +26,9 @@ export class CanvasLayers {
|
||||
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 };
|
||||
// Use the targetArea if provided, otherwise default to the current output area bounds
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const area = targetArea || { width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y };
|
||||
if (addMode === 'fit') {
|
||||
const scale = Math.min(area.width / image.width, area.height / image.height);
|
||||
finalWidth = image.width * scale;
|
||||
@@ -38,6 +44,10 @@ export class CanvasLayers {
|
||||
finalX = area.x + (area.width - finalWidth) / 2;
|
||||
finalY = area.y + (area.height - finalHeight) / 2;
|
||||
}
|
||||
// Find the highest zIndex among existing layers
|
||||
const maxZIndex = this.canvas.layers.length > 0
|
||||
? Math.max(...this.canvas.layers.map(l => l.zIndex))
|
||||
: -1;
|
||||
const layer = {
|
||||
id: generateUUID(),
|
||||
image: image,
|
||||
@@ -50,11 +60,34 @@ export class CanvasLayers {
|
||||
originalWidth: image.width,
|
||||
originalHeight: image.height,
|
||||
rotation: 0,
|
||||
zIndex: this.canvas.layers.length,
|
||||
zIndex: maxZIndex + 1, // Always add new layer on top
|
||||
blendMode: 'normal',
|
||||
opacity: 1,
|
||||
visible: true,
|
||||
...layerProps
|
||||
};
|
||||
if (layer.mask) {
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height);
|
||||
if (tempCtx) {
|
||||
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
||||
const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(layer.width, layer.height);
|
||||
if (maskCtx) {
|
||||
const maskImageData = maskCtx.createImageData(layer.width, layer.height);
|
||||
for (let i = 0; i < layer.mask.length; i++) {
|
||||
maskImageData.data[i * 4] = 255;
|
||||
maskImageData.data[i * 4 + 1] = 255;
|
||||
maskImageData.data[i * 4 + 2] = 255;
|
||||
maskImageData.data[i * 4 + 3] = layer.mask[i] * 255;
|
||||
}
|
||||
maskCtx.putImageData(maskImageData, 0, 0);
|
||||
tempCtx.globalCompositeOperation = 'destination-in';
|
||||
tempCtx.drawImage(maskCanvas, 0, 0);
|
||||
const newImage = new Image();
|
||||
newImage.src = tempCanvas.toDataURL();
|
||||
layer.image = newImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.canvas.layers.push(layer);
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.canvas.render();
|
||||
@@ -65,8 +98,10 @@ export class CanvasLayers {
|
||||
log.info("Layer added successfully");
|
||||
return layer;
|
||||
}, 'CanvasLayers.addLayerWithImage');
|
||||
this.currentCloseMenuListener = null;
|
||||
this.canvas = canvas;
|
||||
this.clipboardManager = new ClipboardManager(canvas);
|
||||
this.distanceFieldCache = new WeakMap();
|
||||
this.blendModes = [
|
||||
{ name: 'normal', label: 'Normal' },
|
||||
{ name: 'multiply', label: 'Multiply' },
|
||||
@@ -161,12 +196,16 @@ export class CanvasLayers {
|
||||
const { x: mouseX, y: mouseY } = this.canvas.lastMousePosition;
|
||||
const offsetX = mouseX - centerX;
|
||||
const offsetY = mouseY - centerY;
|
||||
this.internalClipboard.forEach((clipboardLayer) => {
|
||||
// Find the highest zIndex among existing layers
|
||||
const maxZIndex = this.canvas.layers.length > 0
|
||||
? Math.max(...this.canvas.layers.map(l => l.zIndex))
|
||||
: -1;
|
||||
this.internalClipboard.forEach((clipboardLayer, index) => {
|
||||
const newLayer = {
|
||||
...clipboardLayer,
|
||||
x: clipboardLayer.x + offsetX,
|
||||
y: clipboardLayer.y + offsetY,
|
||||
zIndex: this.canvas.layers.length
|
||||
zIndex: maxZIndex + 1 + index // Ensure pasted layers maintain their relative order
|
||||
};
|
||||
this.canvas.layers.push(newLayer);
|
||||
newLayers.push(newLayer);
|
||||
@@ -286,6 +325,9 @@ export class CanvasLayers {
|
||||
getLayerAtPosition(worldX, worldY) {
|
||||
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
|
||||
const layer = this.canvas.layers[i];
|
||||
// Skip invisible layers
|
||||
if (!layer.visible)
|
||||
continue;
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const dx = worldX - centerX;
|
||||
@@ -308,8 +350,6 @@ export class CanvasLayers {
|
||||
return;
|
||||
const { offsetX = 0, offsetY = 0 } = options;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = layer.blendMode || '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);
|
||||
@@ -321,12 +361,81 @@ export class CanvasLayers {
|
||||
}
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
// Check if we need to apply blend area effect
|
||||
const blendArea = layer.blendArea ?? 0;
|
||||
const needsBlendAreaEffect = blendArea > 0;
|
||||
if (needsBlendAreaEffect) {
|
||||
log.debug(`Applying blend area effect for layer ${layer.id}, blendArea: ${blendArea}%`);
|
||||
// Get or create distance field mask
|
||||
const maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
|
||||
if (maskCanvas) {
|
||||
// Create a temporary canvas for the masked layer
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height);
|
||||
if (tempCtx) {
|
||||
// Draw the original image
|
||||
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
||||
// Apply the distance field mask using destination-in for transparency effect
|
||||
tempCtx.globalCompositeOperation = 'destination-in';
|
||||
tempCtx.drawImage(maskCanvas, 0, 0, layer.width, layer.height);
|
||||
// Draw the result
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
else {
|
||||
// Fallback to normal drawing
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Fallback to normal drawing
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Normal drawing without blend area effect
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
getDistanceFieldMaskSync(image, blendArea) {
|
||||
// Check cache first
|
||||
let imageCache = this.distanceFieldCache.get(image);
|
||||
if (!imageCache) {
|
||||
imageCache = new Map();
|
||||
this.distanceFieldCache.set(image, imageCache);
|
||||
}
|
||||
let maskCanvas = imageCache.get(blendArea);
|
||||
if (!maskCanvas) {
|
||||
try {
|
||||
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
|
||||
maskCanvas = createDistanceFieldMaskSync(image, blendArea);
|
||||
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
|
||||
imageCache.set(blendArea, maskCanvas);
|
||||
}
|
||||
catch (error) {
|
||||
log.error('Failed to create distance field mask:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
|
||||
}
|
||||
return maskCanvas;
|
||||
}
|
||||
_drawLayers(ctx, layers, options = {}) {
|
||||
const sortedLayers = [...layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach(layer => this._drawLayer(ctx, layer, options));
|
||||
sortedLayers.forEach(layer => {
|
||||
if (layer.visible) {
|
||||
this._drawLayer(ctx, layer, options);
|
||||
}
|
||||
});
|
||||
}
|
||||
drawLayersToContext(ctx, layers, options = {}) {
|
||||
this._drawLayers(ctx, layers, options);
|
||||
@@ -351,12 +460,9 @@ export class CanvasLayers {
|
||||
}
|
||||
async getLayerImageData(layer) {
|
||||
try {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height, '2d', { willReadFrequently: true });
|
||||
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 = {
|
||||
@@ -390,6 +496,36 @@ export class CanvasLayers {
|
||||
this.canvas.canvasState.saveStateToDB();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Ustawia nowy rozmiar output area względem środka, resetuje rozszerzenia.
|
||||
*/
|
||||
setOutputAreaSize(width, height) {
|
||||
// Reset rozszerzeń
|
||||
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
this.canvas.outputAreaExtensionEnabled = false;
|
||||
this.canvas.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
// Oblicz środek obecnego output area
|
||||
const prevBounds = this.canvas.outputAreaBounds;
|
||||
const centerX = prevBounds.x + prevBounds.width / 2;
|
||||
const centerY = prevBounds.y + prevBounds.height / 2;
|
||||
// Nowa pozycja lewego górnego rogu, by środek pozostał w miejscu
|
||||
const newX = centerX - width / 2;
|
||||
const newY = centerY - height / 2;
|
||||
// Ustaw nowy rozmiar bazowy i pozycję
|
||||
this.canvas.originalCanvasSize = { width, height };
|
||||
this.canvas.originalOutputAreaPosition = { x: newX, y: newY };
|
||||
// Ustaw outputAreaBounds na nowy rozmiar i pozycję
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: newX,
|
||||
y: newY,
|
||||
width,
|
||||
height
|
||||
};
|
||||
// Zaktualizuj rozmiar przez istniejącą metodę (ustawia maskę, itp.)
|
||||
this.updateOutputAreaSize(width, height, true);
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
getHandles(layer) {
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
@@ -437,14 +573,70 @@ export class CanvasLayers {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
showBlendModeMenu(x, y) {
|
||||
updateBlendModeMenuPosition() {
|
||||
if (!this.blendMenuElement)
|
||||
return;
|
||||
const screenX = (this.blendMenuWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (this.blendMenuWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
this.blendMenuElement.style.transform = `translate(${screenX}px, ${screenY}px)`;
|
||||
}
|
||||
showBlendModeMenu(worldX, worldY) {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Find which selected layer is at the click position (topmost visible layer at that position)
|
||||
let selectedLayer = null;
|
||||
const visibleSelectedLayers = this.canvas.canvasSelection.selectedLayers.filter((layer) => layer.visible);
|
||||
if (visibleSelectedLayers.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Sort by zIndex descending and find the first one that contains the click point
|
||||
const sortedLayers = visibleSelectedLayers.sort((a, b) => b.zIndex - a.zIndex);
|
||||
for (const layer of sortedLayers) {
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
// Transform click point to layer's local coordinates
|
||||
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);
|
||||
const withinX = Math.abs(rotatedX) <= layer.width / 2;
|
||||
const withinY = Math.abs(rotatedY) <= layer.height / 2;
|
||||
// Check if click is within layer bounds
|
||||
if (withinX && withinY) {
|
||||
selectedLayer = layer;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If no layer found at click position, fall back to topmost visible selected layer
|
||||
if (!selectedLayer) {
|
||||
selectedLayer = sortedLayers[0];
|
||||
}
|
||||
// At this point selectedLayer is guaranteed to be non-null
|
||||
if (!selectedLayer) {
|
||||
return;
|
||||
}
|
||||
// Remove any existing event listener first
|
||||
if (this.currentCloseMenuListener) {
|
||||
document.removeEventListener('mousedown', this.currentCloseMenuListener);
|
||||
this.currentCloseMenuListener = null;
|
||||
}
|
||||
this.closeBlendModeMenu();
|
||||
// Calculate position in WORLD coordinates (top-right of viewport)
|
||||
const viewLeft = this.canvas.viewport.x;
|
||||
const viewTop = this.canvas.viewport.y;
|
||||
const viewWidth = this.canvas.canvas.width / this.canvas.viewport.zoom;
|
||||
// Position near top-right corner
|
||||
this.blendMenuWorldX = viewLeft + viewWidth - (250 / this.canvas.viewport.zoom); // 250px from right edge
|
||||
this.blendMenuWorldY = viewTop + (10 / this.canvas.viewport.zoom); // 10px from top edge
|
||||
const menu = document.createElement('div');
|
||||
this.blendMenuElement = menu;
|
||||
menu.id = 'blend-mode-menu';
|
||||
menu.style.cssText = `
|
||||
position: fixed;
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
@@ -463,22 +655,86 @@ export class CanvasLayers {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
titleBar.textContent = 'Blend Mode';
|
||||
const titleText = document.createElement('span');
|
||||
titleText.textContent = `Blend Mode: ${selectedLayer.name}`;
|
||||
titleText.style.cssText = `
|
||||
flex: 1;
|
||||
cursor: move;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.textContent = '×';
|
||||
closeButton.style.cssText = `
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s;
|
||||
`;
|
||||
closeButton.onmouseover = () => {
|
||||
closeButton.style.backgroundColor = '#4a4a4a';
|
||||
};
|
||||
closeButton.onmouseout = () => {
|
||||
closeButton.style.backgroundColor = 'transparent';
|
||||
};
|
||||
closeButton.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.closeBlendModeMenu();
|
||||
};
|
||||
titleBar.appendChild(titleText);
|
||||
titleBar.appendChild(closeButton);
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `padding: 5px;`;
|
||||
menu.appendChild(titleBar);
|
||||
menu.appendChild(content);
|
||||
const blendAreaContainer = document.createElement('div');
|
||||
blendAreaContainer.style.cssText = `padding: 5px 10px; border-bottom: 1px solid #4a4a4a;`;
|
||||
const blendAreaLabel = document.createElement('label');
|
||||
blendAreaLabel.textContent = 'Blend Area';
|
||||
blendAreaLabel.style.color = 'white';
|
||||
const blendAreaSlider = document.createElement('input');
|
||||
blendAreaSlider.type = 'range';
|
||||
blendAreaSlider.min = '0';
|
||||
blendAreaSlider.max = '100';
|
||||
blendAreaSlider.value = selectedLayer?.blendArea?.toString() ?? '0';
|
||||
blendAreaSlider.oninput = () => {
|
||||
if (selectedLayer) {
|
||||
const newValue = parseInt(blendAreaSlider.value, 10);
|
||||
selectedLayer.blendArea = newValue;
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
blendAreaSlider.addEventListener('change', () => {
|
||||
this.canvas.saveState();
|
||||
});
|
||||
blendAreaContainer.appendChild(blendAreaLabel);
|
||||
blendAreaContainer.appendChild(blendAreaSlider);
|
||||
content.appendChild(blendAreaContainer);
|
||||
let isDragging = false;
|
||||
let dragOffset = { x: 0, y: 0 };
|
||||
// Drag logic needs to update world coordinates, not screen coordinates
|
||||
const handleMouseMove = (e) => {
|
||||
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 dx = e.movementX / this.canvas.viewport.zoom;
|
||||
const dy = e.movementY / this.canvas.viewport.zoom;
|
||||
this.blendMenuWorldX += dx;
|
||||
this.blendMenuWorldY += dy;
|
||||
this.updateBlendModeMenuPosition();
|
||||
}
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
@@ -490,8 +746,6 @@ export class CanvasLayers {
|
||||
};
|
||||
titleBar.addEventListener('mousedown', (e) => {
|
||||
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);
|
||||
@@ -516,20 +770,36 @@ export class CanvasLayers {
|
||||
option.style.backgroundColor = '#3a3a3a';
|
||||
}
|
||||
option.onclick = () => {
|
||||
content.querySelectorAll('input[type="range"]').forEach(s => s.style.display = 'none');
|
||||
content.querySelectorAll('.blend-mode-container div').forEach(d => d.style.backgroundColor = '');
|
||||
// Re-check selected layer at the time of click
|
||||
const currentSelectedLayer = this.canvas.canvasSelection.selectedLayers[0];
|
||||
if (!currentSelectedLayer) {
|
||||
return;
|
||||
}
|
||||
// Hide only the opacity sliders within other blend mode containers
|
||||
content.querySelectorAll('.blend-mode-container').forEach(c => {
|
||||
const opacitySlider = c.querySelector('input[type="range"]');
|
||||
if (opacitySlider) {
|
||||
opacitySlider.style.display = 'none';
|
||||
}
|
||||
const optionDiv = c.querySelector('div');
|
||||
if (optionDiv) {
|
||||
optionDiv.style.backgroundColor = '';
|
||||
}
|
||||
});
|
||||
slider.style.display = 'block';
|
||||
option.style.backgroundColor = '#3a3a3a';
|
||||
if (selectedLayer) {
|
||||
selectedLayer.blendMode = mode.name;
|
||||
this.canvas.render();
|
||||
}
|
||||
currentSelectedLayer.blendMode = mode.name;
|
||||
this.canvas.render();
|
||||
};
|
||||
slider.addEventListener('input', () => {
|
||||
if (selectedLayer) {
|
||||
selectedLayer.opacity = parseInt(slider.value, 10) / 100;
|
||||
this.canvas.render();
|
||||
// Re-check selected layer at the time of slider input
|
||||
const currentSelectedLayer = this.canvas.canvasSelection.selectedLayers[0];
|
||||
if (!currentSelectedLayer) {
|
||||
return;
|
||||
}
|
||||
const newOpacity = parseInt(slider.value, 10) / 100;
|
||||
currentSelectedLayer.opacity = newOpacity;
|
||||
this.canvas.render();
|
||||
});
|
||||
slider.addEventListener('change', async () => {
|
||||
if (selectedLayer) {
|
||||
@@ -557,88 +827,145 @@ export class CanvasLayers {
|
||||
container.appendChild(slider);
|
||||
content.appendChild(container);
|
||||
});
|
||||
const container = this.canvas.canvas.parentElement || document.body;
|
||||
container.appendChild(menu);
|
||||
// Add contextmenu event listener to the menu itself to prevent browser context menu
|
||||
menu.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
if (!this.canvas.canvasContainer) {
|
||||
log.error("Canvas container not found, cannot append blend mode menu.");
|
||||
return;
|
||||
}
|
||||
this.canvas.canvasContainer.appendChild(menu);
|
||||
this.updateBlendModeMenuPosition();
|
||||
// Add listener for viewport changes
|
||||
this.canvas.onViewportChange = () => this.updateBlendModeMenuPosition();
|
||||
const closeMenu = (e) => {
|
||||
if (e.target instanceof Node && !menu.contains(e.target) && !isDragging) {
|
||||
this.closeBlendModeMenu();
|
||||
document.removeEventListener('mousedown', closeMenu);
|
||||
if (this.currentCloseMenuListener) {
|
||||
document.removeEventListener('mousedown', this.currentCloseMenuListener);
|
||||
this.currentCloseMenuListener = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('mousedown', closeMenu), 0);
|
||||
// Store the listener reference so we can remove it later
|
||||
this.currentCloseMenuListener = closeMenu;
|
||||
setTimeout(() => {
|
||||
document.addEventListener('mousedown', closeMenu);
|
||||
}, 0);
|
||||
}
|
||||
closeBlendModeMenu() {
|
||||
const menu = document.getElementById('blend-mode-menu');
|
||||
if (menu && menu.parentNode) {
|
||||
menu.parentNode.removeChild(menu);
|
||||
log.info("=== BLEND MODE MENU CLOSING ===");
|
||||
if (this.blendMenuElement && this.blendMenuElement.parentNode) {
|
||||
log.info("Removing blend mode menu from DOM");
|
||||
this.blendMenuElement.parentNode.removeChild(this.blendMenuElement);
|
||||
this.blendMenuElement = null;
|
||||
}
|
||||
else {
|
||||
log.info("Blend mode menu not found or already removed");
|
||||
}
|
||||
// Remove viewport change listener
|
||||
if (this.canvas.onViewportChange) {
|
||||
this.canvas.onViewportChange = null;
|
||||
}
|
||||
}
|
||||
showOpacitySlider(mode) {
|
||||
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.value, 10);
|
||||
});
|
||||
const modeElement = document.querySelector(`[data-blend-mode="${mode}"]`);
|
||||
if (modeElement) {
|
||||
modeElement.appendChild(slider);
|
||||
}
|
||||
}
|
||||
async getFlattenedCanvasWithMaskAsBlob() {
|
||||
/**
|
||||
* Zunifikowana funkcja do generowania blob z canvas
|
||||
* @param options Opcje renderowania
|
||||
*/
|
||||
async _generateCanvasBlob(options = {}) {
|
||||
const { layers = this.canvas.layers, useOutputBounds = true, applyMask = false, enableLogging = false, customBounds } = options;
|
||||
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');
|
||||
let bounds;
|
||||
if (customBounds) {
|
||||
bounds = customBounds;
|
||||
}
|
||||
else if (useOutputBounds) {
|
||||
bounds = this.canvas.outputAreaBounds;
|
||||
}
|
||||
else {
|
||||
// Oblicz bounding box dla wybranych warstw
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
layers.forEach((layer) => {
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
const corners = [
|
||||
{ x: -halfW, y: -halfH },
|
||||
{ x: halfW, y: -halfH },
|
||||
{ x: halfW, y: halfH },
|
||||
{ x: -halfW, y: halfH }
|
||||
];
|
||||
corners.forEach(p => {
|
||||
const worldX = centerX + (p.x * cos - p.y * sin);
|
||||
const worldY = centerY + (p.x * sin + p.y * cos);
|
||||
minX = Math.min(minX, worldX);
|
||||
minY = Math.min(minY, worldY);
|
||||
maxX = Math.max(maxX, worldX);
|
||||
maxY = Math.max(maxY, worldY);
|
||||
});
|
||||
});
|
||||
const newWidth = Math.ceil(maxX - minX);
|
||||
const newHeight = Math.ceil(maxY - minY);
|
||||
if (newWidth <= 0 || newHeight <= 0) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
bounds = { x: minX, y: minY, width: newWidth, height: newHeight };
|
||||
}
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(bounds.width, bounds.height, '2d', { willReadFrequently: true });
|
||||
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;
|
||||
if (enableLogging) {
|
||||
log.info("=== GENERATING OUTPUT CANVAS ===");
|
||||
log.info(`Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`);
|
||||
log.info(`Canvas Size: ${tempCanvas.width}x${tempCanvas.height}`);
|
||||
log.info(`Context Translation: translate(${-bounds.x}, ${-bounds.y})`);
|
||||
log.info(`Apply Mask: ${applyMask}`);
|
||||
// Log layer positions before rendering
|
||||
layers.forEach((layer, index) => {
|
||||
if (layer.visible) {
|
||||
const relativeToOutput = {
|
||||
x: layer.x - bounds.x,
|
||||
y: layer.y - bounds.y
|
||||
};
|
||||
log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_bounds(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Renderuj fragment świata zdefiniowany przez bounds
|
||||
tempCtx.translate(-bounds.x, -bounds.y);
|
||||
this._drawLayers(tempCtx, layers);
|
||||
// Aplikuj maskę jeśli wymagana
|
||||
if (applyMask) {
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
const data = imageData.data;
|
||||
// Use optimized getMaskForOutputArea() for better performance
|
||||
// This only processes chunks that overlap with the output area
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea();
|
||||
if (toolMaskCanvas) {
|
||||
log.debug(`Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height}) for _generateCanvasBlob`);
|
||||
// The optimized mask is already sized and positioned for the output area
|
||||
// So we can apply it directly without complex positioning calculations
|
||||
const maskImageData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height);
|
||||
if (maskImageData) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -650,83 +977,103 @@ export class CanvasLayers {
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
// Publiczne metody używające zunifikowanej funkcji
|
||||
async getFlattenedCanvasWithMaskAsBlob() {
|
||||
return this._generateCanvasBlob({
|
||||
layers: this.canvas.layers,
|
||||
useOutputBounds: true,
|
||||
applyMask: true,
|
||||
enableLogging: true
|
||||
});
|
||||
}
|
||||
async getFlattenedCanvasAsBlob() {
|
||||
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');
|
||||
return this._generateCanvasBlob({
|
||||
layers: this.canvas.layers,
|
||||
useOutputBounds: true,
|
||||
applyMask: false,
|
||||
enableLogging: true
|
||||
});
|
||||
}
|
||||
async getFlattenedCanvasForMaskEditor() {
|
||||
return this.getFlattenedCanvasWithMaskAsBlob();
|
||||
}
|
||||
async getFlattenedSelectionAsBlob() {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this._generateCanvasBlob({
|
||||
layers: this.canvas.canvasSelection.selectedLayers,
|
||||
useOutputBounds: false,
|
||||
applyMask: false,
|
||||
enableLogging: false
|
||||
});
|
||||
}
|
||||
async getFlattenedMaskAsBlob() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
const corners = [
|
||||
{ x: -halfW, y: -halfH },
|
||||
{ x: halfW, y: -halfH },
|
||||
{ x: halfW, y: halfH },
|
||||
{ x: -halfW, y: halfH }
|
||||
];
|
||||
corners.forEach(p => {
|
||||
const worldX = centerX + (p.x * cos - p.y * sin);
|
||||
const worldY = centerY + (p.x * sin + p.y * cos);
|
||||
minX = Math.min(minX, worldX);
|
||||
minY = Math.min(minY, worldY);
|
||||
maxX = Math.max(maxX, worldX);
|
||||
maxY = Math.max(maxY, worldY);
|
||||
});
|
||||
});
|
||||
const newWidth = Math.ceil(maxX - minX);
|
||||
const newHeight = Math.ceil(maxY - minY);
|
||||
if (newWidth <= 0 || newHeight <= 0) {
|
||||
resolve(null);
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(bounds.width, bounds.height, '2d', { willReadFrequently: true });
|
||||
if (!maskCtx) {
|
||||
reject(new Error("Could not create mask context"));
|
||||
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"));
|
||||
log.info("=== GENERATING MASK BLOB ===");
|
||||
log.info(`Mask Canvas Size: ${maskCanvas.width}x${maskCanvas.height}`);
|
||||
// Rozpocznij z białą maską (nic nie zamaskowane)
|
||||
maskCtx.fillStyle = '#ffffff';
|
||||
maskCtx.fillRect(0, 0, bounds.width, bounds.height);
|
||||
// Stwórz canvas do sprawdzenia przezroczystości warstw
|
||||
const { canvas: visibilityCanvas, ctx: visibilityCtx } = createCanvas(bounds.width, bounds.height, '2d', { alpha: true });
|
||||
if (!visibilityCtx) {
|
||||
reject(new Error("Could not create visibility context"));
|
||||
return;
|
||||
}
|
||||
tempCtx.translate(-minX, -minY);
|
||||
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
|
||||
tempCanvas.toBlob((blob) => {
|
||||
resolve(blob);
|
||||
// Renderuj warstwy z przesunięciem dla output bounds
|
||||
visibilityCtx.translate(-bounds.x, -bounds.y);
|
||||
this._drawLayers(visibilityCtx, this.canvas.layers);
|
||||
// Konwertuj przezroczystość warstw na maskę
|
||||
const visibilityData = visibilityCtx.getImageData(0, 0, bounds.width, bounds.height);
|
||||
const maskData = maskCtx.getImageData(0, 0, bounds.width, bounds.height);
|
||||
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
||||
const alpha = visibilityData.data[i + 3];
|
||||
const maskValue = 255 - alpha; // Odwróć alpha żeby stworzyć maskę
|
||||
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
|
||||
maskData.data[i + 3] = 255; // Solidna maska
|
||||
}
|
||||
maskCtx.putImageData(maskData, 0, 0);
|
||||
// Aplikuj maskę narzędzia jeśli istnieje - używaj zoptymalizowanej metody
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea();
|
||||
if (toolMaskCanvas) {
|
||||
log.debug(`[getFlattenedMaskAsBlob] Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height})`);
|
||||
// Zoptymalizowana maska jest już odpowiednio pozycjonowana dla output area
|
||||
// Możemy ją zastosować bezpośrednio
|
||||
const tempMaskData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height);
|
||||
if (tempMaskData) {
|
||||
// Konwertuj dane maski do odpowiedniego formatu
|
||||
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; // Solidna alpha
|
||||
}
|
||||
// Stwórz tymczasowy canvas dla przetworzonej maski
|
||||
const { canvas: tempMaskCanvas, ctx: tempMaskCtx } = createCanvas(toolMaskCanvas.width, toolMaskCanvas.height, '2d', { willReadFrequently: true });
|
||||
if (tempMaskCtx) {
|
||||
tempMaskCtx.putImageData(tempMaskData, 0, 0);
|
||||
maskCtx.globalCompositeOperation = 'screen';
|
||||
maskCtx.drawImage(tempMaskCanvas, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("=== MASK BLOB GENERATED ===");
|
||||
maskCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
}
|
||||
else {
|
||||
resolve(null);
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
async fuseLayers() {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length < 2) {
|
||||
alert("Please select at least 2 layers to fuse.");
|
||||
showErrorNotification("Please select at least 2 layers to fuse.");
|
||||
return;
|
||||
}
|
||||
log.info(`Fusing ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
|
||||
@@ -760,13 +1107,10 @@ export class CanvasLayers {
|
||||
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.");
|
||||
showErrorNotification("Cannot fuse layers: invalid dimensions calculated.");
|
||||
return;
|
||||
}
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = fusedWidth;
|
||||
tempCanvas.height = fusedHeight;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(fusedWidth, fusedHeight, '2d', { willReadFrequently: true });
|
||||
if (!tempCtx)
|
||||
throw new Error("Could not create canvas context");
|
||||
tempCtx.translate(-minX, -minY);
|
||||
@@ -795,7 +1139,8 @@ export class CanvasLayers {
|
||||
rotation: 0,
|
||||
zIndex: minZIndex,
|
||||
blendMode: 'normal',
|
||||
opacity: 1
|
||||
opacity: 1,
|
||||
visible: true
|
||||
};
|
||||
this.canvas.layers = this.canvas.layers.filter((layer) => !this.canvas.canvasSelection.selectedLayers.includes(layer));
|
||||
this.canvas.layers.push(fusedLayer);
|
||||
@@ -817,7 +1162,7 @@ export class CanvasLayers {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error during layer fusion:", error);
|
||||
alert(`Error fusing layers: ${error.message}`);
|
||||
showErrorNotification(`Error fusing layers: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
const log = createModuleLogger('CanvasLayersPanel');
|
||||
export class CanvasLayersPanel {
|
||||
constructor(canvas) {
|
||||
@@ -14,8 +16,100 @@ export class CanvasLayersPanel {
|
||||
this.handleDragOver = this.handleDragOver.bind(this);
|
||||
this.handleDragEnd = this.handleDragEnd.bind(this);
|
||||
this.handleDrop = this.handleDrop.bind(this);
|
||||
// Preload icons
|
||||
this.initializeIcons();
|
||||
log.info('CanvasLayersPanel initialized');
|
||||
}
|
||||
async initializeIcons() {
|
||||
try {
|
||||
await iconLoader.preloadToolIcons();
|
||||
log.debug('Icons preloaded successfully');
|
||||
}
|
||||
catch (error) {
|
||||
log.warn('Failed to preload icons, using fallbacks:', error);
|
||||
}
|
||||
}
|
||||
createIconElement(toolName, size = 16) {
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.style.cssText = `
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
const icon = iconLoader.getIcon(toolName);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode();
|
||||
img.style.cssText = `
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
filter: brightness(0) invert(1);
|
||||
`;
|
||||
iconContainer.appendChild(img);
|
||||
}
|
||||
else if (icon instanceof HTMLCanvasElement) {
|
||||
const { canvas, ctx } = createCanvas(size, size);
|
||||
if (ctx) {
|
||||
ctx.drawImage(icon, 0, 0, size, size);
|
||||
}
|
||||
iconContainer.appendChild(canvas);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Fallback text
|
||||
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
||||
iconContainer.style.fontSize = `${size * 0.6}px`;
|
||||
iconContainer.style.color = '#ffffff';
|
||||
}
|
||||
return iconContainer;
|
||||
}
|
||||
createVisibilityIcon(isVisible) {
|
||||
if (isVisible) {
|
||||
return this.createIconElement(LAYERFORGE_TOOLS.VISIBILITY, 16);
|
||||
}
|
||||
else {
|
||||
// Create a "hidden" version of the visibility icon
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
`;
|
||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode();
|
||||
img.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.3;
|
||||
`;
|
||||
iconContainer.appendChild(img);
|
||||
}
|
||||
else if (icon instanceof HTMLCanvasElement) {
|
||||
const { canvas, ctx } = createCanvas(16, 16);
|
||||
if (ctx) {
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.drawImage(icon, 0, 0, 16, 16);
|
||||
}
|
||||
iconContainer.appendChild(canvas);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Fallback
|
||||
iconContainer.textContent = 'H';
|
||||
iconContainer.style.fontSize = '10px';
|
||||
iconContainer.style.color = '#888888';
|
||||
}
|
||||
return iconContainer;
|
||||
}
|
||||
}
|
||||
createPanelStructure() {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'layers-panel';
|
||||
@@ -24,7 +118,7 @@ export class CanvasLayersPanel {
|
||||
<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>
|
||||
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layers-container" id="layers-container">
|
||||
@@ -231,6 +325,23 @@ export class CanvasLayersPanel {
|
||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layer-visibility-toggle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.layer-visibility-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
log.debug('Styles injected');
|
||||
@@ -239,6 +350,11 @@ export class CanvasLayersPanel {
|
||||
if (!this.container)
|
||||
return;
|
||||
const deleteBtn = this.container.querySelector('#delete-layer-btn');
|
||||
// Add delete icon to button
|
||||
if (deleteBtn) {
|
||||
const deleteIcon = this.createIconElement(LAYERFORGE_TOOLS.DELETE, 16);
|
||||
deleteBtn.appendChild(deleteIcon);
|
||||
}
|
||||
deleteBtn?.addEventListener('click', () => {
|
||||
log.info('Delete layer button clicked');
|
||||
this.deleteSelectedLayers();
|
||||
@@ -280,9 +396,16 @@ export class CanvasLayersPanel {
|
||||
layer.name = this.ensureUniqueName(layer.name, layer);
|
||||
}
|
||||
layerRow.innerHTML = `
|
||||
<div class="layer-visibility-toggle" data-layer-index="${index}" title="Toggle layer visibility"></div>
|
||||
<div class="layer-thumbnail" data-layer-index="${index}"></div>
|
||||
<span class="layer-name" data-layer-index="${index}">${layer.name}</span>
|
||||
`;
|
||||
// Add visibility icon
|
||||
const visibilityToggle = layerRow.querySelector('.layer-visibility-toggle');
|
||||
if (visibilityToggle) {
|
||||
const visibilityIcon = this.createVisibilityIcon(layer.visible);
|
||||
visibilityToggle.appendChild(visibilityIcon);
|
||||
}
|
||||
const thumbnailContainer = layerRow.querySelector('.layer-thumbnail');
|
||||
if (thumbnailContainer) {
|
||||
this.generateThumbnail(layer, thumbnailContainer);
|
||||
@@ -295,12 +418,9 @@ export class CanvasLayersPanel {
|
||||
thumbnailContainer.style.background = '#4a4a4a';
|
||||
return;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const { canvas, ctx } = createCanvas(48, 48, '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;
|
||||
@@ -320,6 +440,16 @@ export class CanvasLayersPanel {
|
||||
}
|
||||
this.handleLayerClick(e, layer, index);
|
||||
});
|
||||
// --- PRAWY PRZYCISK: ODJAZNACZ LAYER ---
|
||||
layerRow.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
|
||||
this.canvas.updateSelection(newSelection);
|
||||
this.updateSelectionAppearance();
|
||||
}
|
||||
});
|
||||
layerRow.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -328,6 +458,15 @@ export class CanvasLayersPanel {
|
||||
this.startEditingLayerName(nameElement, layer);
|
||||
}
|
||||
});
|
||||
// Add visibility toggle event listener
|
||||
const visibilityToggle = layerRow.querySelector('.layer-visibility-toggle');
|
||||
if (visibilityToggle) {
|
||||
visibilityToggle.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.toggleLayerVisibility(layer);
|
||||
});
|
||||
}
|
||||
layerRow.addEventListener('dragstart', (e) => this.handleDragStart(e, layer, index));
|
||||
layerRow.addEventListener('dragover', this.handleDragOver.bind(this));
|
||||
layerRow.addEventListener('dragend', this.handleDragEnd.bind(this));
|
||||
@@ -401,6 +540,19 @@ export class CanvasLayersPanel {
|
||||
} while (existingNames.includes(uniqueName));
|
||||
return uniqueName;
|
||||
}
|
||||
toggleLayerVisibility(layer) {
|
||||
layer.visible = !layer.visible;
|
||||
// If layer became invisible and is selected, deselect it
|
||||
if (!layer.visible && this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
|
||||
this.canvas.updateSelection(newSelection);
|
||||
}
|
||||
this.canvas.render();
|
||||
this.canvas.requestSaveState();
|
||||
// Update the eye icon in the panel
|
||||
this.renderLayers();
|
||||
log.info(`Layer "${layer.name}" visibility toggled to: ${layer.visible}`);
|
||||
}
|
||||
deleteSelectedLayers() {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
|
||||
log.debug('No layers selected for deletion');
|
||||
|
||||
@@ -8,6 +8,57 @@ export class CanvasRenderer {
|
||||
this.renderInterval = 1000 / 60;
|
||||
this.isDirty = false;
|
||||
}
|
||||
/**
|
||||
* Helper function to draw text with background at world coordinates
|
||||
* @param ctx Canvas context
|
||||
* @param text Text to display
|
||||
* @param worldX World X coordinate
|
||||
* @param worldY World Y coordinate
|
||||
* @param options Optional styling options
|
||||
*/
|
||||
drawTextWithBackground(ctx, text, worldX, worldY, options = {}) {
|
||||
const { font = "14px sans-serif", textColor = "white", backgroundColor = "rgba(0, 0, 0, 0.7)", padding = 10, lineHeight = 18 } = options;
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
const screenX = (worldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (worldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
ctx.font = font;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
const lines = text.split('\n');
|
||||
const textMetrics = lines.map(line => ctx.measureText(line));
|
||||
const bgWidth = Math.max(...textMetrics.map(m => m.width)) + padding;
|
||||
const bgHeight = lines.length * lineHeight + 4;
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight);
|
||||
ctx.fillStyle = textColor;
|
||||
lines.forEach((line, index) => {
|
||||
const yPos = screenY - (bgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2;
|
||||
ctx.fillText(line, screenX, yPos);
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
/**
|
||||
* Helper function to draw rectangle with stroke style
|
||||
* @param ctx Canvas context
|
||||
* @param rect Rectangle bounds {x, y, width, height}
|
||||
* @param options Styling options
|
||||
*/
|
||||
drawStyledRect(ctx, rect, options = {}) {
|
||||
const { strokeStyle = "rgba(255, 255, 255, 0.8)", lineWidth = 2, dashPattern = null } = options;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = strokeStyle;
|
||||
ctx.lineWidth = lineWidth / this.canvas.viewport.zoom;
|
||||
if (dashPattern) {
|
||||
const scaledDash = dashPattern.map((d) => d / this.canvas.viewport.zoom);
|
||||
ctx.setLineDash(scaledDash);
|
||||
}
|
||||
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
||||
if (dashPattern) {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
render() {
|
||||
if (this.renderAnimationFrame) {
|
||||
this.isDirty = true;
|
||||
@@ -44,37 +95,9 @@ export class CanvasRenderer {
|
||||
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
|
||||
// Use CanvasLayers to draw layers with proper blend area support
|
||||
this.canvas.canvasLayers.drawLayersToContext(ctx, this.canvas.layers);
|
||||
// Draw mask AFTER layers but BEFORE all preview outlines
|
||||
const maskImage = this.canvas.maskTool.getMask();
|
||||
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||
ctx.save();
|
||||
@@ -86,12 +109,48 @@ export class CanvasRenderer {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y);
|
||||
// Renderuj maskę w jej pozycji światowej (bez przesunięcia względem bounds)
|
||||
const maskWorldX = this.canvas.maskTool.x;
|
||||
const maskWorldY = this.canvas.maskTool.y;
|
||||
ctx.drawImage(maskImage, maskWorldX, maskWorldY);
|
||||
ctx.globalAlpha = 1.0;
|
||||
ctx.restore();
|
||||
}
|
||||
// Draw selection frames for selected layers
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach(layer => {
|
||||
if (!layer.image || !layer.visible)
|
||||
return;
|
||||
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
ctx.save();
|
||||
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);
|
||||
}
|
||||
this.drawSelectionFrame(ctx, layer);
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
this.drawCanvasOutline(ctx);
|
||||
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
|
||||
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
||||
this.renderInteractionElements(ctx);
|
||||
this.canvas.shapeTool.render(ctx);
|
||||
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
|
||||
this.renderLayerInfo(ctx);
|
||||
// Update custom shape menu position and visibility
|
||||
if (this.canvas.outputAreaShape) {
|
||||
this.canvas.customShapeMenu.show();
|
||||
this.canvas.customShapeMenu.updateScreenPosition();
|
||||
}
|
||||
else {
|
||||
this.canvas.customShapeMenu.hide();
|
||||
}
|
||||
ctx.restore();
|
||||
if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width ||
|
||||
this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) {
|
||||
@@ -110,67 +169,39 @@ export class CanvasRenderer {
|
||||
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();
|
||||
this.drawStyledRect(ctx, rect, {
|
||||
strokeStyle: 'rgba(0, 255, 0, 0.8)',
|
||||
lineWidth: 2,
|
||||
dashPattern: [8, 4]
|
||||
});
|
||||
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();
|
||||
this.drawTextWithBackground(ctx, text, textWorldX, textWorldY, {
|
||||
backgroundColor: "rgba(0, 128, 0, 0.7)"
|
||||
});
|
||||
}
|
||||
}
|
||||
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();
|
||||
this.drawStyledRect(ctx, rect, {
|
||||
strokeStyle: 'rgba(0, 150, 255, 0.8)',
|
||||
lineWidth: 2,
|
||||
dashPattern: [10, 5]
|
||||
});
|
||||
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();
|
||||
this.drawTextWithBackground(ctx, text, textWorldX, textWorldY, {
|
||||
backgroundColor: "rgba(0, 100, 170, 0.7)"
|
||||
});
|
||||
}
|
||||
}
|
||||
renderLayerInfo(ctx) {
|
||||
if (this.canvas.canvasSelection.selectedLayer) {
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||
if (!layer.image)
|
||||
if (!layer.image || !layer.visible)
|
||||
return;
|
||||
const layerIndex = this.canvas.layers.indexOf(layer);
|
||||
const currentWidth = Math.round(layer.width);
|
||||
@@ -206,26 +237,7 @@ export class CanvasRenderer {
|
||||
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();
|
||||
this.drawTextWithBackground(ctx, text, textWorldX, textWorldY);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -249,27 +261,196 @@ export class CanvasRenderer {
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
/**
|
||||
* Check if custom shape overlaps with any active batch preview areas
|
||||
*/
|
||||
isCustomShapeOverlappingWithBatchAreas() {
|
||||
if (!this.canvas.outputAreaShape || !this.canvas.batchPreviewManagers || this.canvas.batchPreviewManagers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Get custom shape bounds
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
const shapeOffsetX = bounds.x + ext.left;
|
||||
const shapeOffsetY = bounds.y + ext.top;
|
||||
const shape = this.canvas.outputAreaShape;
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
// Calculate shape bounding box
|
||||
shape.points.forEach((point) => {
|
||||
const worldX = shapeOffsetX + point.x;
|
||||
const worldY = shapeOffsetY + point.y;
|
||||
minX = Math.min(minX, worldX);
|
||||
maxX = Math.max(maxX, worldX);
|
||||
minY = Math.min(minY, worldY);
|
||||
maxY = Math.max(maxY, worldY);
|
||||
});
|
||||
const shapeBounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||||
// Check overlap with each active batch preview area
|
||||
for (const manager of this.canvas.batchPreviewManagers) {
|
||||
if (manager.generationArea) {
|
||||
const area = manager.generationArea;
|
||||
// Check if rectangles overlap
|
||||
if (!(shapeBounds.x + shapeBounds.width < area.x ||
|
||||
area.x + area.width < shapeBounds.x ||
|
||||
shapeBounds.y + shapeBounds.height < area.y ||
|
||||
area.y + area.height < shapeBounds.y)) {
|
||||
return true; // Overlap detected
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
drawCanvasOutline(ctx) {
|
||||
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);
|
||||
// Rysuj outline w pozycji outputAreaBounds
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
// Display dimensions under outputAreaBounds
|
||||
const dimensionsText = `${Math.round(bounds.width)}x${Math.round(bounds.height)}`;
|
||||
const textWorldX = bounds.x + bounds.width / 2;
|
||||
const textWorldY = bounds.y + bounds.height + (20 / this.canvas.viewport.zoom);
|
||||
this.drawTextWithBackground(ctx, dimensionsText, textWorldX, textWorldY);
|
||||
// Only draw custom shape if it doesn't overlap with batch preview areas
|
||||
if (this.canvas.outputAreaShape && !this.isCustomShapeOverlappingWithBatchAreas()) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
|
||||
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
ctx.setLineDash([]);
|
||||
const shape = this.canvas.outputAreaShape;
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
// Calculate custom shape position accounting for extensions
|
||||
// Custom shape should maintain its relative position within the original canvas area
|
||||
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
const shapeOffsetX = bounds.x + ext.left; // Add left extension to maintain relative position
|
||||
const shapeOffsetY = bounds.y + ext.top; // Add top extension to maintain relative position
|
||||
ctx.beginPath();
|
||||
// Render custom shape with extension offset to maintain relative position
|
||||
ctx.moveTo(shapeOffsetX + shape.points[0].x, shapeOffsetY + shape.points[0].y);
|
||||
for (let i = 1; i < shape.points.length; i++) {
|
||||
ctx.lineTo(shapeOffsetX + shape.points[i].x, shapeOffsetY + shape.points[i].y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Sprawdza czy punkt w świecie jest przykryty przez warstwy o wyższym zIndex
|
||||
*/
|
||||
isPointCoveredByHigherLayers(worldX, worldY, currentLayer) {
|
||||
// Znajdź warstwy o wyższym zIndex niż aktualny layer
|
||||
const higherLayers = this.canvas.layers.filter((l) => l.zIndex > currentLayer.zIndex && l.visible && l !== currentLayer);
|
||||
for (const higherLayer of higherLayers) {
|
||||
// Sprawdź czy punkt jest wewnątrz tego layera
|
||||
const centerX = higherLayer.x + higherLayer.width / 2;
|
||||
const centerY = higherLayer.y + higherLayer.height / 2;
|
||||
// Przekształć punkt do lokalnego układu współrzędnych layera
|
||||
const dx = worldX - centerX;
|
||||
const dy = worldY - centerY;
|
||||
const rad = -higherLayer.rotation * Math.PI / 180;
|
||||
const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad);
|
||||
const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad);
|
||||
// Sprawdź czy punkt jest wewnątrz prostokąta layera
|
||||
if (Math.abs(rotatedX) <= higherLayer.width / 2 &&
|
||||
Math.abs(rotatedY) <= higherLayer.height / 2) {
|
||||
// Sprawdź przezroczystość layera - jeśli ma znaczącą nieprzezroczystość, uznaj za przykryty
|
||||
if (higherLayer.opacity > 0.1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Rysuje linię z automatycznym przełączaniem między ciągłą a przerywaną w zależności od przykrycia
|
||||
*/
|
||||
drawAdaptiveLine(ctx, startX, startY, endX, endY, layer) {
|
||||
const segmentLength = 8 / this.canvas.viewport.zoom; // Długość segmentu do sprawdzania
|
||||
const dashLength = 6 / this.canvas.viewport.zoom;
|
||||
const gapLength = 4 / this.canvas.viewport.zoom;
|
||||
const totalLength = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
|
||||
const segments = Math.max(1, Math.floor(totalLength / segmentLength));
|
||||
let currentX = startX;
|
||||
let currentY = startY;
|
||||
let lastCovered = null;
|
||||
let segmentStart = { x: startX, y: startY };
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const t = i / segments;
|
||||
const x = startX + (endX - startX) * t;
|
||||
const y = startY + (endY - startY) * t;
|
||||
// Przekształć współrzędne lokalne na światowe
|
||||
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 worldX = centerX + (x * cos - y * sin);
|
||||
const worldY = centerY + (x * sin + y * cos);
|
||||
const isCovered = this.isPointCoveredByHigherLayers(worldX, worldY, layer);
|
||||
// Jeśli stan się zmienił lub to ostatni segment, narysuj poprzedni odcinek
|
||||
if (lastCovered !== null && (lastCovered !== isCovered || i === segments)) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(segmentStart.x, segmentStart.y);
|
||||
ctx.lineTo(currentX, currentY);
|
||||
if (lastCovered) {
|
||||
// Przykryty - linia przerywana
|
||||
ctx.setLineDash([dashLength, gapLength]);
|
||||
}
|
||||
else {
|
||||
// Nie przykryty - linia ciągła
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.stroke();
|
||||
segmentStart = { x: currentX, y: currentY };
|
||||
}
|
||||
lastCovered = isCovered;
|
||||
currentX = x;
|
||||
currentY = y;
|
||||
}
|
||||
// Narysuj ostatni segment jeśli potrzeba
|
||||
if (lastCovered !== null) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(segmentStart.x, segmentStart.y);
|
||||
ctx.lineTo(endX, endY);
|
||||
if (lastCovered) {
|
||||
ctx.setLineDash([dashLength, gapLength]);
|
||||
}
|
||||
else {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
// Resetuj dash pattern
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
drawSelectionFrame(ctx, layer) {
|
||||
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();
|
||||
// Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia)
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
// Górna krawędź
|
||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
||||
// Prawa krawędź
|
||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
||||
// Dolna krawędź
|
||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
||||
// Lewa krawędź
|
||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
||||
// Rysuj linię do uchwytu rotacji (zawsze ciągła)
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -layer.height / 2);
|
||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
||||
ctx.stroke();
|
||||
// Rysuj uchwyty
|
||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
@@ -287,31 +468,82 @@ export class CanvasRenderer {
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
drawPendingGenerationAreas(ctx) {
|
||||
const areasToDraw = [];
|
||||
// 1. Get areas from active managers
|
||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||
this.canvas.batchPreviewManagers.forEach((manager) => {
|
||||
if (manager.generationArea) {
|
||||
areasToDraw.push(manager.generationArea);
|
||||
}
|
||||
});
|
||||
}
|
||||
// 2. Get the area from the pending context (if it exists)
|
||||
if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) {
|
||||
areasToDraw.push(this.canvas.pendingBatchContext.outputArea);
|
||||
}
|
||||
if (areasToDraw.length === 0) {
|
||||
drawOutputAreaExtensionPreview(ctx) {
|
||||
if (!this.canvas.outputAreaExtensionPreview) {
|
||||
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();
|
||||
// Calculate preview bounds based on original canvas size + preview extensions
|
||||
const baseWidth = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.width : this.canvas.width;
|
||||
const baseHeight = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.height : this.canvas.height;
|
||||
const ext = this.canvas.outputAreaExtensionPreview;
|
||||
// Calculate preview bounds relative to original custom shape position, not (0,0)
|
||||
const originalPos = this.canvas.originalOutputAreaPosition;
|
||||
const previewBounds = {
|
||||
x: originalPos.x - ext.left, // ✅ Względem oryginalnej pozycji custom shape
|
||||
y: originalPos.y - ext.top, // ✅ Względem oryginalnej pozycji custom shape
|
||||
width: baseWidth + ext.left + ext.right,
|
||||
height: baseHeight + ext.top + ext.bottom
|
||||
};
|
||||
this.drawStyledRect(ctx, previewBounds, {
|
||||
strokeStyle: 'rgba(255, 255, 0, 0.8)',
|
||||
lineWidth: 3,
|
||||
dashPattern: [8, 4]
|
||||
});
|
||||
}
|
||||
drawPendingGenerationAreas(ctx) {
|
||||
const pendingAreas = [];
|
||||
// 1. Get all pending generation areas (from pendingBatchContext)
|
||||
if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) {
|
||||
pendingAreas.push(this.canvas.pendingBatchContext.outputArea);
|
||||
}
|
||||
// 2. Draw only those pending areas, które NIE mają aktywnego batch preview managera dla tego samego obszaru
|
||||
const isAreaCoveredByBatch = (area) => {
|
||||
if (!this.canvas.batchPreviewManagers)
|
||||
return false;
|
||||
return this.canvas.batchPreviewManagers.some((manager) => {
|
||||
if (!manager.generationArea)
|
||||
return false;
|
||||
// Sprawdź czy obszary się pokrywają (prosty overlap AABB)
|
||||
const a = area;
|
||||
const b = manager.generationArea;
|
||||
return !(a.x + a.width < b.x || b.x + b.width < a.x || a.y + a.height < b.y || b.y + b.height < a.y);
|
||||
});
|
||||
};
|
||||
pendingAreas.forEach(area => {
|
||||
if (!isAreaCoveredByBatch(area)) {
|
||||
this.drawStyledRect(ctx, area, {
|
||||
strokeStyle: 'rgba(0, 150, 255, 0.9)',
|
||||
lineWidth: 3,
|
||||
dashPattern: [12, 6]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
drawMaskAreaBounds(ctx) {
|
||||
// Only show mask area bounds when mask tool is active
|
||||
if (!this.canvas.maskTool.isActive) {
|
||||
return;
|
||||
}
|
||||
const maskTool = this.canvas.maskTool;
|
||||
// Get mask canvas bounds in world coordinates
|
||||
const maskBounds = {
|
||||
x: maskTool.x,
|
||||
y: maskTool.y,
|
||||
width: maskTool.getMask().width,
|
||||
height: maskTool.getMask().height
|
||||
};
|
||||
this.drawStyledRect(ctx, maskBounds, {
|
||||
strokeStyle: 'rgba(255, 100, 100, 0.7)',
|
||||
lineWidth: 2,
|
||||
dashPattern: [6, 6]
|
||||
});
|
||||
// Add text label to show this is the mask drawing area
|
||||
const textWorldX = maskBounds.x + maskBounds.width / 2;
|
||||
const textWorldY = maskBounds.y - (10 / this.canvas.viewport.zoom);
|
||||
this.drawTextWithBackground(ctx, "Mask Drawing Area", textWorldX, textWorldY, {
|
||||
font: "12px sans-serif",
|
||||
backgroundColor: "rgba(255, 100, 100, 0.8)",
|
||||
padding: 8
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { generateUUID } from "./utils/CommonUtils.js";
|
||||
const log = createModuleLogger('CanvasSelection');
|
||||
export class CanvasSelection {
|
||||
constructor(canvas) {
|
||||
@@ -18,7 +19,7 @@ export class CanvasSelection {
|
||||
sortedLayers.forEach(layer => {
|
||||
const newLayer = {
|
||||
...layer,
|
||||
id: `layer_${+new Date()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
id: generateUUID(),
|
||||
zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu
|
||||
};
|
||||
this.canvas.layers.push(newLayer);
|
||||
@@ -40,7 +41,8 @@ export class CanvasSelection {
|
||||
*/
|
||||
updateSelection(newSelection) {
|
||||
const previousSelection = this.selectedLayers.length;
|
||||
this.selectedLayers = newSelection || [];
|
||||
// Filter out invisible layers from selection
|
||||
this.selectedLayers = (newSelection || []).filter((layer) => layer.visible !== false);
|
||||
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 ||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getCanvasState, setCanvasState, saveImage, getImage } from "./db.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { generateUUID, cloneLayers, getStateSignature, debounce } from "./utils/CommonUtils.js";
|
||||
import { showAlertNotification } from "./utils/NotificationUtils.js";
|
||||
import { generateUUID, cloneLayers, getStateSignature, debounce, createCanvas } from "./utils/CommonUtils.js";
|
||||
const log = createModuleLogger('CanvasState');
|
||||
export class CanvasState {
|
||||
constructor(canvas) {
|
||||
@@ -68,6 +69,21 @@ export class CanvasState {
|
||||
y: -(this.canvas.height / 4),
|
||||
zoom: 0.8
|
||||
};
|
||||
// Restore outputAreaBounds if saved, otherwise use default
|
||||
if (savedState.outputAreaBounds) {
|
||||
this.canvas.outputAreaBounds = savedState.outputAreaBounds;
|
||||
log.debug(`Output Area bounds restored: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${this.canvas.outputAreaBounds.width}, h=${this.canvas.outputAreaBounds.height}`);
|
||||
}
|
||||
else {
|
||||
// Fallback to default positioning for legacy saves
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: -(this.canvas.width / 4),
|
||||
y: -(this.canvas.height / 4),
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height
|
||||
};
|
||||
log.debug(`Output Area bounds set to default: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${this.canvas.outputAreaBounds.width}, h=${this.canvas.outputAreaBounds.height}`);
|
||||
}
|
||||
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);
|
||||
@@ -196,10 +212,7 @@ export class CanvasState {
|
||||
img.src = imageSrc;
|
||||
}
|
||||
else {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imageSrc.width;
|
||||
canvas.height = imageSrc.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const { canvas, ctx } = createCanvas(imageSrc.width, imageSrc.height);
|
||||
if (ctx) {
|
||||
ctx.drawImage(imageSrc, 0, 0);
|
||||
const img = new Image();
|
||||
@@ -225,6 +238,20 @@ export class CanvasState {
|
||||
log.error("Node ID is not available for saving state to DB.");
|
||||
return;
|
||||
}
|
||||
// Auto-correct node_id widget if needed before saving state
|
||||
if (this.canvas.node && this.canvas.node.widgets) {
|
||||
const nodeIdWidget = this.canvas.node.widgets.find((w) => w.name === "node_id");
|
||||
if (nodeIdWidget) {
|
||||
const correctId = String(this.canvas.node.id);
|
||||
if (nodeIdWidget.value !== correctId) {
|
||||
const prevValue = nodeIdWidget.value;
|
||||
nodeIdWidget.value = correctId;
|
||||
log.warn(`[CanvasState] node_id widget value (${prevValue}) did not match node.id (${correctId}) - auto-corrected (saveStateToDB).`);
|
||||
showAlertNotification(`The value of node_id (${prevValue}) did not match the node number (${correctId}) and was automatically corrected.
|
||||
If you see dark images or masks in the output, make sure node_id is set to ${correctId}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.info("Preparing state to be sent to worker...");
|
||||
const layers = await this._prepareLayers();
|
||||
const state = {
|
||||
@@ -232,6 +259,7 @@ export class CanvasState {
|
||||
viewport: this.canvas.viewport,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
outputAreaBounds: this.canvas.outputAreaBounds,
|
||||
};
|
||||
if (state.layers.length === 0) {
|
||||
log.warn("No valid layers to save, skipping.");
|
||||
@@ -315,10 +343,7 @@ export class CanvasState {
|
||||
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 });
|
||||
const { canvas: clonedCanvas, ctx: clonedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', { willReadFrequently: true });
|
||||
if (clonedCtx) {
|
||||
clonedCtx.drawImage(maskCanvas, 0, 0);
|
||||
}
|
||||
|
||||
551
js/CanvasView.js
551
js/CanvasView.js
@@ -6,7 +6,11 @@ import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js"
|
||||
import { Canvas } from "./Canvas.js";
|
||||
import { clearAllCanvasStates } from "./db.js";
|
||||
import { ImageCache } from "./ImageCache.js";
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { showErrorNotification, showSuccessNotification, showInfoNotification } from "./utils/NotificationUtils.js";
|
||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||
import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
|
||||
const log = createModuleLogger('Canvas_view');
|
||||
async function createCanvasWidget(node, widget, app) {
|
||||
const canvas = new Canvas(node, widget, {
|
||||
@@ -61,20 +65,14 @@ async function createCanvasWidget(node, widget, app) {
|
||||
},
|
||||
}, [
|
||||
$el("div.painter-button-group", {}, [
|
||||
$el("button.painter-button", {
|
||||
$el("button.painter-button.icon-button", {
|
||||
id: `open-editor-btn-${node.id}`,
|
||||
textContent: "⛶",
|
||||
title: "Open in Editor",
|
||||
style: { minWidth: "40px", maxWidth: "40px", fontWeight: "bold" },
|
||||
}),
|
||||
$el("button.painter-button", {
|
||||
$el("button.painter-button.icon-button", {
|
||||
textContent: "?",
|
||||
title: "Show shortcuts",
|
||||
style: {
|
||||
minWidth: "30px",
|
||||
maxWidth: "30px",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
onmouseenter: (e) => {
|
||||
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
||||
showTooltip(e.target, content);
|
||||
@@ -127,38 +125,63 @@ async function createCanvasWidget(node, widget, app) {
|
||||
canvas.canvasLayers.handlePaste(addMode);
|
||||
}
|
||||
}),
|
||||
$el("button.painter-button", {
|
||||
id: `clipboard-toggle-${node.id}`,
|
||||
textContent: "📋 System",
|
||||
title: "Toggle clipboard source: System Clipboard",
|
||||
style: {
|
||||
minWidth: "100px",
|
||||
fontSize: "11px",
|
||||
backgroundColor: "#4a4a4a"
|
||||
},
|
||||
onclick: (e) => {
|
||||
const button = e.target;
|
||||
if (canvas.canvasLayers.clipboardPreference === 'system') {
|
||||
canvas.canvasLayers.clipboardPreference = 'clipspace';
|
||||
button.textContent = "📋 Clipspace";
|
||||
button.title = "Toggle clipboard source: ComfyUI Clipspace";
|
||||
button.style.backgroundColor = "#4a6cd4";
|
||||
(() => {
|
||||
// Modern clipboard switch
|
||||
// Initial state: checked = clipspace, unchecked = system
|
||||
const isClipspace = canvas.canvasLayers.clipboardPreference === 'clipspace';
|
||||
const switchId = `clipboard-switch-${node.id}`;
|
||||
const switchEl = $el("label.clipboard-switch", { id: switchId }, [
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: isClipspace,
|
||||
onchange: (e) => {
|
||||
const checked = e.target.checked;
|
||||
canvas.canvasLayers.clipboardPreference = checked ? 'clipspace' : 'system';
|
||||
// For accessibility, update ARIA label
|
||||
switchEl.setAttribute('aria-label', checked ? "Clipboard: Clipspace" : "Clipboard: System");
|
||||
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
|
||||
}
|
||||
}),
|
||||
$el("span.switch-track"),
|
||||
$el("span.switch-labels", {}, [
|
||||
$el("span.text-clipspace", {}, ["Clipspace"]),
|
||||
$el("span.text-system", {}, ["System"])
|
||||
]),
|
||||
$el("span.switch-knob", {}, [
|
||||
$el("span.switch-icon")
|
||||
])
|
||||
]);
|
||||
// Tooltip logic
|
||||
switchEl.addEventListener("mouseenter", (e) => {
|
||||
const checked = switchEl.querySelector('input[type="checkbox"]').checked;
|
||||
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
||||
showTooltip(switchEl, tooltipContent);
|
||||
});
|
||||
switchEl.addEventListener("mouseleave", hideTooltip);
|
||||
// Dynamic icon and text update on toggle
|
||||
const input = switchEl.querySelector('input[type="checkbox"]');
|
||||
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon');
|
||||
const updateSwitchView = (isClipspace) => {
|
||||
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD;
|
||||
const icon = iconLoader.getIcon(iconTool);
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
knobIcon.innerHTML = '';
|
||||
const clonedIcon = icon.cloneNode();
|
||||
clonedIcon.style.width = '20px';
|
||||
clonedIcon.style.height = '20px';
|
||||
knobIcon.appendChild(clonedIcon);
|
||||
}
|
||||
else {
|
||||
canvas.canvasLayers.clipboardPreference = 'system';
|
||||
button.textContent = "📋 System";
|
||||
button.title = "Toggle clipboard source: System Clipboard";
|
||||
button.style.backgroundColor = "#4a4a4a";
|
||||
knobIcon.textContent = isClipspace ? "🗂️" : "📋";
|
||||
}
|
||||
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
|
||||
},
|
||||
onmouseenter: (e) => {
|
||||
const currentPreference = canvas.canvasLayers.clipboardPreference;
|
||||
const tooltipContent = currentPreference === 'system' ? systemClipboardTooltip : clipspaceClipboardTooltip;
|
||||
showTooltip(e.target, tooltipContent);
|
||||
},
|
||||
onmouseleave: hideTooltip
|
||||
})
|
||||
};
|
||||
input.addEventListener('change', () => updateSwitchView(input.checked));
|
||||
// Initial state
|
||||
iconLoader.preloadToolIcons().then(() => {
|
||||
updateSwitchView(isClipspace);
|
||||
});
|
||||
return switchEl;
|
||||
})()
|
||||
]),
|
||||
]),
|
||||
$el("div.painter-separator"),
|
||||
@@ -239,7 +262,7 @@ async function createCanvasWidget(node, widget, app) {
|
||||
const heightInput = document.getElementById('canvas-height');
|
||||
const width = parseInt(widthInput.value) || canvas.width;
|
||||
const height = parseInt(heightInput.value) || canvas.height;
|
||||
canvas.updateOutputAreaSize(width, height);
|
||||
canvas.setOutputAreaSize(width, height);
|
||||
document.body.removeChild(dialog);
|
||||
};
|
||||
document.getElementById('cancel-size').onclick = () => {
|
||||
@@ -308,9 +331,11 @@ async function createCanvasWidget(node, widget, app) {
|
||||
const spinner = $el("div.matting-spinner");
|
||||
button.appendChild(spinner);
|
||||
button.classList.add('loading');
|
||||
showInfoNotification("Starting background removal process...", 2000);
|
||||
try {
|
||||
if (canvas.canvasSelection.selectedLayers.length !== 1)
|
||||
if (canvas.canvasSelection.selectedLayers.length !== 1) {
|
||||
throw new Error("Please select exactly one image layer for matting.");
|
||||
}
|
||||
const selectedLayer = canvas.canvasSelection.selectedLayers[0];
|
||||
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
|
||||
const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer);
|
||||
@@ -323,7 +348,7 @@ async function createCanvasWidget(node, widget, app) {
|
||||
if (!response.ok) {
|
||||
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
|
||||
if (result && result.error) {
|
||||
errorMsg = `Error: ${result.error}\n\nDetails: ${result.details}`;
|
||||
errorMsg = `Error: ${result.error}. Details: ${result.details || 'Check console'}`;
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
@@ -336,12 +361,12 @@ async function createCanvasWidget(node, widget, app) {
|
||||
canvas.canvasSelection.updateSelection([newLayer]);
|
||||
canvas.render();
|
||||
canvas.saveState();
|
||||
showSuccessNotification("Background removed successfully!");
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Matting error:", error);
|
||||
const errorMessage = error.message || "An unknown error occurred.";
|
||||
const errorDetails = error.stack || (error.details ? JSON.stringify(error.details, null, 2) : "No details available.");
|
||||
showErrorDialog(errorMessage, errorDetails);
|
||||
showErrorNotification(`Matting Failed: ${errorMessage}`);
|
||||
}
|
||||
finally {
|
||||
button.classList.remove('loading');
|
||||
@@ -368,24 +393,66 @@ async function createCanvasWidget(node, widget, app) {
|
||||
]),
|
||||
$el("div.painter-separator"),
|
||||
$el("div.painter-button-group", { id: "mask-controls" }, [
|
||||
$el("button.painter-button.primary", {
|
||||
id: `toggle-mask-btn-${node.id}`,
|
||||
textContent: "Show Mask",
|
||||
title: "Toggle mask overlay visibility",
|
||||
onclick: (e) => {
|
||||
const button = e.target;
|
||||
canvas.maskTool.toggleOverlayVisibility();
|
||||
canvas.render();
|
||||
if (canvas.maskTool.isOverlayVisible) {
|
||||
button.classList.add('primary');
|
||||
button.textContent = "Show Mask";
|
||||
$el("label.clipboard-switch.mask-switch", {
|
||||
id: `toggle-mask-switch-${node.id}`,
|
||||
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" }
|
||||
}, [
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: canvas.maskTool.isOverlayVisible,
|
||||
onchange: (e) => {
|
||||
const checked = e.target.checked;
|
||||
canvas.maskTool.isOverlayVisible = checked;
|
||||
canvas.render();
|
||||
}
|
||||
else {
|
||||
button.classList.remove('primary');
|
||||
button.textContent = "Hide Mask";
|
||||
}
|
||||
}
|
||||
}),
|
||||
}),
|
||||
$el("span.switch-track"),
|
||||
$el("span.switch-labels", { style: { fontSize: "11px" } }, [
|
||||
$el("span.text-clipspace", { style: { paddingRight: "22px" } }, ["On"]),
|
||||
$el("span.text-system", { style: { paddingLeft: "20px" } }, ["Off"])
|
||||
]),
|
||||
$el("span.switch-knob", {}, [
|
||||
(() => {
|
||||
// Ikona maski (SVG lub obrazek)
|
||||
const iconContainer = document.createElement('span');
|
||||
iconContainer.className = 'switch-icon';
|
||||
iconContainer.style.display = 'flex';
|
||||
iconContainer.style.alignItems = 'center';
|
||||
iconContainer.style.justifyContent = 'center';
|
||||
iconContainer.style.width = '16px';
|
||||
iconContainer.style.height = '16px';
|
||||
// Pobierz ikonę maski z iconLoader
|
||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.MASK);
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode();
|
||||
img.style.width = "16px";
|
||||
img.style.height = "16px";
|
||||
// Ustaw filtr w zależności od stanu checkboxa
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById(`toggle-mask-switch-${node.id}`)?.querySelector('input[type="checkbox"]');
|
||||
const updateIconFilter = () => {
|
||||
if (input && img) {
|
||||
img.style.filter = input.checked
|
||||
? "brightness(0) invert(1)"
|
||||
: "grayscale(1) brightness(0.7) opacity(0.6)";
|
||||
}
|
||||
};
|
||||
if (input) {
|
||||
input.addEventListener('change', updateIconFilter);
|
||||
updateIconFilter();
|
||||
}
|
||||
}, 0);
|
||||
iconContainer.appendChild(img);
|
||||
}
|
||||
else {
|
||||
iconContainer.textContent = "M";
|
||||
iconContainer.style.fontSize = "12px";
|
||||
iconContainer.style.color = "#fff";
|
||||
}
|
||||
return iconContainer;
|
||||
})()
|
||||
])
|
||||
]),
|
||||
$el("button.painter-button", {
|
||||
textContent: "Edit Mask",
|
||||
title: "Open the current canvas view in the mask editor",
|
||||
@@ -421,8 +488,15 @@ async function createCanvasWidget(node, widget, app) {
|
||||
min: "1",
|
||||
max: "200",
|
||||
value: "20",
|
||||
oninput: (e) => canvas.maskTool.setBrushSize(parseInt(e.target.value))
|
||||
})
|
||||
oninput: (e) => {
|
||||
const value = e.target.value;
|
||||
canvas.maskTool.setBrushSize(parseInt(value));
|
||||
const valueEl = document.getElementById('brush-size-value');
|
||||
if (valueEl)
|
||||
valueEl.textContent = `${value}px`;
|
||||
}
|
||||
}),
|
||||
$el("div.slider-value", { id: "brush-size-value" }, ["20px"])
|
||||
]),
|
||||
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
|
||||
$el("label", { for: "brush-strength-slider", textContent: "Strength:" }),
|
||||
@@ -433,8 +507,15 @@ async function createCanvasWidget(node, widget, app) {
|
||||
max: "1",
|
||||
step: "0.05",
|
||||
value: "0.5",
|
||||
oninput: (e) => canvas.maskTool.setBrushStrength(parseFloat(e.target.value))
|
||||
})
|
||||
oninput: (e) => {
|
||||
const value = e.target.value;
|
||||
canvas.maskTool.setBrushStrength(parseFloat(value));
|
||||
const valueEl = document.getElementById('brush-strength-value');
|
||||
if (valueEl)
|
||||
valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
|
||||
}
|
||||
}),
|
||||
$el("div.slider-value", { id: "brush-strength-value" }, ["50%"])
|
||||
]),
|
||||
$el("div.painter-slider-container.mask-control", { style: { display: 'none' } }, [
|
||||
$el("label", { for: "brush-hardness-slider", textContent: "Hardness:" }),
|
||||
@@ -445,8 +526,15 @@ async function createCanvasWidget(node, widget, app) {
|
||||
max: "1",
|
||||
step: "0.05",
|
||||
value: "0.5",
|
||||
oninput: (e) => canvas.maskTool.setBrushHardness(parseFloat(e.target.value))
|
||||
})
|
||||
oninput: (e) => {
|
||||
const value = e.target.value;
|
||||
canvas.maskTool.setBrushHardness(parseFloat(value));
|
||||
const valueEl = document.getElementById('brush-hardness-value');
|
||||
if (valueEl)
|
||||
valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
|
||||
}
|
||||
}),
|
||||
$el("div.slider-value", { id: "brush-hardness-value" }, ["50%"])
|
||||
]),
|
||||
$el("button.painter-button.mask-control", {
|
||||
textContent: "Clear Mask",
|
||||
@@ -462,10 +550,9 @@ async function createCanvasWidget(node, widget, app) {
|
||||
]),
|
||||
$el("div.painter-separator"),
|
||||
$el("div.painter-button-group", {}, [
|
||||
$el("button.painter-button", {
|
||||
$el("button.painter-button.success", {
|
||||
textContent: "Run GC",
|
||||
title: "Run Garbage Collection to clean unused images",
|
||||
style: { backgroundColor: "#4a7c59", borderColor: "#3a6c49" },
|
||||
onclick: async () => {
|
||||
try {
|
||||
const stats = canvas.imageReferenceManager.getStats();
|
||||
@@ -473,27 +560,26 @@ async function createCanvasWidget(node, widget, app) {
|
||||
await canvas.imageReferenceManager.manualGarbageCollection();
|
||||
const newStats = canvas.imageReferenceManager.getStats();
|
||||
log.info("GC Stats after cleanup:", newStats);
|
||||
alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${canvas.imageReferenceManager.operationCount}/${canvas.imageReferenceManager.operationThreshold}`);
|
||||
showSuccessNotification(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${canvas.imageReferenceManager.operationCount}/${canvas.imageReferenceManager.operationThreshold}`);
|
||||
}
|
||||
catch (e) {
|
||||
log.error("Failed to run garbage collection:", e);
|
||||
alert("Error running garbage collection. Check the console for details.");
|
||||
showErrorNotification("Error running garbage collection. Check the console for details.");
|
||||
}
|
||||
}
|
||||
}),
|
||||
$el("button.painter-button", {
|
||||
$el("button.painter-button.danger", {
|
||||
textContent: "Clear Cache",
|
||||
title: "Clear all saved canvas states from browser storage",
|
||||
style: { backgroundColor: "#c54747", borderColor: "#a53737" },
|
||||
onclick: async () => {
|
||||
if (confirm("Are you sure you want to clear all saved canvas states? This action cannot be undone.")) {
|
||||
try {
|
||||
await clearAllCanvasStates();
|
||||
alert("Canvas cache cleared successfully!");
|
||||
showSuccessNotification("Canvas cache cleared successfully!");
|
||||
}
|
||||
catch (e) {
|
||||
log.error("Failed to clear canvas cache:", e);
|
||||
alert("Error clearing canvas cache. Check the console for details.");
|
||||
showErrorNotification("Error clearing canvas cache. Check the console for details.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,6 +588,44 @@ async function createCanvasWidget(node, widget, app) {
|
||||
]),
|
||||
$el("div.painter-separator")
|
||||
]);
|
||||
// Function to create mask icon
|
||||
const createMaskIcon = () => {
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.className = 'mask-icon-container';
|
||||
iconContainer.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.MASK);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode();
|
||||
img.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
filter: brightness(0) invert(1);
|
||||
`;
|
||||
iconContainer.appendChild(img);
|
||||
}
|
||||
else if (icon instanceof HTMLCanvasElement) {
|
||||
const { canvas, ctx } = createCanvas(16, 16);
|
||||
if (ctx) {
|
||||
ctx.drawImage(icon, 0, 0, 16, 16);
|
||||
}
|
||||
iconContainer.appendChild(canvas);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Fallback text
|
||||
iconContainer.textContent = 'M';
|
||||
iconContainer.style.fontSize = '12px';
|
||||
iconContainer.style.color = '#ffffff';
|
||||
}
|
||||
return iconContainer;
|
||||
};
|
||||
const updateButtonStates = () => {
|
||||
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
||||
const hasSelection = selectionCount > 0;
|
||||
@@ -530,27 +654,103 @@ async function createCanvasWidget(node, widget, app) {
|
||||
};
|
||||
updateButtonStates();
|
||||
canvas.updateHistoryButtons();
|
||||
// Add mask icon to toggle mask button after icons are loaded
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await iconLoader.preloadToolIcons();
|
||||
const toggleMaskBtn = controlPanel.querySelector(`#toggle-mask-btn-${node.id}`);
|
||||
if (toggleMaskBtn && !toggleMaskBtn.querySelector('.mask-icon-container')) {
|
||||
// Clear fallback text
|
||||
toggleMaskBtn.textContent = '';
|
||||
const maskIcon = createMaskIcon();
|
||||
toggleMaskBtn.appendChild(maskIcon);
|
||||
// Set initial state based on mask visibility
|
||||
if (canvas.maskTool.isOverlayVisible) {
|
||||
toggleMaskBtn.classList.add('primary');
|
||||
maskIcon.style.opacity = '1';
|
||||
}
|
||||
else {
|
||||
toggleMaskBtn.classList.remove('primary');
|
||||
maskIcon.style.opacity = '0.5';
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.warn('Failed to load mask icon:', error);
|
||||
}
|
||||
}, 200);
|
||||
// Debounce timer for updateOutput to prevent excessive updates
|
||||
let updateOutputTimer = null;
|
||||
const updateOutput = async (node, canvas) => {
|
||||
// Check if preview is disabled - if so, skip updateOutput entirely
|
||||
const triggerWidget = node.widgets.find((w) => w.name === "trigger");
|
||||
if (triggerWidget) {
|
||||
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
|
||||
}
|
||||
try {
|
||||
const new_preview = new Image();
|
||||
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (blob) {
|
||||
new_preview.src = URL.createObjectURL(blob);
|
||||
await new Promise(r => new_preview.onload = r);
|
||||
node.imgs = [new_preview];
|
||||
}
|
||||
else {
|
||||
node.imgs = [];
|
||||
}
|
||||
const showPreviewWidget = node.widgets.find((w) => w.name === "show_preview");
|
||||
if (showPreviewWidget && !showPreviewWidget.value) {
|
||||
log.debug("Preview disabled, skipping updateOutput");
|
||||
const PLACEHOLDER_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
||||
const placeholder = new Image();
|
||||
placeholder.src = PLACEHOLDER_IMAGE;
|
||||
node.imgs = [placeholder];
|
||||
return;
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error updating node preview:", error);
|
||||
// Clear previous timer
|
||||
if (updateOutputTimer) {
|
||||
clearTimeout(updateOutputTimer);
|
||||
}
|
||||
// Debounce the update to prevent excessive processing during rapid changes
|
||||
updateOutputTimer = setTimeout(async () => {
|
||||
try {
|
||||
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (blob) {
|
||||
// For large images, use blob URL for better performance
|
||||
if (blob.size > 2 * 1024 * 1024) { // 2MB threshold
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
node.imgs = [img];
|
||||
log.debug(`Using blob URL for large image (${(blob.size / 1024 / 1024).toFixed(1)}MB): ${blobUrl.substring(0, 50)}...`);
|
||||
// Clean up old blob URLs to prevent memory leaks
|
||||
if (node.imgs.length > 1) {
|
||||
const oldImg = node.imgs[0];
|
||||
if (oldImg.src.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(oldImg.src);
|
||||
}
|
||||
}
|
||||
};
|
||||
img.src = blobUrl;
|
||||
}
|
||||
else {
|
||||
// For smaller images, use data URI as before
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
node.imgs = [img];
|
||||
log.debug(`Using data URI for small image (${(blob.size / 1024).toFixed(1)}KB): ${dataUrl.substring(0, 50)}...`);
|
||||
};
|
||||
img.src = dataUrl;
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
}
|
||||
else {
|
||||
node.imgs = [];
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error updating node preview:", error);
|
||||
}
|
||||
}, 250); // 150ms debounce delay
|
||||
};
|
||||
// Store previous temp filenames for cleanup (make it globally accessible)
|
||||
if (!window.layerForgeTempFileTracker) {
|
||||
window.layerForgeTempFileTracker = new Map();
|
||||
}
|
||||
const tempFileTracker = window.layerForgeTempFileTracker;
|
||||
const layersPanel = canvas.canvasLayersPanel.createPanelStructure();
|
||||
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
|
||||
style: {
|
||||
@@ -562,6 +762,7 @@ async function createCanvasWidget(node, widget, app) {
|
||||
overflow: "hidden"
|
||||
}
|
||||
}, [canvas.canvas]);
|
||||
canvas.canvasContainer = canvasContainer;
|
||||
const layersPanelContainer = $el("div.painterLayersPanelContainer", {
|
||||
style: {
|
||||
position: "absolute",
|
||||
@@ -603,6 +804,32 @@ async function createCanvasWidget(node, widget, app) {
|
||||
let backdrop = null;
|
||||
let originalParent = null;
|
||||
let isEditorOpen = false;
|
||||
let viewportAdjustment = { x: 0, y: 0 };
|
||||
/**
|
||||
* Adjusts the viewport when entering fullscreen mode.
|
||||
*/
|
||||
const adjustViewportOnOpen = (originalRect) => {
|
||||
const fullscreenRect = canvasContainer.getBoundingClientRect();
|
||||
const widthDiff = fullscreenRect.width - originalRect.width;
|
||||
const heightDiff = fullscreenRect.height - originalRect.height;
|
||||
const adjustX = (widthDiff / 2) / canvas.viewport.zoom;
|
||||
const adjustY = (heightDiff / 2) / canvas.viewport.zoom;
|
||||
// Store the adjustment
|
||||
viewportAdjustment = { x: adjustX, y: adjustY };
|
||||
// Apply the adjustment
|
||||
canvas.viewport.x -= viewportAdjustment.x;
|
||||
canvas.viewport.y -= viewportAdjustment.y;
|
||||
};
|
||||
/**
|
||||
* Restores the viewport when exiting fullscreen mode.
|
||||
*/
|
||||
const adjustViewportOnClose = () => {
|
||||
// Apply the stored adjustment in reverse
|
||||
canvas.viewport.x += viewportAdjustment.x;
|
||||
canvas.viewport.y += viewportAdjustment.y;
|
||||
// Reset adjustment
|
||||
viewportAdjustment = { x: 0, y: 0 };
|
||||
};
|
||||
const closeEditor = () => {
|
||||
if (originalParent && backdrop) {
|
||||
originalParent.appendChild(mainContainer);
|
||||
@@ -611,18 +838,30 @@ async function createCanvasWidget(node, widget, app) {
|
||||
isEditorOpen = false;
|
||||
openEditorBtn.textContent = "⛶";
|
||||
openEditorBtn.title = "Open in Editor";
|
||||
// Remove ESC key listener when editor closes
|
||||
document.removeEventListener('keydown', handleEscKey);
|
||||
setTimeout(() => {
|
||||
adjustViewportOnClose();
|
||||
canvas.render();
|
||||
if (node.onResize) {
|
||||
node.onResize();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
// ESC key handler for closing fullscreen editor
|
||||
const handleEscKey = (e) => {
|
||||
if (e.key === 'Escape' && isEditorOpen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeEditor();
|
||||
}
|
||||
};
|
||||
openEditorBtn.onclick = () => {
|
||||
if (isEditorOpen) {
|
||||
closeEditor();
|
||||
return;
|
||||
}
|
||||
const originalRect = canvasContainer.getBoundingClientRect();
|
||||
originalParent = mainContainer.parentElement;
|
||||
if (!originalParent) {
|
||||
log.error("Could not find original parent of the canvas container!");
|
||||
@@ -635,8 +874,11 @@ async function createCanvasWidget(node, widget, app) {
|
||||
document.body.appendChild(backdrop);
|
||||
isEditorOpen = true;
|
||||
openEditorBtn.textContent = "X";
|
||||
openEditorBtn.title = "Close Editor";
|
||||
openEditorBtn.title = "Close Editor (ESC)";
|
||||
// Add ESC key listener when editor opens
|
||||
document.addEventListener('keydown', handleEscKey);
|
||||
setTimeout(() => {
|
||||
adjustViewportOnOpen(originalRect);
|
||||
canvas.render();
|
||||
if (node.onResize) {
|
||||
node.onResize();
|
||||
@@ -677,55 +919,6 @@ async function createCanvasWidget(node, widget, app) {
|
||||
panel: controlPanel
|
||||
};
|
||||
}
|
||||
function showErrorDialog(message, details) {
|
||||
const dialog = $el("div.painter-dialog.error-dialog", {
|
||||
style: {
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: '9999',
|
||||
padding: '20px',
|
||||
background: '#282828',
|
||||
border: '1px solid #ff4444',
|
||||
borderRadius: '8px',
|
||||
minWidth: '400px',
|
||||
maxWidth: '80vw',
|
||||
}
|
||||
}, [
|
||||
$el("h3", { textContent: "Matting Error", style: { color: "#ff4444", marginTop: "0" } }),
|
||||
$el("p", { textContent: message, style: { color: "white" } }),
|
||||
$el("pre.error-details", {
|
||||
textContent: details,
|
||||
style: {
|
||||
background: "#1e1e1e",
|
||||
border: "1px solid #444",
|
||||
padding: "10px",
|
||||
maxHeight: "300px",
|
||||
overflowY: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
color: "#ccc"
|
||||
}
|
||||
}),
|
||||
$el("div.dialog-buttons", { style: { textAlign: "right", marginTop: "20px" } }, [
|
||||
$el("button", {
|
||||
textContent: "Copy Details",
|
||||
onclick: () => {
|
||||
navigator.clipboard.writeText(details)
|
||||
.then(() => alert("Error details copied to clipboard!"))
|
||||
.catch(err => alert("Failed to copy details: " + err));
|
||||
}
|
||||
}),
|
||||
$el("button", {
|
||||
textContent: "Close",
|
||||
style: { marginLeft: "10px" },
|
||||
onclick: () => document.body.removeChild(dialog)
|
||||
})
|
||||
])
|
||||
]);
|
||||
document.body.appendChild(dialog);
|
||||
}
|
||||
const canvasNodeInstances = new Map();
|
||||
app.registerExtension({
|
||||
name: "Comfy.CanvasNode",
|
||||
@@ -753,7 +946,7 @@ app.registerExtension({
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Failed to send canvas data for one or more nodes. Aborting prompt.", error);
|
||||
alert(`CanvasNode Error: ${error.message}`);
|
||||
showErrorNotification(`CanvasNode Error: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -798,6 +991,13 @@ app.registerExtension({
|
||||
const onRemoved = nodeType.prototype.onRemoved;
|
||||
nodeType.prototype.onRemoved = function () {
|
||||
log.info(`Cleaning up canvas node ${this.id}`);
|
||||
// Clean up temp file tracker for this node (just remove from tracker)
|
||||
const nodeKey = `node-${this.id}`;
|
||||
const tempFileTracker = window.layerForgeTempFileTracker;
|
||||
if (tempFileTracker && tempFileTracker.has(nodeKey)) {
|
||||
tempFileTracker.delete(nodeKey);
|
||||
log.debug(`Removed temp file tracker for node ${this.id}`);
|
||||
}
|
||||
canvasNodeInstances.delete(this.id);
|
||||
log.info(`Deregistered CanvasNode instance for ID: ${this.id}`);
|
||||
if (window.canvasExecutionStates) {
|
||||
@@ -818,12 +1018,81 @@ app.registerExtension({
|
||||
};
|
||||
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
// FIRST: Call original to let other extensions add their options
|
||||
originalGetExtraMenuOptions?.apply(this, arguments);
|
||||
const self = this;
|
||||
// Debug: Log all menu options AFTER other extensions have added theirs
|
||||
log.info("Available menu options AFTER original call:", options.map((opt, idx) => ({
|
||||
index: idx,
|
||||
content: opt?.content,
|
||||
hasCallback: !!opt?.callback
|
||||
})));
|
||||
// Debug: Check node data to see what Impact Pack sees
|
||||
const nodeData = self.constructor.nodeData || {};
|
||||
log.info("Node data for Impact Pack check:", {
|
||||
output: nodeData.output,
|
||||
outputType: typeof nodeData.output,
|
||||
isArray: Array.isArray(nodeData.output),
|
||||
nodeType: self.type,
|
||||
comfyClass: self.comfyClass
|
||||
});
|
||||
// Additional debug: Check if any option contains common Impact Pack keywords
|
||||
const impactOptions = options.filter((opt, idx) => {
|
||||
if (!opt || !opt.content)
|
||||
return false;
|
||||
const content = opt.content.toLowerCase();
|
||||
return content.includes('impact') ||
|
||||
content.includes('sam') ||
|
||||
content.includes('detector') ||
|
||||
content.includes('segment') ||
|
||||
content.includes('mask') ||
|
||||
content.includes('open in');
|
||||
});
|
||||
if (impactOptions.length > 0) {
|
||||
log.info("Found potential Impact Pack options:", impactOptions.map(opt => opt.content));
|
||||
}
|
||||
else {
|
||||
log.info("No Impact Pack-related options found in menu");
|
||||
}
|
||||
// Debug: Check if Impact Pack extension is loaded
|
||||
const impactExtensions = app.extensions.filter((ext) => ext.name && ext.name.toLowerCase().includes('impact'));
|
||||
log.info("Impact Pack extensions found:", impactExtensions.map((ext) => ext.name));
|
||||
// Debug: Check menu options again after a delay to see if Impact Pack adds options later
|
||||
setTimeout(() => {
|
||||
log.info("Menu options after 100ms delay:", options.map((opt, idx) => ({
|
||||
index: idx,
|
||||
content: opt?.content,
|
||||
hasCallback: !!opt?.callback
|
||||
})));
|
||||
// Try to find SAM Detector again
|
||||
const delayedSamDetectorIndex = options.findIndex((option) => option && option.content && (option.content.includes("SAM Detector") ||
|
||||
option.content.includes("SAM") ||
|
||||
option.content.includes("Detector") ||
|
||||
option.content.toLowerCase().includes("sam") ||
|
||||
option.content.toLowerCase().includes("detector")));
|
||||
if (delayedSamDetectorIndex !== -1) {
|
||||
log.info(`Found SAM Detector after delay at index ${delayedSamDetectorIndex}: "${options[delayedSamDetectorIndex].content}"`);
|
||||
}
|
||||
else {
|
||||
log.info("SAM Detector still not found after delay");
|
||||
}
|
||||
}, 100);
|
||||
// Debug: Let's also check what the Impact Pack extension actually does
|
||||
const samExtension = app.extensions.find((ext) => ext.name === 'Comfy.Impact.SAMEditor');
|
||||
if (samExtension) {
|
||||
log.info("SAM Extension details:", {
|
||||
name: samExtension.name,
|
||||
hasBeforeRegisterNodeDef: !!samExtension.beforeRegisterNodeDef,
|
||||
hasInit: !!samExtension.init
|
||||
});
|
||||
}
|
||||
// Remove our old MaskEditor if it exists
|
||||
const maskEditorIndex = options.findIndex((option) => option && option.content === "Open in MaskEditor");
|
||||
if (maskEditorIndex !== -1) {
|
||||
options.splice(maskEditorIndex, 1);
|
||||
}
|
||||
// Hook into "Open in SAM Detector" using the new integration module
|
||||
setupSAMDetectorHook(self, options);
|
||||
const newOptions = [
|
||||
{
|
||||
content: "Open in MaskEditor",
|
||||
@@ -835,12 +1104,12 @@ app.registerExtension({
|
||||
}
|
||||
else {
|
||||
log.error("Canvas widget not available");
|
||||
alert("Canvas not ready. Please try again.");
|
||||
showErrorNotification("Canvas not ready. Please try again.");
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
log.error("Error opening MaskEditor:", e);
|
||||
alert(`Failed to open MaskEditor: ${e.message}`);
|
||||
showErrorNotification(`Failed to open MaskEditor: ${e.message}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -895,7 +1164,7 @@ app.registerExtension({
|
||||
}
|
||||
catch (e) {
|
||||
log.error("Error copying image:", e);
|
||||
alert("Failed to copy image to clipboard.");
|
||||
showErrorNotification("Failed to copy image to clipboard.");
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -914,7 +1183,7 @@ app.registerExtension({
|
||||
}
|
||||
catch (e) {
|
||||
log.error("Error copying image with mask:", e);
|
||||
alert("Failed to copy image with mask to clipboard.");
|
||||
showErrorNotification("Failed to copy image with mask to clipboard.");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
582
js/CustomShapeMenu.js
Normal file
582
js/CustomShapeMenu.js
Normal file
@@ -0,0 +1,582 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
|
||||
const log = createModuleLogger('CustomShapeMenu');
|
||||
export class CustomShapeMenu {
|
||||
constructor(canvas) {
|
||||
this.isMinimized = false;
|
||||
this.canvas = canvas;
|
||||
this.element = null;
|
||||
this.worldX = 0;
|
||||
this.worldY = 0;
|
||||
this.uiInitialized = false;
|
||||
this.tooltip = null;
|
||||
}
|
||||
show() {
|
||||
if (!this.canvas.outputAreaShape) {
|
||||
return;
|
||||
}
|
||||
this._createUI();
|
||||
if (this.element) {
|
||||
this.element.style.display = 'block';
|
||||
this._updateMinimizedState();
|
||||
}
|
||||
// Position in top-left corner of viewport (closer to edge)
|
||||
const viewLeft = this.canvas.viewport.x;
|
||||
const viewTop = this.canvas.viewport.y;
|
||||
this.worldX = viewLeft + (8 / this.canvas.viewport.zoom);
|
||||
this.worldY = viewTop + (8 / this.canvas.viewport.zoom);
|
||||
this.updateScreenPosition();
|
||||
}
|
||||
hide() {
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
this.uiInitialized = false;
|
||||
}
|
||||
this.hideTooltip();
|
||||
}
|
||||
updateScreenPosition() {
|
||||
if (!this.element)
|
||||
return;
|
||||
const screenX = (this.worldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (this.worldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
this.element.style.transform = `translate(${screenX}px, ${screenY}px)`;
|
||||
}
|
||||
_createUI() {
|
||||
if (this.uiInitialized)
|
||||
return;
|
||||
addStylesheet(getUrl('./css/custom_shape_menu.css'));
|
||||
this.element = document.createElement('div');
|
||||
this.element.id = 'layerforge-custom-shape-menu';
|
||||
// --- MINIMIZED BAR ---
|
||||
const minimizedBar = document.createElement('div');
|
||||
minimizedBar.className = 'custom-shape-minimized-bar';
|
||||
minimizedBar.textContent = "Custom Output Area Active";
|
||||
minimizedBar.style.display = 'none';
|
||||
minimizedBar.style.cursor = 'pointer';
|
||||
minimizedBar.onclick = () => {
|
||||
this.isMinimized = false;
|
||||
this._updateMinimizedState();
|
||||
};
|
||||
this.element.appendChild(minimizedBar);
|
||||
// --- FULL MENU ---
|
||||
const fullMenu = document.createElement('div');
|
||||
fullMenu.className = 'custom-shape-full-menu';
|
||||
// Minimize button (top right)
|
||||
const minimizeBtn = document.createElement('button');
|
||||
minimizeBtn.innerHTML = "–";
|
||||
minimizeBtn.title = "Minimize menu";
|
||||
minimizeBtn.className = 'custom-shape-minimize-btn';
|
||||
minimizeBtn.style.position = 'absolute';
|
||||
minimizeBtn.style.top = '4px';
|
||||
minimizeBtn.style.right = '4px';
|
||||
minimizeBtn.style.width = '24px';
|
||||
minimizeBtn.style.height = '24px';
|
||||
minimizeBtn.style.border = 'none';
|
||||
minimizeBtn.style.background = 'transparent';
|
||||
minimizeBtn.style.color = '#888';
|
||||
minimizeBtn.style.fontSize = '20px';
|
||||
minimizeBtn.style.cursor = 'pointer';
|
||||
minimizeBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.isMinimized = true;
|
||||
this._updateMinimizedState();
|
||||
};
|
||||
fullMenu.appendChild(minimizeBtn);
|
||||
// Create menu content
|
||||
const lines = [
|
||||
"Custom Output Area Active"
|
||||
];
|
||||
lines.forEach(line => {
|
||||
const lineElement = document.createElement('div');
|
||||
lineElement.textContent = line;
|
||||
lineElement.className = 'menu-line';
|
||||
fullMenu.appendChild(lineElement);
|
||||
});
|
||||
// Create a container for the entire shape mask feature set
|
||||
const featureContainer = document.createElement('div');
|
||||
featureContainer.id = 'shape-mask-feature-container';
|
||||
featureContainer.className = 'feature-container';
|
||||
// Add main auto-apply checkbox to the new container
|
||||
const checkboxContainer = this._createCheckbox('auto-apply-checkbox', () => this.canvas.autoApplyShapeMask, 'Auto-apply shape mask', (e) => {
|
||||
this.canvas.autoApplyShapeMask = e.target.checked;
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
log.info("Auto-apply shape mask enabled - mask applied automatically");
|
||||
}
|
||||
else {
|
||||
this.canvas.maskTool.removeShapeMask();
|
||||
this.canvas.shapeMaskExpansion = false;
|
||||
this.canvas.shapeMaskFeather = false;
|
||||
log.info("Auto-apply shape mask disabled - mask area removed and sub-options reset.");
|
||||
}
|
||||
this._updateUI();
|
||||
this.canvas.render();
|
||||
}, "Automatically applies a mask based on the custom output area shape. When enabled, the mask will be applied to all layers within the shape boundary.");
|
||||
featureContainer.appendChild(checkboxContainer);
|
||||
// Add expansion checkbox
|
||||
const expansionContainer = this._createCheckbox('expansion-checkbox', () => this.canvas.shapeMaskExpansion, 'Expand/Contract mask', (e) => {
|
||||
this.canvas.shapeMaskExpansion = e.target.checked;
|
||||
this._updateUI();
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.hideShapePreview();
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
this.canvas.render();
|
||||
}
|
||||
}, "Dilate (expand) or erode (contract) the shape mask. Positive values expand the mask outward, negative values shrink it inward.");
|
||||
featureContainer.appendChild(expansionContainer);
|
||||
// Add expansion slider container
|
||||
const expansionSliderContainer = document.createElement('div');
|
||||
expansionSliderContainer.id = 'expansion-slider-container';
|
||||
expansionSliderContainer.className = 'slider-container';
|
||||
const expansionSliderLabel = document.createElement('div');
|
||||
expansionSliderLabel.textContent = 'Expansion amount:';
|
||||
expansionSliderLabel.className = 'slider-label';
|
||||
const expansionSlider = document.createElement('input');
|
||||
expansionSlider.type = 'range';
|
||||
expansionSlider.min = '-300';
|
||||
expansionSlider.max = '300';
|
||||
expansionSlider.value = String(this.canvas.shapeMaskExpansionValue);
|
||||
const expansionValueDisplay = document.createElement('div');
|
||||
expansionValueDisplay.className = 'slider-value-display';
|
||||
let expansionValueBeforeDrag = this.canvas.shapeMaskExpansionValue;
|
||||
const updateExpansionSliderDisplay = () => {
|
||||
const value = parseInt(expansionSlider.value);
|
||||
this.canvas.shapeMaskExpansionValue = value;
|
||||
expansionValueDisplay.textContent = value > 0 ? `+${value}px` : `${value}px`;
|
||||
};
|
||||
let isExpansionDragging = false;
|
||||
expansionSlider.onmousedown = () => {
|
||||
isExpansionDragging = true;
|
||||
expansionValueBeforeDrag = this.canvas.shapeMaskExpansionValue; // Store value before dragging
|
||||
};
|
||||
expansionSlider.oninput = () => {
|
||||
updateExpansionSliderDisplay();
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
if (isExpansionDragging) {
|
||||
const featherValue = this.canvas.shapeMaskFeather ? this.canvas.shapeMaskFeatherValue : 0;
|
||||
this.canvas.maskTool.showShapePreview(this.canvas.shapeMaskExpansionValue, featherValue);
|
||||
}
|
||||
else {
|
||||
this.canvas.maskTool.hideShapePreview();
|
||||
this.canvas.maskTool.applyShapeMask(false);
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
};
|
||||
expansionSlider.onmouseup = () => {
|
||||
isExpansionDragging = false;
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
const finalValue = parseInt(expansionSlider.value);
|
||||
// If value changed during drag, remove old mask with previous expansion value
|
||||
if (expansionValueBeforeDrag !== finalValue) {
|
||||
// Temporarily set the previous value to remove the old mask properly
|
||||
const tempValue = this.canvas.shapeMaskExpansionValue;
|
||||
this.canvas.shapeMaskExpansionValue = expansionValueBeforeDrag;
|
||||
this.canvas.maskTool.removeShapeMask();
|
||||
this.canvas.shapeMaskExpansionValue = tempValue; // Restore current value
|
||||
log.info(`Removed old shape mask with expansion: ${expansionValueBeforeDrag}px before applying new value: ${finalValue}px`);
|
||||
}
|
||||
this.canvas.maskTool.hideShapePreview();
|
||||
this.canvas.maskTool.applyShapeMask(true);
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
updateExpansionSliderDisplay();
|
||||
expansionSliderContainer.appendChild(expansionSliderLabel);
|
||||
expansionSliderContainer.appendChild(expansionSlider);
|
||||
expansionSliderContainer.appendChild(expansionValueDisplay);
|
||||
featureContainer.appendChild(expansionSliderContainer);
|
||||
// Add feather checkbox
|
||||
const featherContainer = this._createCheckbox('feather-checkbox', () => this.canvas.shapeMaskFeather, 'Feather edges', (e) => {
|
||||
this.canvas.shapeMaskFeather = e.target.checked;
|
||||
this._updateUI();
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.hideShapePreview();
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
this.canvas.render();
|
||||
}
|
||||
}, "Softens the edges of the shape mask by creating a gradual transition from opaque to transparent.");
|
||||
featureContainer.appendChild(featherContainer);
|
||||
// Add feather slider container
|
||||
const featherSliderContainer = document.createElement('div');
|
||||
featherSliderContainer.id = 'feather-slider-container';
|
||||
featherSliderContainer.className = 'slider-container';
|
||||
const featherSliderLabel = document.createElement('div');
|
||||
featherSliderLabel.textContent = 'Feather amount:';
|
||||
featherSliderLabel.className = 'slider-label';
|
||||
const featherSlider = document.createElement('input');
|
||||
featherSlider.type = 'range';
|
||||
featherSlider.min = '0';
|
||||
featherSlider.max = '300';
|
||||
featherSlider.value = String(this.canvas.shapeMaskFeatherValue);
|
||||
const featherValueDisplay = document.createElement('div');
|
||||
featherValueDisplay.className = 'slider-value-display';
|
||||
const updateFeatherSliderDisplay = () => {
|
||||
const value = parseInt(featherSlider.value);
|
||||
this.canvas.shapeMaskFeatherValue = value;
|
||||
featherValueDisplay.textContent = `${value}px`;
|
||||
};
|
||||
let isFeatherDragging = false;
|
||||
featherSlider.onmousedown = () => { isFeatherDragging = true; };
|
||||
featherSlider.oninput = () => {
|
||||
updateFeatherSliderDisplay();
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
if (isFeatherDragging) {
|
||||
const expansionValue = this.canvas.shapeMaskExpansion ? this.canvas.shapeMaskExpansionValue : 0;
|
||||
this.canvas.maskTool.showShapePreview(expansionValue, this.canvas.shapeMaskFeatherValue);
|
||||
}
|
||||
else {
|
||||
this.canvas.maskTool.hideShapePreview();
|
||||
this.canvas.maskTool.applyShapeMask(false);
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
};
|
||||
featherSlider.onmouseup = () => {
|
||||
isFeatherDragging = false;
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.hideShapePreview();
|
||||
this.canvas.maskTool.applyShapeMask(true); // true = save state
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
updateFeatherSliderDisplay();
|
||||
featherSliderContainer.appendChild(featherSliderLabel);
|
||||
featherSliderContainer.appendChild(featherSlider);
|
||||
featherSliderContainer.appendChild(featherValueDisplay);
|
||||
featureContainer.appendChild(featherSliderContainer);
|
||||
fullMenu.appendChild(featureContainer);
|
||||
// Create output area extension container
|
||||
const extensionContainer = document.createElement('div');
|
||||
extensionContainer.id = 'output-area-extension-container';
|
||||
extensionContainer.className = 'feature-container';
|
||||
// Add main extension checkbox
|
||||
const extensionCheckboxContainer = this._createCheckbox('extension-checkbox', () => this.canvas.outputAreaExtensionEnabled, 'Extend output area', (e) => {
|
||||
this.canvas.outputAreaExtensionEnabled = e.target.checked;
|
||||
if (this.canvas.outputAreaExtensionEnabled) {
|
||||
this.canvas.originalCanvasSize = { width: this.canvas.width, height: this.canvas.height };
|
||||
this.canvas.outputAreaExtensions = { ...this.canvas.lastOutputAreaExtensions };
|
||||
}
|
||||
else {
|
||||
this.canvas.lastOutputAreaExtensions = { ...this.canvas.outputAreaExtensions };
|
||||
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
}
|
||||
this._updateExtensionUI();
|
||||
this._updateCanvasSize();
|
||||
this.canvas.render();
|
||||
}, "Allows extending the output area boundaries in all directions without changing the custom shape.");
|
||||
extensionContainer.appendChild(extensionCheckboxContainer);
|
||||
// Create sliders container
|
||||
const slidersContainer = document.createElement('div');
|
||||
slidersContainer.id = 'extension-sliders-container';
|
||||
slidersContainer.className = 'slider-container';
|
||||
// Helper function to create a slider with preview system
|
||||
const createExtensionSlider = (label, direction) => {
|
||||
const sliderContainer = document.createElement('div');
|
||||
sliderContainer.className = 'extension-slider-container';
|
||||
const sliderLabel = document.createElement('div');
|
||||
sliderLabel.textContent = label;
|
||||
sliderLabel.className = 'slider-label';
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.min = '0';
|
||||
slider.max = '500';
|
||||
slider.value = String(this.canvas.outputAreaExtensions[direction]);
|
||||
const valueDisplay = document.createElement('div');
|
||||
valueDisplay.className = 'slider-value-display';
|
||||
const updateDisplay = () => {
|
||||
const value = parseInt(slider.value);
|
||||
valueDisplay.textContent = `${value}px`;
|
||||
};
|
||||
let isDragging = false;
|
||||
slider.onmousedown = () => {
|
||||
isDragging = true;
|
||||
};
|
||||
slider.oninput = () => {
|
||||
updateDisplay();
|
||||
if (isDragging) {
|
||||
// During dragging, show preview
|
||||
const previewExtensions = { ...this.canvas.outputAreaExtensions };
|
||||
previewExtensions[direction] = parseInt(slider.value);
|
||||
this.canvas.outputAreaExtensionPreview = previewExtensions;
|
||||
this.canvas.render();
|
||||
}
|
||||
else {
|
||||
// Not dragging, apply immediately (for keyboard navigation)
|
||||
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
|
||||
this._updateCanvasSize();
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
slider.onmouseup = () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
// Apply the final value and clear preview
|
||||
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
|
||||
this.canvas.outputAreaExtensionPreview = null;
|
||||
this._updateCanvasSize();
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
// Handle mouse leave (in case user drags outside)
|
||||
slider.onmouseleave = () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
// Apply the final value and clear preview
|
||||
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
|
||||
this.canvas.outputAreaExtensionPreview = null;
|
||||
this._updateCanvasSize();
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
updateDisplay();
|
||||
sliderContainer.appendChild(sliderLabel);
|
||||
sliderContainer.appendChild(slider);
|
||||
sliderContainer.appendChild(valueDisplay);
|
||||
return sliderContainer;
|
||||
};
|
||||
// Add all four sliders
|
||||
slidersContainer.appendChild(createExtensionSlider('Top extension:', 'top'));
|
||||
slidersContainer.appendChild(createExtensionSlider('Bottom extension:', 'bottom'));
|
||||
slidersContainer.appendChild(createExtensionSlider('Left extension:', 'left'));
|
||||
slidersContainer.appendChild(createExtensionSlider('Right extension:', 'right'));
|
||||
extensionContainer.appendChild(slidersContainer);
|
||||
fullMenu.appendChild(extensionContainer);
|
||||
this.element.appendChild(fullMenu);
|
||||
// Add to DOM
|
||||
if (this.canvas.canvas.parentElement) {
|
||||
this.canvas.canvas.parentElement.appendChild(this.element);
|
||||
}
|
||||
else {
|
||||
log.error("Could not find parent node to attach custom shape menu.");
|
||||
}
|
||||
this.uiInitialized = true;
|
||||
this._updateUI();
|
||||
this._updateMinimizedState();
|
||||
// Add viewport change listener to update shape preview when zooming/panning
|
||||
this._addViewportChangeListener();
|
||||
}
|
||||
_createCheckbox(id, getChecked, text, clickHandler, tooltipText) {
|
||||
const container = document.createElement('label');
|
||||
container.className = 'checkbox-container';
|
||||
container.htmlFor = id;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.id = id;
|
||||
input.checked = getChecked();
|
||||
const customCheckbox = document.createElement('div');
|
||||
customCheckbox.className = 'custom-checkbox';
|
||||
const labelText = document.createElement('span');
|
||||
labelText.textContent = text;
|
||||
container.appendChild(input);
|
||||
container.appendChild(customCheckbox);
|
||||
container.appendChild(labelText);
|
||||
// Stop propagation to prevent menu from closing, but allow default checkbox behavior
|
||||
container.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
input.onchange = (e) => {
|
||||
clickHandler(e);
|
||||
};
|
||||
if (tooltipText) {
|
||||
this._addTooltip(container, tooltipText);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
_updateUI() {
|
||||
if (!this.element)
|
||||
return;
|
||||
// Always update only the full menu part
|
||||
const fullMenu = this.element.querySelector('.custom-shape-full-menu');
|
||||
if (!fullMenu)
|
||||
return;
|
||||
const setChecked = (id, checked) => {
|
||||
const input = fullMenu.querySelector(`#${id}`);
|
||||
if (input)
|
||||
input.checked = checked;
|
||||
};
|
||||
setChecked('auto-apply-checkbox', this.canvas.autoApplyShapeMask);
|
||||
setChecked('expansion-checkbox', this.canvas.shapeMaskExpansion);
|
||||
setChecked('feather-checkbox', this.canvas.shapeMaskFeather);
|
||||
setChecked('extension-checkbox', this.canvas.outputAreaExtensionEnabled);
|
||||
const expansionCheckbox = fullMenu.querySelector('#expansion-checkbox')?.parentElement;
|
||||
if (expansionCheckbox) {
|
||||
expansionCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'flex' : 'none';
|
||||
}
|
||||
const featherCheckbox = fullMenu.querySelector('#feather-checkbox')?.parentElement;
|
||||
if (featherCheckbox) {
|
||||
featherCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'flex' : 'none';
|
||||
}
|
||||
const expansionSliderContainer = fullMenu.querySelector('#expansion-slider-container');
|
||||
if (expansionSliderContainer) {
|
||||
expansionSliderContainer.style.display = (this.canvas.autoApplyShapeMask && this.canvas.shapeMaskExpansion) ? 'block' : 'none';
|
||||
}
|
||||
const featherSliderContainer = fullMenu.querySelector('#feather-slider-container');
|
||||
if (featherSliderContainer) {
|
||||
featherSliderContainer.style.display = (this.canvas.autoApplyShapeMask && this.canvas.shapeMaskFeather) ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
_updateMinimizedState() {
|
||||
if (!this.element)
|
||||
return;
|
||||
const minimizedBar = this.element.querySelector('.custom-shape-minimized-bar');
|
||||
const fullMenu = this.element.querySelector('.custom-shape-full-menu');
|
||||
if (this.isMinimized) {
|
||||
minimizedBar.style.display = 'block';
|
||||
fullMenu.style.display = 'none';
|
||||
}
|
||||
else {
|
||||
minimizedBar.style.display = 'none';
|
||||
fullMenu.style.display = 'block';
|
||||
}
|
||||
}
|
||||
_updateExtensionUI() {
|
||||
if (!this.element)
|
||||
return;
|
||||
// Toggle visibility of extension sliders based on the extension checkbox state
|
||||
const extensionSlidersContainer = this.element.querySelector('#extension-sliders-container');
|
||||
if (extensionSlidersContainer) {
|
||||
extensionSlidersContainer.style.display = this.canvas.outputAreaExtensionEnabled ? 'block' : 'none';
|
||||
}
|
||||
// Update slider values if they exist
|
||||
if (this.canvas.outputAreaExtensionEnabled) {
|
||||
const sliders = extensionSlidersContainer?.querySelectorAll('input[type="range"]');
|
||||
const directions = ['top', 'bottom', 'left', 'right'];
|
||||
sliders?.forEach((slider, index) => {
|
||||
const direction = directions[index];
|
||||
if (direction) {
|
||||
slider.value = String(this.canvas.outputAreaExtensions[direction]);
|
||||
// Update the corresponding value display
|
||||
const valueDisplay = slider.parentElement?.querySelector('div:last-child');
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = `${this.canvas.outputAreaExtensions[direction]}px`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Add viewport change listener to update shape preview when zooming/panning
|
||||
*/
|
||||
_addViewportChangeListener() {
|
||||
// Store previous viewport state to detect changes
|
||||
let previousViewport = {
|
||||
x: this.canvas.viewport.x,
|
||||
y: this.canvas.viewport.y,
|
||||
zoom: this.canvas.viewport.zoom
|
||||
};
|
||||
// Check for viewport changes in render loop
|
||||
const checkViewportChange = () => {
|
||||
if (this.canvas.maskTool.shapePreviewVisible) {
|
||||
const current = this.canvas.viewport;
|
||||
// Check if viewport has changed
|
||||
if (current.x !== previousViewport.x ||
|
||||
current.y !== previousViewport.y ||
|
||||
current.zoom !== previousViewport.zoom) {
|
||||
// Update shape preview with current expansion/feather values
|
||||
const expansionValue = this.canvas.shapeMaskExpansionValue || 0;
|
||||
const featherValue = this.canvas.shapeMaskFeather ? (this.canvas.shapeMaskFeatherValue || 0) : 0;
|
||||
this.canvas.maskTool.showShapePreview(expansionValue, featherValue);
|
||||
// Update previous viewport state
|
||||
previousViewport = {
|
||||
x: current.x,
|
||||
y: current.y,
|
||||
zoom: current.zoom
|
||||
};
|
||||
}
|
||||
}
|
||||
// Continue checking if UI is still active
|
||||
if (this.uiInitialized) {
|
||||
requestAnimationFrame(checkViewportChange);
|
||||
}
|
||||
};
|
||||
// Start the viewport change detection
|
||||
requestAnimationFrame(checkViewportChange);
|
||||
}
|
||||
_addTooltip(element, text) {
|
||||
element.addEventListener('mouseenter', (e) => {
|
||||
this.showTooltip(text, e);
|
||||
});
|
||||
element.addEventListener('mouseleave', () => {
|
||||
this.hideTooltip();
|
||||
});
|
||||
element.addEventListener('mousemove', (e) => {
|
||||
if (this.tooltip && this.tooltip.style.display === 'block') {
|
||||
this.updateTooltipPosition(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
showTooltip(text, event) {
|
||||
this.hideTooltip(); // Hide any existing tooltip
|
||||
this.tooltip = document.createElement('div');
|
||||
this.tooltip.textContent = text;
|
||||
this.tooltip.className = 'layerforge-tooltip';
|
||||
document.body.appendChild(this.tooltip);
|
||||
this.updateTooltipPosition(event);
|
||||
// Fade in the tooltip
|
||||
requestAnimationFrame(() => {
|
||||
if (this.tooltip) {
|
||||
this.tooltip.style.opacity = '1';
|
||||
}
|
||||
});
|
||||
}
|
||||
updateTooltipPosition(event) {
|
||||
if (!this.tooltip)
|
||||
return;
|
||||
const tooltipRect = this.tooltip.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
let x = event.clientX + 10;
|
||||
let y = event.clientY - 10;
|
||||
// Adjust if tooltip would go off the right edge
|
||||
if (x + tooltipRect.width > viewportWidth) {
|
||||
x = event.clientX - tooltipRect.width - 10;
|
||||
}
|
||||
// Adjust if tooltip would go off the bottom edge
|
||||
if (y + tooltipRect.height > viewportHeight) {
|
||||
y = event.clientY - tooltipRect.height - 10;
|
||||
}
|
||||
// Ensure tooltip doesn't go off the left or top edges
|
||||
x = Math.max(5, x);
|
||||
y = Math.max(5, y);
|
||||
this.tooltip.style.left = `${x}px`;
|
||||
this.tooltip.style.top = `${y}px`;
|
||||
}
|
||||
hideTooltip() {
|
||||
if (this.tooltip) {
|
||||
this.tooltip.remove();
|
||||
this.tooltip = null;
|
||||
}
|
||||
}
|
||||
_updateCanvasSize() {
|
||||
if (!this.canvas.outputAreaExtensionEnabled) {
|
||||
// When extensions are disabled, return to original custom shape position
|
||||
// Use originalOutputAreaPosition instead of current bounds position
|
||||
const originalPos = this.canvas.originalOutputAreaPosition;
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: originalPos.x, // ✅ Return to original custom shape position
|
||||
y: originalPos.y, // ✅ Return to original custom shape position
|
||||
width: this.canvas.originalCanvasSize.width,
|
||||
height: this.canvas.originalCanvasSize.height
|
||||
};
|
||||
this.canvas.updateOutputAreaSize(this.canvas.originalCanvasSize.width, this.canvas.originalCanvasSize.height, false);
|
||||
return;
|
||||
}
|
||||
const ext = this.canvas.outputAreaExtensions;
|
||||
const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right;
|
||||
const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom;
|
||||
// When extensions are enabled, calculate new bounds relative to original custom shape position
|
||||
const originalPos = this.canvas.originalOutputAreaPosition;
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: originalPos.x - ext.left, // Adjust position by left extension from original position
|
||||
y: originalPos.y - ext.top, // Adjust position by top extension from original position
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
// Zmień rozmiar canvas (fizyczny rozmiar dla renderowania)
|
||||
this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
|
||||
log.info(`Output area bounds updated: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${newWidth}, h=${newHeight}`);
|
||||
log.info(`Extensions: top=${ext.top}, bottom=${ext.bottom}, left=${ext.left}, right=${ext.right}`);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,16 @@
|
||||
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 { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||
import { uploadImageBlob } from "./utils/ImageUploadUtils.js";
|
||||
import { processImageToMask, processMaskForViewport } from "./utils/MaskProcessingUtils.js";
|
||||
import { convertToImage } from "./utils/ImageUtils.js";
|
||||
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
||||
import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js";
|
||||
const log = createModuleLogger('CanvasMask');
|
||||
export class CanvasMask {
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
const log = createModuleLogger('MaskEditorIntegration');
|
||||
export class MaskEditorIntegration {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.node = canvas.node;
|
||||
@@ -48,7 +52,7 @@ export class CanvasMask {
|
||||
}
|
||||
else {
|
||||
log.debug('Getting flattened canvas for mask editor (with mask)');
|
||||
blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor();
|
||||
blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
}
|
||||
if (!blob) {
|
||||
log.warn("Canvas is empty, cannot open mask editor.");
|
||||
@@ -56,28 +60,11 @@ export class CanvasMask {
|
||||
}
|
||||
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,
|
||||
// Use ImageUploadUtils to upload the blob
|
||||
const uploadResult = await uploadImageBlob(blob, {
|
||||
filenamePrefix: 'layerforge-mask-edit'
|
||||
});
|
||||
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];
|
||||
this.node.imgs = [uploadResult.imageElement];
|
||||
log.info('Opening ComfyUI mask editor');
|
||||
ComfyApp.copyToClipspace(this.node);
|
||||
ComfyApp.clipspace_return_node = this.node;
|
||||
@@ -92,7 +79,53 @@ export class CanvasMask {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error preparing image for mask editor:", error);
|
||||
alert(`Error: ${error.message}`);
|
||||
showErrorNotification(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Oblicza dynamiczny czas oczekiwania na podstawie rozmiaru obrazu
|
||||
* @returns {number} Czas oczekiwania w milisekundach
|
||||
*/
|
||||
calculateDynamicWaitTime() {
|
||||
try {
|
||||
// Get canvas dimensions from output area bounds
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const width = bounds.width;
|
||||
const height = bounds.height;
|
||||
// Calculate total pixels
|
||||
const totalPixels = width * height;
|
||||
// Define wait time based on image size
|
||||
let waitTime = 500; // Base wait time for small images
|
||||
if (totalPixels <= 1000 * 1000) {
|
||||
// Below 1MP (1000x1000) - 500ms
|
||||
waitTime = 500;
|
||||
}
|
||||
else if (totalPixels <= 2000 * 2000) {
|
||||
// 1MP to 4MP (2000x2000) - 1000ms
|
||||
waitTime = 1000;
|
||||
}
|
||||
else if (totalPixels <= 4000 * 4000) {
|
||||
// 4MP to 16MP (4000x4000) - 2000ms
|
||||
waitTime = 2000;
|
||||
}
|
||||
else if (totalPixels <= 6000 * 6000) {
|
||||
// 16MP to 36MP (6000x6000) - 4000ms
|
||||
waitTime = 4000;
|
||||
}
|
||||
else {
|
||||
// Above 36MP - 6000ms
|
||||
waitTime = 6000;
|
||||
}
|
||||
log.debug("Calculated dynamic wait time", {
|
||||
imageSize: `${width}x${height}`,
|
||||
totalPixels: totalPixels,
|
||||
waitTime: waitTime
|
||||
});
|
||||
return waitTime;
|
||||
}
|
||||
catch (error) {
|
||||
log.warn("Error calculating dynamic wait time, using default 1000ms", error);
|
||||
return 1000; // Fallback to 1 second
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -141,11 +174,13 @@ export class CanvasMask {
|
||||
}
|
||||
}
|
||||
if (editorReady) {
|
||||
log.info("Applying mask to editor after", attempts * 100, "ms wait");
|
||||
// Calculate dynamic wait time based on image size
|
||||
const waitTime = this.calculateDynamicWaitTime();
|
||||
log.info("Applying mask to editor after", waitTime, "ms wait (dynamic based on image size)");
|
||||
setTimeout(() => {
|
||||
this.applyMaskToEditor(this.pendingMask);
|
||||
this.pendingMask = null;
|
||||
}, 300);
|
||||
}, waitTime);
|
||||
}
|
||||
else if (attempts < maxAttempts) {
|
||||
if (attempts % 10 === 0) {
|
||||
@@ -250,53 +285,16 @@ export class CanvasMask {
|
||||
* @param {number} targetHeight - Docelowa wysokość
|
||||
* @param {Object} maskColor - Kolor maski {r, g, b}
|
||||
* @returns {HTMLCanvasElement} Przetworzona maska
|
||||
*/ async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) {
|
||||
// Współrzędne przesunięcia (pan) widoku edytora
|
||||
const panX = this.maskTool.x;
|
||||
const panY = this.maskTool.y;
|
||||
log.info("Processing mask for editor:", {
|
||||
sourceSize: { width: maskData.width, height: maskData.height },
|
||||
targetSize: { width: targetWidth, height: targetHeight },
|
||||
viewportPan: { x: panX, y: panY }
|
||||
});
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = targetWidth;
|
||||
tempCanvas.height = targetHeight;
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
const sourceX = -panX;
|
||||
const sourceY = -panY;
|
||||
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;
|
||||
*/
|
||||
async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) {
|
||||
// Pozycja maski w świecie względem output bounds
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const maskWorldX = this.maskTool.x;
|
||||
const maskWorldY = this.maskTool.y;
|
||||
const panX = maskWorldX - bounds.x;
|
||||
const panY = maskWorldY - bounds.y;
|
||||
// Use MaskProcessingUtils for viewport processing
|
||||
return await processMaskForViewport(maskData, targetWidth, targetHeight, { x: panX, y: panY }, maskColor);
|
||||
}
|
||||
/**
|
||||
* Tworzy obiekt Image z obecnej maski canvas
|
||||
@@ -334,10 +332,7 @@ export class CanvasMask {
|
||||
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 });
|
||||
const { canvas: savedCanvas, ctx: savedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', { willReadFrequently: true });
|
||||
if (savedCtx) {
|
||||
savedCtx.drawImage(maskCanvas, 0, 0);
|
||||
}
|
||||
@@ -415,52 +410,24 @@ export class CanvasMask {
|
||||
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();
|
||||
// Process image to mask using MaskProcessingUtils
|
||||
log.debug("Processing image to mask using utils");
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const processedMask = await processImageToMask(resultImage, {
|
||||
targetWidth: bounds.width,
|
||||
targetHeight: bounds.height,
|
||||
invertAlpha: true
|
||||
});
|
||||
// Convert processed mask to image
|
||||
const maskAsImage = await convertToImage(processedMask);
|
||||
log.debug("Applying mask using chunk system", {
|
||||
boundsPos: { x: bounds.x, y: bounds.y },
|
||||
maskSize: { width: bounds.width, height: bounds.height }
|
||||
});
|
||||
// Use the chunk system instead of direct canvas manipulation
|
||||
this.maskTool.setMask(maskAsImage);
|
||||
// Update node preview using PreviewUtils
|
||||
await updateNodePreview(this.canvas, this.node, true);
|
||||
this.savedMaskState = null;
|
||||
log.info("Mask editor result processed successfully");
|
||||
}
|
||||
1629
js/MaskTool.js
1629
js/MaskTool.js
File diff suppressed because it is too large
Load Diff
385
js/SAMDetectorIntegration.js
Normal file
385
js/SAMDetectorIntegration.js
Normal file
@@ -0,0 +1,385 @@
|
||||
// @ts-ignore
|
||||
import { ComfyApp } from "../../scripts/app.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { showInfoNotification, showSuccessNotification, showErrorNotification } from "./utils/NotificationUtils.js";
|
||||
import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.js";
|
||||
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
|
||||
import { convertToImage } from "./utils/ImageUtils.js";
|
||||
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
||||
const log = createModuleLogger('SAMDetectorIntegration');
|
||||
/**
|
||||
* SAM Detector Integration for LayerForge
|
||||
* Handles automatic clipspace integration and mask application from Impact Pack's SAM Detector
|
||||
*/
|
||||
// Function to register image in clipspace for Impact Pack compatibility
|
||||
export const registerImageInClipspace = async (node, blob) => {
|
||||
try {
|
||||
// Use ImageUploadUtils to upload the blob
|
||||
const uploadResult = await uploadImageBlob(blob, {
|
||||
filenamePrefix: 'layerforge-sam',
|
||||
nodeId: node.id
|
||||
});
|
||||
log.debug(`Image registered in clipspace for node ${node.id}: ${uploadResult.filename}`);
|
||||
return uploadResult.imageElement;
|
||||
}
|
||||
catch (error) {
|
||||
log.debug("Failed to register image in clipspace:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
// Function to monitor for SAM Detector modal closure and apply masks to LayerForge
|
||||
export function startSAMDetectorMonitoring(node) {
|
||||
if (node.samMonitoringActive) {
|
||||
log.debug("SAM Detector monitoring already active for node", node.id);
|
||||
return;
|
||||
}
|
||||
node.samMonitoringActive = true;
|
||||
log.info("Starting SAM Detector modal monitoring for node", node.id);
|
||||
// Store original image source for comparison
|
||||
const originalImgSrc = node.imgs?.[0]?.src;
|
||||
node.samOriginalImgSrc = originalImgSrc;
|
||||
// Start monitoring for SAM Detector modal closure
|
||||
monitorSAMDetectorModal(node);
|
||||
}
|
||||
// Function to monitor SAM Detector modal closure
|
||||
function monitorSAMDetectorModal(node) {
|
||||
log.info("Starting SAM Detector modal monitoring for node", node.id);
|
||||
// Try to find modal multiple times with increasing delays
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10; // Try for 5 seconds total
|
||||
const findModal = () => {
|
||||
attempts++;
|
||||
log.debug(`Looking for SAM Detector modal, attempt ${attempts}/${maxAttempts}`);
|
||||
// Look for SAM Detector specific elements instead of generic modal
|
||||
const samCanvas = document.querySelector('#samEditorMaskCanvas');
|
||||
const pointsCanvas = document.querySelector('#pointsCanvas');
|
||||
const imageCanvas = document.querySelector('#imageCanvas');
|
||||
// Debug: Log SAM specific elements
|
||||
log.debug(`SAM specific elements found:`, {
|
||||
samCanvas: !!samCanvas,
|
||||
pointsCanvas: !!pointsCanvas,
|
||||
imageCanvas: !!imageCanvas
|
||||
});
|
||||
// Find the modal that contains SAM Detector elements
|
||||
let modal = null;
|
||||
if (samCanvas || pointsCanvas || imageCanvas) {
|
||||
// Find the parent modal of SAM elements
|
||||
const samElement = samCanvas || pointsCanvas || imageCanvas;
|
||||
let parent = samElement?.parentElement;
|
||||
while (parent && !parent.classList.contains('comfy-modal')) {
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
modal = parent;
|
||||
}
|
||||
if (!modal) {
|
||||
if (attempts < maxAttempts) {
|
||||
log.debug(`SAM Detector modal not found on attempt ${attempts}, retrying in 500ms...`);
|
||||
setTimeout(findModal, 500);
|
||||
return;
|
||||
}
|
||||
else {
|
||||
log.warn("SAM Detector modal not found after all attempts, falling back to polling");
|
||||
// Fallback to old polling method if modal not found
|
||||
monitorSAMDetectorChanges(node);
|
||||
return;
|
||||
}
|
||||
}
|
||||
log.info("Found SAM Detector modal, setting up observers", {
|
||||
className: modal.className,
|
||||
id: modal.id,
|
||||
display: window.getComputedStyle(modal).display,
|
||||
children: modal.children.length,
|
||||
hasSamCanvas: !!modal.querySelector('#samEditorMaskCanvas'),
|
||||
hasPointsCanvas: !!modal.querySelector('#pointsCanvas'),
|
||||
hasImageCanvas: !!modal.querySelector('#imageCanvas')
|
||||
});
|
||||
// Create a MutationObserver to watch for modal removal or style changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
// Check if the modal was removed from DOM
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.removedNodes.forEach((removedNode) => {
|
||||
if (removedNode === modal || removedNode?.contains?.(modal)) {
|
||||
log.info("SAM Detector modal removed from DOM");
|
||||
handleSAMDetectorModalClosed(node);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Check if modal style changed to hidden
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
|
||||
const target = mutation.target;
|
||||
if (target === modal) {
|
||||
const display = window.getComputedStyle(modal).display;
|
||||
if (display === 'none') {
|
||||
log.info("SAM Detector modal hidden via style");
|
||||
// Add delay to allow SAM Detector to process and save the mask
|
||||
setTimeout(() => {
|
||||
handleSAMDetectorModalClosed(node);
|
||||
}, 1000); // 1 second delay
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
// Observe the document body for child removals (modal removal)
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style']
|
||||
});
|
||||
// Also observe the modal itself for style changes
|
||||
observer.observe(modal, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style']
|
||||
});
|
||||
// Store observer reference for cleanup
|
||||
node.samModalObserver = observer;
|
||||
// Fallback timeout in case observer doesn't catch the closure
|
||||
setTimeout(() => {
|
||||
if (node.samMonitoringActive) {
|
||||
log.debug("SAM Detector modal monitoring timeout, cleaning up");
|
||||
observer.disconnect();
|
||||
node.samMonitoringActive = false;
|
||||
}
|
||||
}, 60000); // 1 minute timeout
|
||||
log.info("SAM Detector modal observers set up successfully");
|
||||
};
|
||||
// Start the modal finding process
|
||||
findModal();
|
||||
}
|
||||
// Function to handle SAM Detector modal closure
|
||||
function handleSAMDetectorModalClosed(node) {
|
||||
if (!node.samMonitoringActive) {
|
||||
log.debug("SAM monitoring already inactive for node", node.id);
|
||||
return;
|
||||
}
|
||||
log.info("SAM Detector modal closed for node", node.id);
|
||||
node.samMonitoringActive = false;
|
||||
// Clean up observer
|
||||
if (node.samModalObserver) {
|
||||
node.samModalObserver.disconnect();
|
||||
delete node.samModalObserver;
|
||||
}
|
||||
// Check if there's a new image to process
|
||||
if (node.imgs && node.imgs.length > 0) {
|
||||
const currentImgSrc = node.imgs[0].src;
|
||||
const originalImgSrc = node.samOriginalImgSrc;
|
||||
if (currentImgSrc && currentImgSrc !== originalImgSrc) {
|
||||
log.info("SAM Detector result detected after modal closure, processing mask...");
|
||||
handleSAMDetectorResult(node, node.imgs[0]);
|
||||
}
|
||||
else {
|
||||
log.info("No new image detected after SAM Detector modal closure");
|
||||
// Show info notification
|
||||
showInfoNotification("SAM Detector closed. No mask was applied.");
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.info("No image available after SAM Detector modal closure");
|
||||
}
|
||||
// Clean up stored references
|
||||
delete node.samOriginalImgSrc;
|
||||
}
|
||||
// Fallback function to monitor changes in node.imgs (old polling approach)
|
||||
function monitorSAMDetectorChanges(node) {
|
||||
let checkCount = 0;
|
||||
const maxChecks = 300; // 30 seconds maximum monitoring
|
||||
const checkForChanges = () => {
|
||||
checkCount++;
|
||||
if (!(node.samMonitoringActive)) {
|
||||
log.debug("SAM monitoring stopped for node", node.id);
|
||||
return;
|
||||
}
|
||||
log.debug(`SAM monitoring check ${checkCount}/${maxChecks} for node ${node.id}`);
|
||||
// Check if the node's image has been updated (this happens when "Save to node" is clicked)
|
||||
if (node.imgs && node.imgs.length > 0) {
|
||||
const currentImgSrc = node.imgs[0].src;
|
||||
const originalImgSrc = node.samOriginalImgSrc;
|
||||
if (currentImgSrc && currentImgSrc !== originalImgSrc) {
|
||||
log.info("SAM Detector result detected in node.imgs, processing mask...");
|
||||
handleSAMDetectorResult(node, node.imgs[0]);
|
||||
node.samMonitoringActive = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Continue monitoring if not exceeded max checks
|
||||
if (checkCount < maxChecks && node.samMonitoringActive) {
|
||||
setTimeout(checkForChanges, 100);
|
||||
}
|
||||
else {
|
||||
log.debug("SAM Detector monitoring timeout or stopped for node", node.id);
|
||||
node.samMonitoringActive = false;
|
||||
}
|
||||
};
|
||||
// Start monitoring after a short delay
|
||||
setTimeout(checkForChanges, 500);
|
||||
}
|
||||
// Function to handle SAM Detector result (using same logic as MaskEditorIntegration.handleMaskEditorClose)
|
||||
async function handleSAMDetectorResult(node, resultImage) {
|
||||
try {
|
||||
log.info("Handling SAM Detector result for node", node.id);
|
||||
log.debug("Result image source:", resultImage.src.substring(0, 100) + '...');
|
||||
const canvasWidget = node.canvasWidget;
|
||||
if (!canvasWidget || !canvasWidget.canvas) {
|
||||
log.error("Canvas widget not available for SAM result processing");
|
||||
return;
|
||||
}
|
||||
const canvas = canvasWidget; // canvasWidget is the Canvas object, not canvasWidget.canvas
|
||||
// Wait for the result image to load (same as MaskEditorIntegration)
|
||||
try {
|
||||
// First check if the image is already loaded
|
||||
if (resultImage.complete && resultImage.naturalWidth > 0) {
|
||||
log.debug("SAM result image already loaded", {
|
||||
width: resultImage.width,
|
||||
height: resultImage.height
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Try to reload the image with a fresh request
|
||||
log.debug("Attempting to reload SAM result image");
|
||||
const originalSrc = resultImage.src;
|
||||
// Add cache-busting parameter to force fresh load
|
||||
const url = new URL(originalSrc);
|
||||
url.searchParams.set('_t', Date.now().toString());
|
||||
await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
// Copy the loaded image data to the original image
|
||||
resultImage.src = img.src;
|
||||
resultImage.width = img.width;
|
||||
resultImage.height = img.height;
|
||||
log.debug("SAM result image reloaded successfully", {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
originalSrc: originalSrc,
|
||||
newSrc: img.src
|
||||
});
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = (error) => {
|
||||
log.error("Failed to reload SAM result image", {
|
||||
originalSrc: originalSrc,
|
||||
newSrc: url.toString(),
|
||||
error: error
|
||||
});
|
||||
reject(error);
|
||||
};
|
||||
img.src = url.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Failed to load image from SAM Detector.", error);
|
||||
showErrorNotification("Failed to load SAM Detector result. The mask file may not be available.");
|
||||
return;
|
||||
}
|
||||
// Process image to mask using MaskProcessingUtils
|
||||
log.debug("Processing image to mask using utils");
|
||||
const processedMask = await processImageToMask(resultImage, {
|
||||
targetWidth: resultImage.width,
|
||||
targetHeight: resultImage.height,
|
||||
invertAlpha: true
|
||||
});
|
||||
// Convert processed mask to image
|
||||
const maskAsImage = await convertToImage(processedMask);
|
||||
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
||||
log.debug("Checking canvas and maskTool availability", {
|
||||
hasCanvas: !!canvas,
|
||||
hasMaskTool: !!canvas.maskTool,
|
||||
maskToolType: typeof canvas.maskTool,
|
||||
canvasKeys: Object.keys(canvas)
|
||||
});
|
||||
if (!canvas.maskTool) {
|
||||
log.error("MaskTool is not available. Canvas state:", {
|
||||
hasCanvas: !!canvas,
|
||||
canvasConstructor: canvas.constructor.name,
|
||||
canvasKeys: Object.keys(canvas),
|
||||
maskToolValue: canvas.maskTool
|
||||
});
|
||||
throw new Error("Mask tool not available or not initialized");
|
||||
}
|
||||
log.debug("Applying SAM mask to canvas using addMask method");
|
||||
// Use the addMask method which overlays on existing mask without clearing it
|
||||
canvas.maskTool.addMask(maskAsImage);
|
||||
// Update canvas and save state (same as MaskEditorIntegration)
|
||||
canvas.render();
|
||||
canvas.saveState();
|
||||
// Update node preview using PreviewUtils
|
||||
await updateNodePreview(canvas, node, true);
|
||||
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
||||
// Show success notification
|
||||
showSuccessNotification("SAM Detector mask applied to LayerForge!");
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error processing SAM Detector result:", error);
|
||||
// Show error notification
|
||||
showErrorNotification(`Failed to apply SAM mask: ${error.message}`);
|
||||
}
|
||||
finally {
|
||||
node.samMonitoringActive = false;
|
||||
node.samOriginalImgSrc = null;
|
||||
}
|
||||
}
|
||||
// Function to setup SAM Detector hook in menu options
|
||||
export function setupSAMDetectorHook(node, options) {
|
||||
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
||||
const hookSAMDetector = () => {
|
||||
const samDetectorIndex = options.findIndex((option) => option && option.content && (option.content.includes("SAM Detector") ||
|
||||
option.content === "Open in SAM Detector"));
|
||||
if (samDetectorIndex !== -1) {
|
||||
log.info(`Found SAM Detector menu item at index ${samDetectorIndex}: "${options[samDetectorIndex].content}"`);
|
||||
const originalSamCallback = options[samDetectorIndex].callback;
|
||||
options[samDetectorIndex].callback = async () => {
|
||||
try {
|
||||
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
||||
// Automatically send canvas to clipspace and start monitoring
|
||||
if (node.canvasWidget && node.canvasWidget.canvas) {
|
||||
const canvas = node.canvasWidget; // canvasWidget IS the Canvas object
|
||||
// Use ImageUploadUtils to upload canvas
|
||||
const uploadResult = await uploadCanvasAsImage(canvas, {
|
||||
filenamePrefix: 'layerforge-sam',
|
||||
nodeId: node.id
|
||||
});
|
||||
// Set the image to the node for clipspace
|
||||
node.imgs = [uploadResult.imageElement];
|
||||
node.clipspaceImg = uploadResult.imageElement;
|
||||
// Copy to ComfyUI clipspace
|
||||
ComfyApp.copyToClipspace(node);
|
||||
// Start monitoring for SAM Detector results
|
||||
startSAMDetectorMonitoring(node);
|
||||
log.info("Canvas automatically sent to clipspace and monitoring started");
|
||||
}
|
||||
// Call the original SAM Detector callback
|
||||
if (originalSamCallback) {
|
||||
await originalSamCallback();
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
log.error("Error in SAM Detector hook:", e);
|
||||
// Still try to call original callback
|
||||
if (originalSamCallback) {
|
||||
await originalSamCallback();
|
||||
}
|
||||
}
|
||||
};
|
||||
return true; // Found and hooked
|
||||
}
|
||||
return false; // Not found
|
||||
};
|
||||
// Try to hook immediately
|
||||
if (!hookSAMDetector()) {
|
||||
// If not found immediately, try again after Impact Pack adds it
|
||||
setTimeout(() => {
|
||||
if (hookSAMDetector()) {
|
||||
log.info("Successfully hooked SAM Detector after delay");
|
||||
}
|
||||
else {
|
||||
log.debug("SAM Detector menu item not found even after delay");
|
||||
}
|
||||
}, 150); // Slightly longer delay to ensure Impact Pack has added it
|
||||
}
|
||||
}
|
||||
142
js/ShapeTool.js
Normal file
142
js/ShapeTool.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('ShapeTool');
|
||||
export class ShapeTool {
|
||||
constructor(canvas) {
|
||||
this.isActive = false;
|
||||
this.canvas = canvas;
|
||||
this.shape = {
|
||||
points: [],
|
||||
isClosed: false,
|
||||
};
|
||||
}
|
||||
toggle() {
|
||||
this.isActive = !this.isActive;
|
||||
if (this.isActive) {
|
||||
log.info('ShapeTool activated. Press "S" to exit.');
|
||||
this.reset();
|
||||
}
|
||||
else {
|
||||
log.info('ShapeTool deactivated.');
|
||||
this.reset();
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
activate() {
|
||||
if (!this.isActive) {
|
||||
this.isActive = true;
|
||||
log.info('ShapeTool activated. Hold Shift+S to draw.');
|
||||
this.reset();
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
deactivate() {
|
||||
if (this.isActive) {
|
||||
this.isActive = false;
|
||||
log.info('ShapeTool deactivated.');
|
||||
this.reset();
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
addPoint(point) {
|
||||
if (this.shape.isClosed) {
|
||||
this.reset();
|
||||
}
|
||||
// Check if the new point is close to the start point to close the shape
|
||||
if (this.shape.points.length > 2) {
|
||||
const firstPoint = this.shape.points[0];
|
||||
const dx = point.x - firstPoint.x;
|
||||
const dy = point.y - firstPoint.y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < 10 / this.canvas.viewport.zoom) {
|
||||
this.closeShape();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.shape.points.push(point);
|
||||
this.canvas.render();
|
||||
}
|
||||
closeShape() {
|
||||
if (this.shape.points.length > 2) {
|
||||
this.shape.isClosed = true;
|
||||
log.info('Shape closed with', this.shape.points.length, 'points.');
|
||||
this.canvas.defineOutputAreaWithShape(this.shape);
|
||||
this.reset();
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
getBoundingBox() {
|
||||
if (this.shape.points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
this.shape.points.forEach(p => {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
});
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
reset() {
|
||||
this.shape = {
|
||||
points: [],
|
||||
isClosed: false,
|
||||
};
|
||||
log.info('ShapeTool reset.');
|
||||
this.canvas.render();
|
||||
}
|
||||
render(ctx) {
|
||||
if (this.shape.points.length === 0) {
|
||||
return;
|
||||
}
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
|
||||
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]);
|
||||
ctx.beginPath();
|
||||
const startPoint = this.shape.points[0];
|
||||
ctx.moveTo(startPoint.x, startPoint.y);
|
||||
for (let i = 1; i < this.shape.points.length; i++) {
|
||||
ctx.lineTo(this.shape.points[i].x, this.shape.points[i].y);
|
||||
}
|
||||
if (this.shape.isClosed) {
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(0, 255, 255, 0.2)';
|
||||
ctx.fill();
|
||||
}
|
||||
else if (this.isActive) {
|
||||
// Draw a line to the current mouse position
|
||||
ctx.lineTo(this.canvas.lastMousePosition.x, this.canvas.lastMousePosition.y);
|
||||
}
|
||||
ctx.stroke();
|
||||
// Draw vertices
|
||||
const mouse = this.canvas.lastMousePosition;
|
||||
const firstPoint = this.shape.points[0];
|
||||
let highlightFirst = false;
|
||||
if (!this.shape.isClosed && this.shape.points.length > 2 && mouse) {
|
||||
const dx = mouse.x - firstPoint.x;
|
||||
const dy = mouse.y - firstPoint.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 10 / this.canvas.viewport.zoom) {
|
||||
highlightFirst = true;
|
||||
}
|
||||
}
|
||||
this.shape.points.forEach((point, index) => {
|
||||
ctx.beginPath();
|
||||
if (index === 0 && highlightFirst) {
|
||||
ctx.arc(point.x, point.y, 8 / this.canvas.viewport.zoom, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = 'yellow';
|
||||
}
|
||||
else {
|
||||
ctx.arc(point.x, point.y, 4 / this.canvas.viewport.zoom, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = 'rgba(0, 255, 255, 1)';
|
||||
}
|
||||
ctx.fill();
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,99 @@
|
||||
.painter-button {
|
||||
background: linear-gradient(to bottom, #4a4a4a, #3a3a3a);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 6px 12px;
|
||||
background-color: #444;
|
||||
border: 1px solid #555;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.2s ease-in-out;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
margin: 2px;
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
text-shadow: 0 1px 2px rgb(0,0,0);
|
||||
}
|
||||
|
||||
.painter-button:hover {
|
||||
background: linear-gradient(to bottom, #5a5a5a, #4a4a4a);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
background-color: #555;
|
||||
border-color: #666;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.painter-button:active {
|
||||
background: linear-gradient(to bottom, #3a3a3a, #4a4a4a);
|
||||
transform: translateY(1px);
|
||||
background-color: #3a3a3a;
|
||||
transform: translateY(0);
|
||||
box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.painter-button:disabled,
|
||||
.painter-button:disabled:hover {
|
||||
background: #555;
|
||||
color: #888;
|
||||
background-color: #3a3a3a;
|
||||
color: #777;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
border-color: #444;
|
||||
border-color: #4a4a4a;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.painter-button.primary {
|
||||
background: linear-gradient(to bottom, #4a6cd4, #3a5cc4);
|
||||
border-color: #2a4cb4;
|
||||
background-color: #3a76d6;
|
||||
border-color: #2a6ac4;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgb(0,0,0);
|
||||
}
|
||||
|
||||
.painter-button.primary:hover {
|
||||
background: linear-gradient(to bottom, #5a7ce4, #4a6cd4);
|
||||
background-color: #4a86e4;
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
.painter-button.success {
|
||||
border-color: #4ae27a;
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 1.5px #4ae27a88;
|
||||
}
|
||||
.painter-button.success:hover {
|
||||
border-color: #6aff9a;
|
||||
box-shadow: 0 0 0 2.5px #6aff9a88;
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.painter-button.danger {
|
||||
border-color: #e24a4a;
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 1.5px #e24a4a88;
|
||||
}
|
||||
.painter-button.danger:hover {
|
||||
border-color: #ff6a6a;
|
||||
box-shadow: 0 0 0 2.5px #ff6a6a88;
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.painter-button.icon-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
min-width: 30px;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
line-height: 30px; /* Match height */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
background-color: #2f2f2f;
|
||||
border-bottom: 1px solid #202020;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
@@ -56,57 +101,216 @@
|
||||
|
||||
.painter-slider-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.painter-slider-container input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.painter-slider-container input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.painter-slider-container input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.painter-slider-container input[type="range"]::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
margin-top: 2px;
|
||||
min-height: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.painter-button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
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;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.painter-clipboard-group .painter-button {
|
||||
margin: 1px;
|
||||
height: 30px; /* Match switch height */
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* --- Clipboard Switch Modern --- */
|
||||
.clipboard-switch {
|
||||
position: relative;
|
||||
width: 90px;
|
||||
height: 30px;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(to right, #5a5a5a 30%, #3a76d6);
|
||||
border-radius: 5px;
|
||||
border: 1px solid #555;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
}
|
||||
.clipboard-switch:hover {
|
||||
background: linear-gradient(to right, #6a6a6a 30%, #4a86e4);
|
||||
border-color: #666;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Mask switch: szaro-czarny gradient tylko dla maski */
|
||||
.clipboard-switch.mask-switch {
|
||||
background: linear-gradient(to right, #5a5a5a 30%, #e53935);
|
||||
}
|
||||
.clipboard-switch.mask-switch:hover {
|
||||
background: linear-gradient(to right, #6a6a6a 30%, #ff5252);
|
||||
}
|
||||
.clipboard-switch:active {
|
||||
background: linear-gradient(135deg, #3a76d6, #3a3a3a);
|
||||
}
|
||||
|
||||
.clipboard-switch input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clipboard-switch .switch-track {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clipboard-switch .switch-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #5a5a5a;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.clipboard-switch:hover .switch-knob {
|
||||
background-color: #6a6a6a;
|
||||
}
|
||||
|
||||
.clipboard-switch:hover .switch-knob {
|
||||
background-color: #6a6a6a;
|
||||
}
|
||||
|
||||
.clipboard-switch .switch-labels {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 550;
|
||||
color: #ffffff;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
text-shadow: 0 1px 2px rgb(0, 0, 0);
|
||||
}
|
||||
.clipboard-switch .switch-labels .text-clipspace,
|
||||
.clipboard-switch .switch-labels .text-system {
|
||||
position: absolute;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
.clipboard-switch .switch-labels .text-clipspace { opacity: 0; }
|
||||
.clipboard-switch .switch-labels .text-system { opacity: 1; padding-left: 20px; }
|
||||
|
||||
.clipboard-switch .switch-knob .switch-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clipboard-switch .switch-knob .switch-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Checked state */
|
||||
.clipboard-switch:has(input:checked) {
|
||||
background: linear-gradient(to right, #3a76d6, #5a5a5a 70%);
|
||||
border-color: #2a6ac4;
|
||||
}
|
||||
.clipboard-switch:has(input:checked):hover {
|
||||
background: linear-gradient(to right, #4a86e4, #6a6a6a 70%);
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
.clipboard-switch input:checked ~ .switch-knob {
|
||||
left: calc(100% - 26px);
|
||||
}
|
||||
.clipboard-switch input:checked ~ .switch-knob .switch-icon img {
|
||||
filter: none;
|
||||
}
|
||||
.clipboard-switch input:checked ~ .switch-labels .text-clipspace {
|
||||
opacity: 1;
|
||||
color: #fff;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.clipboard-switch input:checked ~ .switch-labels .text-system {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
.painter-separator {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background-color: #2a2a2a;
|
||||
height: 24px;
|
||||
background-color: #444;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
@@ -182,17 +386,18 @@
|
||||
.painter-tooltip {
|
||||
position: fixed;
|
||||
display: none;
|
||||
background: #3a3a3a;
|
||||
background: #2B2B2B;
|
||||
color: #f0f0f0;
|
||||
border: 1px solid #555;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
border-top: 2px solid #4a90e2;
|
||||
border-radius: 6px;
|
||||
padding: 12px 18px;
|
||||
z-index: 9999;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
width: auto;
|
||||
max-width: min(500px, calc(100vw - 40px));
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
max-width: min(450px, calc(100vw - 30px));
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||
pointer-events: none;
|
||||
transform-origin: top left;
|
||||
transition: transform 0.2s ease;
|
||||
@@ -216,8 +421,9 @@
|
||||
}
|
||||
|
||||
.painter-tooltip table td {
|
||||
padding: 2px 8px;
|
||||
padding: 4px 8px;
|
||||
vertical-align: middle;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.painter-tooltip table td:first-child {
|
||||
@@ -231,7 +437,10 @@
|
||||
}
|
||||
|
||||
.painter-tooltip table tr:nth-child(odd) td {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.painter-tooltip table tr:hover td {
|
||||
background-color: rgba(74, 144, 226, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@@ -304,10 +513,15 @@
|
||||
|
||||
.painter-tooltip h4 {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
color: #4a90e2; /* Jasnoniebieski akcent */
|
||||
border-bottom: 1px solid #555;
|
||||
margin-bottom: 6px;
|
||||
color: #4a90e2;
|
||||
border-bottom: 1px solid #4a90e2;
|
||||
padding-bottom: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.painter-tooltip h4:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.painter-tooltip ul {
|
||||
@@ -317,13 +531,18 @@
|
||||
}
|
||||
|
||||
.painter-tooltip kbd {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 3px;
|
||||
background-color: #444;
|
||||
border: 1px solid #555;
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #d0d0d0;
|
||||
font-size: 11px;
|
||||
color: #e0e0e0;
|
||||
box-shadow: 0 1px 1px rgba(0,0,0,0.15);
|
||||
margin: 0 1px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.painter-container.has-focus {
|
||||
|
||||
204
js/css/custom_shape_menu.css
Normal file
204
js/css/custom_shape_menu.css
Normal file
@@ -0,0 +1,204 @@
|
||||
#layerforge-custom-shape-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #2f2f2f;
|
||||
color: #e0e0e0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
z-index: 1001;
|
||||
border: 1px solid #202020;
|
||||
user-select: none;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .menu-line {
|
||||
font-weight: 600;
|
||||
color: #4a90e2;
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px solid #444;
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- MINIMIZED BAR INTERACTIVE STYLE --- */
|
||||
.custom-shape-minimized-bar {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
background: #222;
|
||||
color: #4a90e2;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
|
||||
margin: 0 0 8px 0;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
border: 1px solid #444;
|
||||
transition: background 0.18s, color 0.18s, box-shadow 0.18s, border 0.18s;
|
||||
outline: none;
|
||||
text-shadow: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.custom-shape-minimized-bar:hover, .custom-shape-minimized-bar:focus {
|
||||
background: #2a2a2a;
|
||||
color: #4a90e2;
|
||||
border: 1.5px solid #4a90e2;
|
||||
box-shadow: 0 4px 16px #4a90e244;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .feature-container {
|
||||
background-color: #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #4a4a4a;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
#layerforge-custom-shape-menu .feature-container:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .slider-container {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 0;
|
||||
display: none;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .slider-label {
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
#layerforge-custom-shape-menu input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
#layerforge-custom-shape-menu input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: #fff;
|
||||
}
|
||||
#layerforge-custom-shape-menu input[type="range"]::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .slider-value-display {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
color: #bbb;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .extension-slider-container {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container .custom-checkbox {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #666;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container input:checked ~ .custom-checkbox {
|
||||
background-color: #3a76d6;
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container .custom-checkbox::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 5px;
|
||||
top: 1px;
|
||||
width: 4px;
|
||||
height: 9px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container input:checked ~ .custom-checkbox::after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layerforge-tooltip {
|
||||
position: fixed;
|
||||
background-color: #2f2f2f;
|
||||
color: #e0e0e0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.4;
|
||||
max-width: 250px;
|
||||
word-wrap: break-word;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
border: 1px solid #202020;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
<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>Shift + S + Left Click</kbd></td><td>Draw custom shape for output area</td></tr>
|
||||
<tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
|
||||
<tr><td><kbd>Esc</kbd></td><td>Close fullscreen editor mode</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Clipboard & I/O</h4>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
||||
// @ts-ignore
|
||||
import { api } from "../../../scripts/api.js";
|
||||
// @ts-ignore
|
||||
@@ -6,17 +8,13 @@ import { ComfyApp } from "../../../scripts/app.js";
|
||||
const log = createModuleLogger('ClipboardManager');
|
||||
export class ClipboardManager {
|
||||
constructor(canvas) {
|
||||
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 = 'mouse', preference = 'system') {
|
||||
try {
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
this.handlePaste = withErrorHandling(async (addMode = 'mouse', preference = 'system') => {
|
||||
log.info(`ClipboardManager handling paste with preference: ${preference}`);
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
log.info("Found layers in internal clipboard, pasting layers");
|
||||
@@ -33,19 +31,13 @@ export class ClipboardManager {
|
||||
}
|
||||
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) {
|
||||
try {
|
||||
}, 'ClipboardManager.handlePaste');
|
||||
/**
|
||||
* Attempts to paste from ComfyUI Clipspace
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
this.tryClipspacePaste = withErrorHandling(async (addMode) => {
|
||||
log.info("Attempting to paste from ComfyUI Clipspace");
|
||||
ComfyApp.pasteFromClipspace(this.canvas.node);
|
||||
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
||||
@@ -61,11 +53,57 @@ export class ClipboardManager {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch (clipspaceError) {
|
||||
log.warn("ComfyUI Clipspace paste failed:", clipspaceError);
|
||||
return false;
|
||||
}
|
||||
}, 'ClipboardManager.tryClipspacePaste');
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
this.loadFileViaBackend = withErrorHandling(async (filePath, addMode) => {
|
||||
if (!filePath) {
|
||||
throw createValidationError("File path is required", { filePath });
|
||||
}
|
||||
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();
|
||||
throw createNetworkError(`Backend failed to load image: ${errorData.error}`, {
|
||||
filePath,
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
});
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw createFileError(`Backend returned error: ${data.error}`, { filePath, backendError: data.error });
|
||||
}
|
||||
log.info("Successfully loaded image via ComfyUI backend:", filePath);
|
||||
const img = new Image();
|
||||
const success = 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;
|
||||
}, 'ClipboardManager.loadFileViaBackend');
|
||||
this.canvas = canvas;
|
||||
this.clipboardPreference = 'system'; // 'system', 'clipspace'
|
||||
}
|
||||
/**
|
||||
* System clipboard paste - handles both image data and text paths
|
||||
@@ -247,55 +285,6 @@ export class ClipboardManager {
|
||||
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, addMode) {
|
||||
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 = 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
|
||||
@@ -352,7 +341,7 @@ export class ClipboardManager {
|
||||
document.body.removeChild(fileInput);
|
||||
resolve(false);
|
||||
};
|
||||
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
|
||||
showInfoNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.click();
|
||||
});
|
||||
@@ -364,7 +353,7 @@ export class ClipboardManager {
|
||||
showFilePathMessage(filePath) {
|
||||
const fileName = filePath.split(/[\\\/]/).pop();
|
||||
const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`;
|
||||
this.showNotification(message, 5000);
|
||||
showNotification(message, "#c54747", 5000);
|
||||
log.info("Showed file path limitation message to user");
|
||||
}
|
||||
/**
|
||||
@@ -431,33 +420,4 @@ export class ClipboardManager {
|
||||
}, 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, duration = 3000) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
183
js/utils/IconLoader.js
Normal file
183
js/utils/IconLoader.js
Normal file
@@ -0,0 +1,183 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { createCanvas } from "./CommonUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
const log = createModuleLogger('IconLoader');
|
||||
// Define tool constants for LayerForge
|
||||
export const LAYERFORGE_TOOLS = {
|
||||
VISIBILITY: 'visibility',
|
||||
MOVE: 'move',
|
||||
ROTATE: 'rotate',
|
||||
SCALE: 'scale',
|
||||
DELETE: 'delete',
|
||||
DUPLICATE: 'duplicate',
|
||||
BLEND_MODE: 'blend_mode',
|
||||
OPACITY: 'opacity',
|
||||
MASK: 'mask',
|
||||
BRUSH: 'brush',
|
||||
ERASER: 'eraser',
|
||||
SHAPE: 'shape',
|
||||
SETTINGS: 'settings',
|
||||
SYSTEM_CLIPBOARD: 'system_clipboard',
|
||||
CLIPSPACE: 'clipspace',
|
||||
};
|
||||
// SVG Icons for LayerForge tools
|
||||
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
|
||||
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <defs> <mask id="cutout"> <rect width="100%" height="100%" fill="white"/> <path d="M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" fill="black" transform="translate(4.8 4.8) scale(0.6)" /> </mask> </defs> <path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z" fill="#ffffff" mask="url(#cutout)" /></svg>`;
|
||||
const LAYERFORGE_TOOL_ICONS = {
|
||||
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,6V9L16,5L12,1V4A8,8 0 0,0 4,12C4,13.57 4.46,15.03 5.24,16.26L6.7,14.8C6.25,13.97 6,13 6,12A6,6 0 0,1 12,6M18.76,7.74L17.3,9.2C17.74,10.04 18,11 18,12A6,6 0 0,1 12,18V15L8,19L12,23V20A8,8 0 0,0 20,12C20,10.43 19.54,8.97 18.76,7.74Z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.SCALE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M22,18V22H18V20H20V18H22M22,6V10H20V8H18V6H22M2,6V10H4V8H6V6H2M2,18V22H6V20H4V18H2M16,8V10H14V12H16V14H14V16H12V14H10V12H12V10H10V8H12V6H14V8H16Z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.DELETE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M7,6H17V19H7V6M9,8V17H11V8H9M13,8V17H15V8H13Z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.DUPLICATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.BLEND_MODE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20V4Z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.OPACITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,20A6,6 0 0,1 6,14C6,10 12,3.25 12,3.25S18,10 18,14A6,6 0 0,1 12,20Z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.MASK]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="#ffffff" stroke-width="2"/><circle cx="12" cy="12" r="5" fill="#ffffff"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.BRUSH]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M15.4565 9.67503L15.3144 9.53297C14.6661 8.90796 13.8549 8.43369 12.9235 8.18412C10.0168 7.40527 7.22541 9.05273 6.43185 12.0143C6.38901 12.1742 6.36574 12.3537 6.3285 12.8051C6.17423 14.6752 5.73449 16.0697 4.5286 17.4842C6.78847 18.3727 9.46572 18.9986 11.5016 18.9986C13.9702 18.9986 16.1644 17.3394 16.8126 14.9202C17.3306 12.9869 16.7513 11.0181 15.4565 9.67503ZM13.2886 6.21301L18.2278 2.37142C18.6259 2.0618 19.1922 2.09706 19.5488 2.45367L22.543 5.44787C22.8997 5.80448 22.9349 6.37082 22.6253 6.76891L18.7847 11.7068C19.0778 12.8951 19.0836 14.1721 18.7444 15.4379C17.8463 18.7897 14.8142 20.9986 11.5016 20.9986C8 20.9986 3.5 19.4967 1 17.9967C4.97978 14.9967 4.04722 13.1865 4.5 11.4967C5.55843 7.54658 9.34224 5.23935 13.2886 6.21301ZM16.7015 8.09161C16.7673 8.15506 16.8319 8.21964 16.8952 8.28533L18.0297 9.41984L20.5046 6.23786L18.7589 4.4921L15.5769 6.96698L16.7015 8.09161Z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.ERASER]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M8.58564 8.85449L3.63589 13.8042L8.83021 18.9985L9.99985 18.9978V18.9966H11.1714L14.9496 15.2184L8.58564 8.85449ZM9.99985 7.44027L16.3638 13.8042L19.1922 10.9758L12.8283 4.61185L9.99985 7.44027ZM13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966Z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.SHAPE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M3 4H21C21.5523 4 22 4.44772 22 5V19C22 19.5523 21.5523 20 21 20H3C2.44772 20 2 19.5523 2 19V5C2 4.44772 2.44772 4 3 4ZM4 6V18H20V6H4Z"/></svg>')}`,
|
||||
[LAYERFORGE_TOOLS.SETTINGS]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.5,11L5.13,18.93C5.17,19.18 5.38,19.36 5.63,19.36H18.37C18.62,19.36 18.83,19.18 18.87,18.93L19.5,11L21.54,9.37Z"/></svg>')}`
|
||||
};
|
||||
// Tool colors for LayerForge
|
||||
const LAYERFORGE_TOOL_COLORS = {
|
||||
[LAYERFORGE_TOOLS.VISIBILITY]: '#4285F4',
|
||||
[LAYERFORGE_TOOLS.MOVE]: '#34A853',
|
||||
[LAYERFORGE_TOOLS.ROTATE]: '#FBBC05',
|
||||
[LAYERFORGE_TOOLS.SCALE]: '#EA4335',
|
||||
[LAYERFORGE_TOOLS.DELETE]: '#FF6D01',
|
||||
[LAYERFORGE_TOOLS.DUPLICATE]: '#46BDC6',
|
||||
[LAYERFORGE_TOOLS.BLEND_MODE]: '#9C27B0',
|
||||
[LAYERFORGE_TOOLS.OPACITY]: '#8BC34A',
|
||||
[LAYERFORGE_TOOLS.MASK]: '#607D8B',
|
||||
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
|
||||
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
|
||||
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
|
||||
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292'
|
||||
};
|
||||
export class IconLoader {
|
||||
constructor() {
|
||||
this._iconCache = {};
|
||||
this._loadingPromises = new Map();
|
||||
/**
|
||||
* Preload all LayerForge tool icons
|
||||
*/
|
||||
this.preloadToolIcons = withErrorHandling(async () => {
|
||||
log.info('Starting to preload LayerForge tool icons');
|
||||
const loadPromises = Object.keys(LAYERFORGE_TOOL_ICONS).map(tool => {
|
||||
return this.loadIcon(tool);
|
||||
});
|
||||
await Promise.all(loadPromises);
|
||||
log.info(`Successfully preloaded ${loadPromises.length} tool icons`);
|
||||
}, 'IconLoader.preloadToolIcons');
|
||||
/**
|
||||
* Load a specific icon by tool name
|
||||
*/
|
||||
this.loadIcon = withErrorHandling(async (tool) => {
|
||||
if (!tool) {
|
||||
throw createValidationError("Tool name is required", { tool });
|
||||
}
|
||||
// Check if already cached
|
||||
if (this._iconCache[tool] && this._iconCache[tool] instanceof HTMLImageElement) {
|
||||
return this._iconCache[tool];
|
||||
}
|
||||
// Check if already loading
|
||||
if (this._loadingPromises.has(tool)) {
|
||||
return this._loadingPromises.get(tool);
|
||||
}
|
||||
// Create fallback canvas first
|
||||
const fallbackCanvas = this.createFallbackIcon(tool);
|
||||
this._iconCache[tool] = fallbackCanvas;
|
||||
// Start loading the SVG icon
|
||||
const loadPromise = new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
this._iconCache[tool] = img;
|
||||
this._loadingPromises.delete(tool);
|
||||
log.debug(`Successfully loaded icon for tool: ${tool}`);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = (error) => {
|
||||
log.warn(`Failed to load SVG icon for tool: ${tool}, using fallback`);
|
||||
this._loadingPromises.delete(tool);
|
||||
// Keep the fallback canvas in cache
|
||||
reject(error);
|
||||
};
|
||||
const iconData = LAYERFORGE_TOOL_ICONS[tool];
|
||||
if (iconData) {
|
||||
img.src = iconData;
|
||||
}
|
||||
else {
|
||||
log.warn(`No icon data found for tool: ${tool}`);
|
||||
reject(createValidationError(`No icon data for tool: ${tool}`, { tool, availableTools: Object.keys(LAYERFORGE_TOOL_ICONS) }));
|
||||
}
|
||||
});
|
||||
this._loadingPromises.set(tool, loadPromise);
|
||||
return loadPromise;
|
||||
}, 'IconLoader.loadIcon');
|
||||
log.info('IconLoader initialized');
|
||||
}
|
||||
/**
|
||||
* Create a fallback canvas icon with colored background and text
|
||||
*/
|
||||
createFallbackIcon(tool) {
|
||||
const { canvas, ctx } = createCanvas(24, 24);
|
||||
if (!ctx) {
|
||||
log.error('Failed to get canvas context for fallback icon');
|
||||
return canvas;
|
||||
}
|
||||
// Fill background with tool color
|
||||
const color = LAYERFORGE_TOOL_COLORS[tool] || '#888888';
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, 24, 24);
|
||||
// Add border
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(0.5, 0.5, 23, 23);
|
||||
// Add text
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const firstChar = tool.charAt(0).toUpperCase();
|
||||
ctx.fillText(firstChar, 12, 12);
|
||||
return canvas;
|
||||
}
|
||||
/**
|
||||
* Get cached icon (canvas or image)
|
||||
*/
|
||||
getIcon(tool) {
|
||||
return this._iconCache[tool] || null;
|
||||
}
|
||||
/**
|
||||
* Check if icon is loaded (as image, not fallback canvas)
|
||||
*/
|
||||
isIconLoaded(tool) {
|
||||
return this._iconCache[tool] instanceof HTMLImageElement;
|
||||
}
|
||||
/**
|
||||
* Clear all cached icons
|
||||
*/
|
||||
clearCache() {
|
||||
this._iconCache = {};
|
||||
this._loadingPromises.clear();
|
||||
log.info('Icon cache cleared');
|
||||
}
|
||||
/**
|
||||
* Get all available tool names
|
||||
*/
|
||||
getAvailableTools() {
|
||||
return Object.values(LAYERFORGE_TOOLS);
|
||||
}
|
||||
/**
|
||||
* Get tool color
|
||||
*/
|
||||
getToolColor(tool) {
|
||||
return LAYERFORGE_TOOL_COLORS[tool] || '#888888';
|
||||
}
|
||||
}
|
||||
// Export singleton instance
|
||||
export const iconLoader = new IconLoader();
|
||||
// Export for external use
|
||||
export { LAYERFORGE_TOOL_ICONS, LAYERFORGE_TOOL_COLORS };
|
||||
230
js/utils/ImageAnalysis.js
Normal file
230
js/utils/ImageAnalysis.js
Normal file
@@ -0,0 +1,230 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { createCanvas } from "./CommonUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
const log = createModuleLogger('ImageAnalysis');
|
||||
/**
|
||||
* Creates a distance field mask based on the alpha channel of an image.
|
||||
* The mask will have gradients from the edges of visible pixels inward.
|
||||
* @param image - The source image to analyze
|
||||
* @param blendArea - The percentage (0-100) of the area to apply blending
|
||||
* @returns HTMLCanvasElement containing the distance field mask
|
||||
*/
|
||||
/**
|
||||
* Synchronous version of createDistanceFieldMask for use in synchronous rendering
|
||||
*/
|
||||
export function createDistanceFieldMaskSync(image, blendArea) {
|
||||
if (!image) {
|
||||
log.error("Image is required for distance field mask");
|
||||
return createCanvas(1, 1).canvas;
|
||||
}
|
||||
if (typeof blendArea !== 'number' || blendArea < 0 || blendArea > 100) {
|
||||
log.error("Blend area must be a number between 0 and 100");
|
||||
return createCanvas(1, 1).canvas;
|
||||
}
|
||||
const { canvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
|
||||
if (!ctx) {
|
||||
log.error('Failed to create canvas context for distance field mask');
|
||||
return canvas;
|
||||
}
|
||||
// Draw the image to extract pixel data
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
// Check if image has transparency (any alpha < 255)
|
||||
let hasTransparency = false;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
if (data[i * 4 + 3] < 255) {
|
||||
hasTransparency = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let distanceField;
|
||||
let maxDistance;
|
||||
if (hasTransparency) {
|
||||
// For images with transparency, use alpha-based distance transform
|
||||
const binaryMask = new Uint8Array(width * height);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
binaryMask[i] = data[i * 4 + 3] > 0 ? 1 : 0;
|
||||
}
|
||||
distanceField = calculateDistanceTransform(binaryMask, width, height);
|
||||
}
|
||||
else {
|
||||
// For opaque images, calculate distance from edges of the rectangle
|
||||
distanceField = calculateDistanceFromEdges(width, height);
|
||||
}
|
||||
// Find the maximum distance to normalize
|
||||
maxDistance = 0;
|
||||
for (let i = 0; i < distanceField.length; i++) {
|
||||
if (distanceField[i] > maxDistance) {
|
||||
maxDistance = distanceField[i];
|
||||
}
|
||||
}
|
||||
// Create the gradient mask based on blendArea
|
||||
const maskData = ctx.createImageData(width, height);
|
||||
const threshold = maxDistance * (blendArea / 100);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const distance = distanceField[i];
|
||||
const alpha = data[i * 4 + 3];
|
||||
if (alpha === 0) {
|
||||
// Transparent pixels remain transparent
|
||||
maskData.data[i * 4] = 255;
|
||||
maskData.data[i * 4 + 1] = 255;
|
||||
maskData.data[i * 4 + 2] = 255;
|
||||
maskData.data[i * 4 + 3] = 0;
|
||||
}
|
||||
else if (distance <= threshold) {
|
||||
// Edge area - apply gradient alpha
|
||||
const gradientValue = distance / threshold;
|
||||
const alphaValue = Math.floor(gradientValue * 255);
|
||||
maskData.data[i * 4] = 255;
|
||||
maskData.data[i * 4 + 1] = 255;
|
||||
maskData.data[i * 4 + 2] = 255;
|
||||
maskData.data[i * 4 + 3] = alphaValue;
|
||||
}
|
||||
else {
|
||||
// Inner area - full alpha (no blending effect)
|
||||
maskData.data[i * 4] = 255;
|
||||
maskData.data[i * 4 + 1] = 255;
|
||||
maskData.data[i * 4 + 2] = 255;
|
||||
maskData.data[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
// Clear canvas and put the mask data
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.putImageData(maskData, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
/**
|
||||
* Async version with error handling for use in async contexts
|
||||
*/
|
||||
export const createDistanceFieldMask = withErrorHandling(function (image, blendArea) {
|
||||
return createDistanceFieldMaskSync(image, blendArea);
|
||||
}, 'createDistanceFieldMask');
|
||||
/**
|
||||
* Calculates the Euclidean distance transform of a binary mask.
|
||||
* Uses a two-pass algorithm for efficiency.
|
||||
* @param binaryMask - Binary mask where 1 = inside, 0 = outside
|
||||
* @param width - Width of the mask
|
||||
* @param height - Height of the mask
|
||||
* @returns Float32Array containing distance values
|
||||
*/
|
||||
function calculateDistanceTransform(binaryMask, width, height) {
|
||||
const distances = new Float32Array(width * height);
|
||||
const infinity = width + height; // A value larger than any possible distance
|
||||
// Initialize distances
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
distances[i] = binaryMask[i] === 1 ? infinity : 0;
|
||||
}
|
||||
// Forward pass (top-left to bottom-right)
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
if (distances[idx] > 0) {
|
||||
let minDist = distances[idx];
|
||||
// Check top neighbor
|
||||
if (y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + x] + 1);
|
||||
}
|
||||
// Check left neighbor
|
||||
if (x > 0) {
|
||||
minDist = Math.min(minDist, distances[y * width + (x - 1)] + 1);
|
||||
}
|
||||
// Check top-left diagonal
|
||||
if (x > 0 && y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + (x - 1)] + Math.sqrt(2));
|
||||
}
|
||||
// Check top-right diagonal
|
||||
if (x < width - 1 && y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + (x + 1)] + Math.sqrt(2));
|
||||
}
|
||||
distances[idx] = minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Backward pass (bottom-right to top-left)
|
||||
for (let y = height - 1; y >= 0; y--) {
|
||||
for (let x = width - 1; x >= 0; x--) {
|
||||
const idx = y * width + x;
|
||||
if (distances[idx] > 0) {
|
||||
let minDist = distances[idx];
|
||||
// Check bottom neighbor
|
||||
if (y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + x] + 1);
|
||||
}
|
||||
// Check right neighbor
|
||||
if (x < width - 1) {
|
||||
minDist = Math.min(minDist, distances[y * width + (x + 1)] + 1);
|
||||
}
|
||||
// Check bottom-right diagonal
|
||||
if (x < width - 1 && y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + (x + 1)] + Math.sqrt(2));
|
||||
}
|
||||
// Check bottom-left diagonal
|
||||
if (x > 0 && y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + (x - 1)] + Math.sqrt(2));
|
||||
}
|
||||
distances[idx] = minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
return distances;
|
||||
}
|
||||
/**
|
||||
* Calculates distance from edges of a rectangle for opaque images.
|
||||
* @param width - Width of the rectangle
|
||||
* @param height - Height of the rectangle
|
||||
* @returns Float32Array containing distance values from edges
|
||||
*/
|
||||
function calculateDistanceFromEdges(width, height) {
|
||||
const distances = new Float32Array(width * height);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
// Calculate distance to nearest edge
|
||||
const distToLeft = x;
|
||||
const distToRight = width - 1 - x;
|
||||
const distToTop = y;
|
||||
const distToBottom = height - 1 - y;
|
||||
// Minimum distance to any edge
|
||||
const minDistToEdge = Math.min(distToLeft, distToRight, distToTop, distToBottom);
|
||||
distances[idx] = minDistToEdge;
|
||||
}
|
||||
}
|
||||
return distances;
|
||||
}
|
||||
/**
|
||||
* Creates a simple radial gradient mask (fallback for rectangular areas).
|
||||
* @param width - Width of the mask
|
||||
* @param height - Height of the mask
|
||||
* @param blendArea - The percentage (0-100) of the area to apply blending
|
||||
* @returns HTMLCanvasElement containing the radial gradient mask
|
||||
*/
|
||||
export const createRadialGradientMask = withErrorHandling(function (width, height, blendArea) {
|
||||
if (typeof width !== 'number' || width <= 0) {
|
||||
throw createValidationError("Width must be a positive number", { width });
|
||||
}
|
||||
if (typeof height !== 'number' || height <= 0) {
|
||||
throw createValidationError("Height must be a positive number", { height });
|
||||
}
|
||||
if (typeof blendArea !== 'number' || blendArea < 0 || blendArea > 100) {
|
||||
throw createValidationError("Blend area must be a number between 0 and 100", { blendArea });
|
||||
}
|
||||
const { canvas, ctx } = createCanvas(width, height);
|
||||
if (!ctx) {
|
||||
log.error('Failed to create canvas context for radial gradient mask');
|
||||
return canvas;
|
||||
}
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
|
||||
const innerRadius = maxRadius * (1 - blendArea / 100);
|
||||
// Create radial gradient
|
||||
const gradient = ctx.createRadialGradient(centerX, centerY, innerRadius, centerX, centerY, maxRadius);
|
||||
gradient.addColorStop(0, 'white');
|
||||
gradient.addColorStop(1, 'black');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
return canvas;
|
||||
}, 'createRadialGradientMask');
|
||||
130
js/utils/ImageUploadUtils.js
Normal file
130
js/utils/ImageUploadUtils.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
|
||||
const log = createModuleLogger('ImageUploadUtils');
|
||||
/**
|
||||
* Uploads an image blob to ComfyUI server and returns image element
|
||||
* @param blob - Image blob to upload
|
||||
* @param options - Upload options
|
||||
* @returns Promise with upload result
|
||||
*/
|
||||
export const uploadImageBlob = withErrorHandling(async function (blob, options = {}) {
|
||||
if (!blob) {
|
||||
throw createValidationError("Blob is required", { blob });
|
||||
}
|
||||
if (blob.size === 0) {
|
||||
throw createValidationError("Blob cannot be empty", { blobSize: blob.size });
|
||||
}
|
||||
const { filenamePrefix = 'layerforge', overwrite = true, type = 'temp', nodeId } = options;
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const nodeIdSuffix = nodeId ? `-${nodeId}` : '';
|
||||
const filename = `${filenamePrefix}${nodeIdSuffix}-${timestamp}.png`;
|
||||
log.debug('Uploading image blob:', {
|
||||
filename,
|
||||
blobSize: blob.size,
|
||||
type,
|
||||
overwrite
|
||||
});
|
||||
// Create FormData
|
||||
const formData = new FormData();
|
||||
formData.append("image", blob, filename);
|
||||
formData.append("overwrite", overwrite.toString());
|
||||
formData.append("type", type);
|
||||
// Upload to server
|
||||
const response = await api.fetchApi("/upload/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw createNetworkError(`Failed to upload image: ${response.statusText}`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
filename,
|
||||
blobSize: blob.size
|
||||
});
|
||||
}
|
||||
const data = await response.json();
|
||||
log.debug('Image uploaded successfully:', data);
|
||||
// Create image element with proper URL
|
||||
const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
|
||||
const imageElement = new Image();
|
||||
imageElement.crossOrigin = "anonymous";
|
||||
// Wait for image to load
|
||||
await new Promise((resolve, reject) => {
|
||||
imageElement.onload = () => {
|
||||
log.debug("Uploaded image loaded successfully", {
|
||||
width: imageElement.width,
|
||||
height: imageElement.height,
|
||||
src: imageElement.src.substring(0, 100) + '...'
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
imageElement.onerror = (error) => {
|
||||
log.error("Failed to load uploaded image", error);
|
||||
reject(createNetworkError("Failed to load uploaded image", { error, imageUrl, filename }));
|
||||
};
|
||||
imageElement.src = imageUrl;
|
||||
});
|
||||
return {
|
||||
data,
|
||||
filename,
|
||||
imageUrl,
|
||||
imageElement
|
||||
};
|
||||
}, 'uploadImageBlob');
|
||||
/**
|
||||
* Uploads canvas content as image blob
|
||||
* @param canvas - Canvas element or Canvas object with canvasLayers
|
||||
* @param options - Upload options
|
||||
* @returns Promise with upload result
|
||||
*/
|
||||
export const uploadCanvasAsImage = withErrorHandling(async function (canvas, options = {}) {
|
||||
if (!canvas) {
|
||||
throw createValidationError("Canvas is required", { canvas });
|
||||
}
|
||||
let blob = null;
|
||||
// Handle different canvas types
|
||||
if (canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') {
|
||||
// LayerForge Canvas object
|
||||
blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
}
|
||||
else if (canvas instanceof HTMLCanvasElement) {
|
||||
// Standard HTML Canvas
|
||||
blob = await new Promise(resolve => canvas.toBlob(resolve));
|
||||
}
|
||||
else {
|
||||
throw createValidationError("Unsupported canvas type", {
|
||||
canvas,
|
||||
hasCanvasLayers: !!canvas.canvasLayers,
|
||||
isHTMLCanvas: canvas instanceof HTMLCanvasElement
|
||||
});
|
||||
}
|
||||
if (!blob) {
|
||||
throw createValidationError("Failed to generate canvas blob", { canvas, options });
|
||||
}
|
||||
return uploadImageBlob(blob, options);
|
||||
}, 'uploadCanvasAsImage');
|
||||
/**
|
||||
* Uploads canvas with mask as image blob
|
||||
* @param canvas - Canvas object with canvasLayers
|
||||
* @param options - Upload options
|
||||
* @returns Promise with upload result
|
||||
*/
|
||||
export const uploadCanvasWithMaskAsImage = withErrorHandling(async function (canvas, options = {}) {
|
||||
if (!canvas) {
|
||||
throw createValidationError("Canvas is required", { canvas });
|
||||
}
|
||||
if (!canvas.canvasLayers || typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob !== 'function') {
|
||||
throw createValidationError("Canvas does not support mask operations", {
|
||||
canvas,
|
||||
hasCanvasLayers: !!canvas.canvasLayers,
|
||||
hasMaskMethod: !!(canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function')
|
||||
});
|
||||
}
|
||||
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob) {
|
||||
throw createValidationError("Failed to generate canvas with mask blob", { canvas, options });
|
||||
}
|
||||
return uploadImageBlob(blob, options);
|
||||
}, 'uploadCanvasWithMaskAsImage');
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
import { createCanvas } from "./CommonUtils.js";
|
||||
const log = createModuleLogger('ImageUtils');
|
||||
export function validateImageData(data) {
|
||||
log.debug("Validating data structure:", {
|
||||
@@ -126,10 +127,7 @@ export const imageToTensor = withErrorHandling(async function (image) {
|
||||
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;
|
||||
const { canvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
@@ -154,10 +152,7 @@ export const tensorToImage = withErrorHandling(async function (tensor) {
|
||||
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;
|
||||
const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = tensor.data;
|
||||
@@ -183,15 +178,12 @@ export const resizeImage = withErrorHandling(async function (image, maxWidth, ma
|
||||
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;
|
||||
const { canvas, ctx } = createCanvas(newWidth, newHeight, '2d', { willReadFrequently: true });
|
||||
if (ctx) {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
@@ -212,10 +204,9 @@ export const imageToBase64 = withErrorHandling(function (image, format = 'png',
|
||||
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;
|
||||
const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
|
||||
const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
|
||||
const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const mimeType = `image/${format}`;
|
||||
@@ -262,10 +253,7 @@ export function createImageFromSource(source) {
|
||||
});
|
||||
}
|
||||
export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||
if (ctx) {
|
||||
if (color !== 'transparent') {
|
||||
ctx.fillStyle = color;
|
||||
@@ -280,3 +268,49 @@ export const createEmptyImage = withErrorHandling(function (width, height, color
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'createEmptyImage');
|
||||
/**
|
||||
* Converts a canvas or image to an Image element
|
||||
* Consolidated from MaskProcessingUtils.convertToImage()
|
||||
* @param source - Source canvas or image
|
||||
* @returns Promise with Image element
|
||||
*/
|
||||
export async function convertToImage(source) {
|
||||
if (source instanceof HTMLImageElement) {
|
||||
return source; // Already an image
|
||||
}
|
||||
const image = new Image();
|
||||
image.src = source.toDataURL();
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = reject;
|
||||
});
|
||||
return image;
|
||||
}
|
||||
/**
|
||||
* Creates a mask from image source for use in mask editor
|
||||
* Consolidated from mask_utils.create_mask_from_image_src()
|
||||
* @param imageSrc - Image source (URL or data URL)
|
||||
* @returns Promise returning Image object
|
||||
*/
|
||||
export function createMaskFromImageSrc(imageSrc) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = imageSrc;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Converts canvas to Image for use as mask
|
||||
* Consolidated from mask_utils.canvas_to_mask_image()
|
||||
* @param canvas - Canvas to convert
|
||||
* @returns Promise returning Image object
|
||||
*/
|
||||
export function canvasToMaskImage(canvas) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
|
||||
170
js/utils/MaskProcessingUtils.js
Normal file
170
js/utils/MaskProcessingUtils.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { createCanvas } from "./CommonUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
const log = createModuleLogger('MaskProcessingUtils');
|
||||
/**
|
||||
* Processes an image to create a mask with inverted alpha channel
|
||||
* @param sourceImage - Source image or canvas element
|
||||
* @param options - Processing options
|
||||
* @returns Promise with processed mask as HTMLCanvasElement
|
||||
*/
|
||||
export const processImageToMask = withErrorHandling(async function (sourceImage, options = {}) {
|
||||
if (!sourceImage) {
|
||||
throw createValidationError("Source image is required", { sourceImage });
|
||||
}
|
||||
const { targetWidth = sourceImage.width, targetHeight = sourceImage.height, invertAlpha = true, maskColor = { r: 255, g: 255, b: 255 } } = options;
|
||||
log.debug('Processing image to mask:', {
|
||||
sourceSize: { width: sourceImage.width, height: sourceImage.height },
|
||||
targetSize: { width: targetWidth, height: targetHeight },
|
||||
invertAlpha,
|
||||
maskColor
|
||||
});
|
||||
// Create temporary canvas for processing
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true });
|
||||
if (!tempCtx) {
|
||||
throw createValidationError("Failed to get 2D context for mask processing");
|
||||
}
|
||||
// Draw the source image
|
||||
tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight);
|
||||
// Get image data for processing
|
||||
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
|
||||
const data = imageData.data;
|
||||
// Process pixels to create mask
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const originalAlpha = data[i + 3];
|
||||
// Set RGB to mask color
|
||||
data[i] = maskColor.r; // Red
|
||||
data[i + 1] = maskColor.g; // Green
|
||||
data[i + 2] = maskColor.b; // Blue
|
||||
// Handle alpha channel
|
||||
if (invertAlpha) {
|
||||
data[i + 3] = 255 - originalAlpha; // Invert alpha
|
||||
}
|
||||
else {
|
||||
data[i + 3] = originalAlpha; // Keep original alpha
|
||||
}
|
||||
}
|
||||
// Put processed data back to canvas
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
log.debug('Mask processing completed');
|
||||
return tempCanvas;
|
||||
}, 'processImageToMask');
|
||||
/**
|
||||
* Processes image data with custom pixel transformation
|
||||
* @param sourceImage - Source image or canvas element
|
||||
* @param pixelTransform - Custom pixel transformation function
|
||||
* @param options - Processing options
|
||||
* @returns Promise with processed image as HTMLCanvasElement
|
||||
*/
|
||||
export const processImageWithTransform = withErrorHandling(async function (sourceImage, pixelTransform, options = {}) {
|
||||
if (!sourceImage) {
|
||||
throw createValidationError("Source image is required", { sourceImage });
|
||||
}
|
||||
if (!pixelTransform || typeof pixelTransform !== 'function') {
|
||||
throw createValidationError("Pixel transform function is required", { pixelTransform });
|
||||
}
|
||||
const { targetWidth = sourceImage.width, targetHeight = sourceImage.height } = options;
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true });
|
||||
if (!tempCtx) {
|
||||
throw createValidationError("Failed to get 2D context for image processing");
|
||||
}
|
||||
tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight);
|
||||
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const [r, g, b, a] = pixelTransform(data[i], data[i + 1], data[i + 2], data[i + 3], i / 4);
|
||||
data[i] = r;
|
||||
data[i + 1] = g;
|
||||
data[i + 2] = b;
|
||||
data[i + 3] = a;
|
||||
}
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
return tempCanvas;
|
||||
}, 'processImageWithTransform');
|
||||
/**
|
||||
* Crops an image to a specific region
|
||||
* @param sourceImage - Source image or canvas
|
||||
* @param cropArea - Crop area {x, y, width, height}
|
||||
* @returns Promise with cropped image as HTMLCanvasElement
|
||||
*/
|
||||
export const cropImage = withErrorHandling(async function (sourceImage, cropArea) {
|
||||
if (!sourceImage) {
|
||||
throw createValidationError("Source image is required", { sourceImage });
|
||||
}
|
||||
if (!cropArea || typeof cropArea !== 'object') {
|
||||
throw createValidationError("Crop area is required", { cropArea });
|
||||
}
|
||||
const { x, y, width, height } = cropArea;
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw createValidationError("Crop area must have positive width and height", { cropArea });
|
||||
}
|
||||
log.debug('Cropping image:', {
|
||||
sourceSize: { width: sourceImage.width, height: sourceImage.height },
|
||||
cropArea
|
||||
});
|
||||
const { canvas, ctx } = createCanvas(width, height);
|
||||
if (!ctx) {
|
||||
throw createValidationError("Failed to get 2D context for image cropping");
|
||||
}
|
||||
ctx.drawImage(sourceImage, x, y, width, height, // Source rectangle
|
||||
0, 0, width, height // Destination rectangle
|
||||
);
|
||||
return canvas;
|
||||
}, 'cropImage');
|
||||
/**
|
||||
* Applies a mask to an image using viewport positioning
|
||||
* @param maskImage - Mask image or canvas
|
||||
* @param targetWidth - Target viewport width
|
||||
* @param targetHeight - Target viewport height
|
||||
* @param viewportOffset - Viewport offset {x, y}
|
||||
* @param maskColor - Mask color (default: white)
|
||||
* @returns Promise with processed mask for viewport
|
||||
*/
|
||||
export const processMaskForViewport = withErrorHandling(async function (maskImage, targetWidth, targetHeight, viewportOffset, maskColor = { r: 255, g: 255, b: 255 }) {
|
||||
if (!maskImage) {
|
||||
throw createValidationError("Mask image is required", { maskImage });
|
||||
}
|
||||
if (!viewportOffset || typeof viewportOffset !== 'object') {
|
||||
throw createValidationError("Viewport offset is required", { viewportOffset });
|
||||
}
|
||||
if (targetWidth <= 0 || targetHeight <= 0) {
|
||||
throw createValidationError("Target dimensions must be positive", { targetWidth, targetHeight });
|
||||
}
|
||||
log.debug("Processing mask for viewport:", {
|
||||
sourceSize: { width: maskImage.width, height: maskImage.height },
|
||||
targetSize: { width: targetWidth, height: targetHeight },
|
||||
viewportOffset
|
||||
});
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true });
|
||||
if (!tempCtx) {
|
||||
throw createValidationError("Failed to get 2D context for viewport mask processing");
|
||||
}
|
||||
// Calculate source coordinates based on viewport offset
|
||||
const sourceX = -viewportOffset.x;
|
||||
const sourceY = -viewportOffset.y;
|
||||
// Draw the mask with viewport cropping
|
||||
tempCtx.drawImage(maskImage, // Source: full mask from "output area"
|
||||
sourceX, // sx: Real X coordinate on large mask
|
||||
sourceY, // sy: Real Y coordinate on large mask
|
||||
targetWidth, // sWidth: Width of cropped fragment
|
||||
targetHeight, // sHeight: Height of cropped fragment
|
||||
0, // dx: Where to paste in target canvas (always 0)
|
||||
0, // dy: Where to paste in target canvas (always 0)
|
||||
targetWidth, // dWidth: Width of pasted image
|
||||
targetHeight // dHeight: Height of pasted image
|
||||
);
|
||||
// Apply mask color
|
||||
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.debug("Viewport mask processing completed");
|
||||
return tempCanvas;
|
||||
}, 'processMaskForViewport');
|
||||
220
js/utils/NotificationUtils.js
Normal file
220
js/utils/NotificationUtils.js
Normal file
@@ -0,0 +1,220 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
const log = createModuleLogger('NotificationUtils');
|
||||
/**
|
||||
* Utility functions for showing notifications to the user
|
||||
*/
|
||||
/**
|
||||
* Shows a temporary notification to the user
|
||||
* @param message - The message to show
|
||||
* @param backgroundColor - Background color (default: #4a6cd4)
|
||||
* @param duration - Duration in milliseconds (default: 3000)
|
||||
*/
|
||||
export function showNotification(message, backgroundColor = "#4a6cd4", duration = 3000, type = "info") {
|
||||
// Remove any existing prefix to avoid double prefixing
|
||||
message = message.replace(/^\[Layer Forge\]\s*/, "");
|
||||
// Type-specific config
|
||||
const config = {
|
||||
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
|
||||
error: { icon: "❌", title: "Error", bg: "#ff6f6f" },
|
||||
info: { icon: "ℹ️", title: "Info", bg: "#4a6cd4" },
|
||||
warning: { icon: "⚠️", title: "Warning", bg: "#ffd43b" },
|
||||
alert: { icon: "⚠️", title: "Alert", bg: "#fff7cc" }
|
||||
}[type];
|
||||
// --- Get or create the main notification container ---
|
||||
let container = document.getElementById('lf-notification-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'lf-notification-container';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
// --- Dark, modern notification style ---
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
min-width: 380px;
|
||||
max-width: 440px;
|
||||
max-height: 80vh;
|
||||
background: rgba(30, 32, 41, 0.9);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 32px rgba(0,0,0,0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
font-family: 'Segoe UI', 'Arial', sans-serif;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(80, 80, 80, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: lf-fadein 0.3s ease-out;
|
||||
`;
|
||||
// --- Header (non-scrollable) ---
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = `display: flex; align-items: flex-start; padding: 16px 20px; position: relative; flex-shrink: 0;`;
|
||||
const leftBar = document.createElement('div');
|
||||
leftBar.style.cssText = `position: absolute; left: 0; top: 0; bottom: 0; width: 6px; background: ${config.bg}; box-shadow: 0 0 12px ${config.bg}; border-radius: 3px 0 0 3px;`;
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.style.cssText = `width: 48px; height: 48px; min-width: 48px; min-height: 48px; display: flex; align-items: center; justify-content: center; margin-left: 18px; margin-right: 18px;`;
|
||||
iconContainer.innerHTML = {
|
||||
success: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-succ"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 4 L44 14 L44 34 L24 44 L4 34 L4 14 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/><g filter="url(#f-succ)"><path d="M16 24 L22 30 L34 18" stroke="#fff" stroke-width="3" fill="none"/></g></svg>`,
|
||||
error: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-err"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M14 14 L34 34 M34 14 L14 34" fill="none" stroke="#fff" stroke-width="3"/><g filter="url(#f-err)"><path d="M24,4 L42,12 L42,36 L24,44 L6,36 L6,12 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
|
||||
info: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-info"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 16 M24 22 L24 34" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-info)"><path d="M12,4 L36,4 L44,12 L44,36 L36,44 L12,44 L4,36 L4,12 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
|
||||
warning: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-warn"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 28 M24 34 L24 36" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-warn)"><path d="M24,4 L46,24 L24,44 L2,24 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
|
||||
alert: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-alert"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 28 M24 34 L24 36" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-alert)"><path d="M24,4 L46,24 L24,44 L2,24 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`
|
||||
}[type];
|
||||
const headerTextContent = document.createElement('div');
|
||||
headerTextContent.style.cssText = `display: flex; flex-direction: column; justify-content: center; flex: 1; min-width: 0;`;
|
||||
const titleSpan = document.createElement('div');
|
||||
titleSpan.style.cssText = `font-weight: 700; font-size: 16px; margin-bottom: 4px; color: #fff; text-transform: uppercase; letter-spacing: 0.5px;`;
|
||||
titleSpan.textContent = config.title;
|
||||
headerTextContent.appendChild(titleSpan);
|
||||
const topRightContainer = document.createElement('div');
|
||||
topRightContainer.style.cssText = `position: absolute; top: 14px; right: 18px; display: flex; align-items: center; gap: 12px;`;
|
||||
const tag = document.createElement('span');
|
||||
tag.style.cssText = `font-size: 11px; font-weight: 600; color: #fff; background: ${config.bg}; border-radius: 4px; padding: 2px 8px; box-shadow: 0 0 8px ${config.bg};`;
|
||||
tag.innerHTML = '🎨 Layer Forge';
|
||||
const getTextColorForBg = (hexColor) => {
|
||||
const r = parseInt(hexColor.slice(1, 3), 16), g = parseInt(hexColor.slice(3, 5), 16), b = parseInt(hexColor.slice(5, 7), 16);
|
||||
return ((0.299 * r + 0.587 * g + 0.114 * b) / 255) > 0.5 ? '#000' : '#fff';
|
||||
};
|
||||
tag.style.color = getTextColorForBg(config.bg);
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.setAttribute("aria-label", "Close notification");
|
||||
closeBtn.style.cssText = `background: none; border: none; color: #ccc; font-size: 22px; font-weight: bold; cursor: pointer; padding: 0; opacity: 0.7; transition: opacity 0.15s; line-height: 1;`;
|
||||
topRightContainer.appendChild(tag);
|
||||
topRightContainer.appendChild(closeBtn);
|
||||
header.appendChild(iconContainer);
|
||||
header.appendChild(headerTextContent);
|
||||
header.appendChild(topRightContainer);
|
||||
// --- Scrollable Body ---
|
||||
const body = document.createElement('div');
|
||||
body.style.cssText = `padding: 0px 20px 16px 20px; overflow-y: auto; flex: 1;`;
|
||||
const msgSpan = document.createElement('div');
|
||||
msgSpan.style.cssText = `font-size: 14px; color: #ccc; line-height: 1.5; white-space: pre-wrap; word-break: break-word;`;
|
||||
msgSpan.textContent = message;
|
||||
body.appendChild(msgSpan);
|
||||
// --- Progress Bar ---
|
||||
const progressBar = document.createElement('div');
|
||||
progressBar.style.cssText = `height: 4px; width: 100%; background: ${config.bg}; box-shadow: 0 0 12px ${config.bg}; transform-origin: left; animation: lf-progress ${duration / 1000}s linear; flex-shrink: 0;`;
|
||||
// --- Assemble Notification ---
|
||||
notification.appendChild(leftBar);
|
||||
notification.appendChild(header);
|
||||
notification.appendChild(body);
|
||||
if (type === 'error') {
|
||||
const footer = document.createElement('div');
|
||||
footer.style.cssText = `padding: 0 20px 12px 86px; flex-shrink: 0;`;
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.textContent = 'Copy Error';
|
||||
copyButton.style.cssText = `background: rgba(255, 111, 111, 0.2); border: 1px solid #ff6f6f; color: #ffafaf; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; transition: background 0.2s;`;
|
||||
copyButton.onmouseenter = () => copyButton.style.background = 'rgba(255, 111, 111, 0.3)';
|
||||
copyButton.onmouseleave = () => copyButton.style.background = 'rgba(255, 111, 111, 0.2)';
|
||||
copyButton.onclick = () => {
|
||||
navigator.clipboard.writeText(message)
|
||||
.then(() => showSuccessNotification("Error message copied!", 2000))
|
||||
.catch(err => console.error('Failed to copy error message: ', err));
|
||||
};
|
||||
footer.appendChild(copyButton);
|
||||
notification.appendChild(footer);
|
||||
}
|
||||
notification.appendChild(progressBar);
|
||||
// Add to DOM
|
||||
container.appendChild(notification);
|
||||
// --- Keyframes and Timer Logic ---
|
||||
const styleSheet = document.getElementById('lf-notification-styles');
|
||||
if (!styleSheet) {
|
||||
const newStyleSheet = document.createElement("style");
|
||||
newStyleSheet.id = 'lf-notification-styles';
|
||||
newStyleSheet.innerText = `
|
||||
@keyframes lf-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
@keyframes lf-progress-rewind { to { transform: scaleX(1); } }
|
||||
@keyframes lf-fadein { from { opacity: 0; transform: scale(0.95) translateX(20px); } to { opacity: 1; transform: scale(1) translateX(0); } }
|
||||
@keyframes lf-fadeout { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } }
|
||||
.notification-scrollbar::-webkit-scrollbar { width: 8px; }
|
||||
.notification-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); border-radius: 4px; }
|
||||
.notification-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.25); border-radius: 4px; }
|
||||
.notification-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.4); }
|
||||
`;
|
||||
document.head.appendChild(newStyleSheet);
|
||||
}
|
||||
body.classList.add('notification-scrollbar');
|
||||
let dismissTimeout = null;
|
||||
const closeNotification = () => {
|
||||
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
|
||||
notification.addEventListener('animationend', () => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
if (container && container.children.length === 0) {
|
||||
container.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
closeBtn.onclick = closeNotification;
|
||||
const startDismissTimer = () => {
|
||||
dismissTimeout = window.setTimeout(closeNotification, duration);
|
||||
progressBar.style.animation = `lf-progress ${duration / 1000}s linear`;
|
||||
};
|
||||
const pauseAndRewindTimer = () => {
|
||||
if (dismissTimeout !== null)
|
||||
clearTimeout(dismissTimeout);
|
||||
dismissTimeout = null;
|
||||
const computedStyle = window.getComputedStyle(progressBar);
|
||||
progressBar.style.transform = computedStyle.transform;
|
||||
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
|
||||
};
|
||||
notification.addEventListener('mouseenter', pauseAndRewindTimer);
|
||||
notification.addEventListener('mouseleave', startDismissTimer);
|
||||
startDismissTimer();
|
||||
log.debug(`Notification shown: [Layer Forge] ${message}`);
|
||||
}
|
||||
/**
|
||||
* Shows a success notification
|
||||
*/
|
||||
export function showSuccessNotification(message, duration = 3000) {
|
||||
showNotification(message, undefined, duration, "success");
|
||||
}
|
||||
/**
|
||||
* Shows an error notification
|
||||
*/
|
||||
export function showErrorNotification(message, duration = 5000) {
|
||||
showNotification(message, undefined, duration, "error");
|
||||
}
|
||||
/**
|
||||
* Shows an info notification
|
||||
*/
|
||||
export function showInfoNotification(message, duration = 3000) {
|
||||
showNotification(message, undefined, duration, "info");
|
||||
}
|
||||
/**
|
||||
* Shows a warning notification
|
||||
*/
|
||||
export function showWarningNotification(message, duration = 3000) {
|
||||
showNotification(message, undefined, duration, "warning");
|
||||
}
|
||||
/**
|
||||
* Shows an alert notification
|
||||
*/
|
||||
export function showAlertNotification(message, duration = 3000) {
|
||||
showNotification(message, undefined, duration, "alert");
|
||||
}
|
||||
/**
|
||||
* Shows a sequence of all notification types for debugging purposes.
|
||||
*/
|
||||
export function showAllNotificationTypes(message) {
|
||||
const types = ["success", "error", "info", "warning", "alert"];
|
||||
types.forEach((type, index) => {
|
||||
const notificationMessage = message || `This is a '${type}' notification.`;
|
||||
setTimeout(() => {
|
||||
showNotification(notificationMessage, undefined, 3000, type);
|
||||
}, index * 400); // Stagger the notifications
|
||||
});
|
||||
}
|
||||
192
js/utils/PreviewUtils.js
Normal file
192
js/utils/PreviewUtils.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
const log = createModuleLogger('PreviewUtils');
|
||||
/**
|
||||
* Creates a preview image from canvas and updates node
|
||||
* @param canvas - Canvas object with canvasLayers
|
||||
* @param node - ComfyUI node to update
|
||||
* @param options - Preview options
|
||||
* @returns Promise with created Image element
|
||||
*/
|
||||
export const createPreviewFromCanvas = withErrorHandling(async function (canvas, node, options = {}) {
|
||||
if (!canvas) {
|
||||
throw createValidationError("Canvas is required", { canvas });
|
||||
}
|
||||
if (!node) {
|
||||
throw createValidationError("Node is required", { node });
|
||||
}
|
||||
const { includeMask = true, updateNodeImages = true, customBlob } = options;
|
||||
log.debug('Creating preview from canvas:', {
|
||||
includeMask,
|
||||
updateNodeImages,
|
||||
hasCustomBlob: !!customBlob,
|
||||
nodeId: node.id
|
||||
});
|
||||
let blob = customBlob || null;
|
||||
// Get blob from canvas if not provided
|
||||
if (!blob) {
|
||||
if (!canvas.canvasLayers) {
|
||||
throw createValidationError("Canvas does not have canvasLayers", { canvas });
|
||||
}
|
||||
if (includeMask && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function') {
|
||||
blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
}
|
||||
else if (typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') {
|
||||
blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
}
|
||||
else {
|
||||
throw createValidationError("Canvas does not support required blob generation methods", {
|
||||
canvas,
|
||||
availableMethods: Object.getOwnPropertyNames(canvas.canvasLayers)
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!blob) {
|
||||
throw createValidationError("Failed to generate canvas blob for preview", { canvas, options });
|
||||
}
|
||||
// Create preview image
|
||||
const previewImage = new Image();
|
||||
previewImage.src = URL.createObjectURL(blob);
|
||||
// Wait for image to load
|
||||
await new Promise((resolve, reject) => {
|
||||
previewImage.onload = () => {
|
||||
log.debug("Preview image loaded successfully", {
|
||||
width: previewImage.width,
|
||||
height: previewImage.height,
|
||||
nodeId: node.id
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
previewImage.onerror = (error) => {
|
||||
log.error("Failed to load preview image", error);
|
||||
reject(createValidationError("Failed to load preview image", { error, blob: blob?.size }));
|
||||
};
|
||||
});
|
||||
// Update node images if requested
|
||||
if (updateNodeImages) {
|
||||
node.imgs = [previewImage];
|
||||
log.debug("Node images updated with new preview");
|
||||
}
|
||||
return previewImage;
|
||||
}, 'createPreviewFromCanvas');
|
||||
/**
|
||||
* Creates a preview image from a blob
|
||||
* @param blob - Image blob
|
||||
* @param node - ComfyUI node to update (optional)
|
||||
* @param updateNodeImages - Whether to update node.imgs (default: false)
|
||||
* @returns Promise with created Image element
|
||||
*/
|
||||
export const createPreviewFromBlob = withErrorHandling(async function (blob, node, updateNodeImages = false) {
|
||||
if (!blob) {
|
||||
throw createValidationError("Blob is required", { blob });
|
||||
}
|
||||
if (blob.size === 0) {
|
||||
throw createValidationError("Blob cannot be empty", { blobSize: blob.size });
|
||||
}
|
||||
log.debug('Creating preview from blob:', {
|
||||
blobSize: blob.size,
|
||||
updateNodeImages,
|
||||
hasNode: !!node
|
||||
});
|
||||
const previewImage = new Image();
|
||||
previewImage.src = URL.createObjectURL(blob);
|
||||
await new Promise((resolve, reject) => {
|
||||
previewImage.onload = () => {
|
||||
log.debug("Preview image from blob loaded successfully", {
|
||||
width: previewImage.width,
|
||||
height: previewImage.height
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
previewImage.onerror = (error) => {
|
||||
log.error("Failed to load preview image from blob", error);
|
||||
reject(createValidationError("Failed to load preview image from blob", { error, blobSize: blob.size }));
|
||||
};
|
||||
});
|
||||
if (updateNodeImages && node) {
|
||||
node.imgs = [previewImage];
|
||||
log.debug("Node images updated with blob preview");
|
||||
}
|
||||
return previewImage;
|
||||
}, 'createPreviewFromBlob');
|
||||
/**
|
||||
* Updates node preview after canvas changes
|
||||
* @param canvas - Canvas object
|
||||
* @param node - ComfyUI node
|
||||
* @param includeMask - Whether to include mask in preview
|
||||
* @returns Promise with updated preview image
|
||||
*/
|
||||
export const updateNodePreview = withErrorHandling(async function (canvas, node, includeMask = true) {
|
||||
if (!canvas) {
|
||||
throw createValidationError("Canvas is required", { canvas });
|
||||
}
|
||||
if (!node) {
|
||||
throw createValidationError("Node is required", { node });
|
||||
}
|
||||
log.info('Updating node preview:', {
|
||||
nodeId: node.id,
|
||||
includeMask
|
||||
});
|
||||
// Trigger canvas render and save state
|
||||
if (typeof canvas.render === 'function') {
|
||||
canvas.render();
|
||||
}
|
||||
if (typeof canvas.saveState === 'function') {
|
||||
canvas.saveState();
|
||||
}
|
||||
// Create new preview
|
||||
const previewImage = await createPreviewFromCanvas(canvas, node, {
|
||||
includeMask,
|
||||
updateNodeImages: true
|
||||
});
|
||||
log.info('Node preview updated successfully');
|
||||
return previewImage;
|
||||
}, 'updateNodePreview');
|
||||
/**
|
||||
* Clears node preview images
|
||||
* @param node - ComfyUI node
|
||||
*/
|
||||
export function clearNodePreview(node) {
|
||||
log.debug('Clearing node preview:', { nodeId: node.id });
|
||||
node.imgs = [];
|
||||
}
|
||||
/**
|
||||
* Checks if node has preview images
|
||||
* @param node - ComfyUI node
|
||||
* @returns True if node has preview images
|
||||
*/
|
||||
export function hasNodePreview(node) {
|
||||
return !!(node.imgs && node.imgs.length > 0 && node.imgs[0].src);
|
||||
}
|
||||
/**
|
||||
* Gets the current preview image from node
|
||||
* @param node - ComfyUI node
|
||||
* @returns Current preview image or null
|
||||
*/
|
||||
export function getCurrentPreview(node) {
|
||||
if (hasNodePreview(node) && node.imgs) {
|
||||
return node.imgs[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Creates a preview with custom processing
|
||||
* @param canvas - Canvas object
|
||||
* @param node - ComfyUI node
|
||||
* @param processor - Custom processing function that takes canvas and returns blob
|
||||
* @returns Promise with processed preview image
|
||||
*/
|
||||
export const createCustomPreview = withErrorHandling(async function (canvas, node, processor) {
|
||||
if (!canvas) {
|
||||
throw createValidationError("Canvas is required", { canvas });
|
||||
}
|
||||
if (!node) {
|
||||
throw createValidationError("Node is required", { node });
|
||||
}
|
||||
if (!processor || typeof processor !== 'function') {
|
||||
throw createValidationError("Processor function is required", { processor });
|
||||
}
|
||||
log.debug('Creating custom preview:', { nodeId: node.id });
|
||||
const blob = await processor(canvas);
|
||||
return createPreviewFromBlob(blob, node, true);
|
||||
}, 'createCustomPreview');
|
||||
@@ -1,6 +1,13 @@
|
||||
// @ts-ignore
|
||||
import { $el } from "../../../scripts/ui.js";
|
||||
export function addStylesheet(url) {
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
|
||||
const log = createModuleLogger('ResourceManager');
|
||||
export const addStylesheet = withErrorHandling(function (url) {
|
||||
if (!url) {
|
||||
throw createValidationError("URL is required", { url });
|
||||
}
|
||||
log.debug('Adding stylesheet:', { url });
|
||||
if (url.endsWith(".js")) {
|
||||
url = url.substr(0, url.length - 2) + "css";
|
||||
}
|
||||
@@ -10,8 +17,12 @@ export function addStylesheet(url) {
|
||||
type: "text/css",
|
||||
href: url.startsWith("http") ? url : getUrl(url),
|
||||
});
|
||||
}
|
||||
log.debug('Stylesheet added successfully:', { finalUrl: url });
|
||||
}, 'addStylesheet');
|
||||
export function getUrl(path, baseUrl) {
|
||||
if (!path) {
|
||||
throw createValidationError("Path is required", { path });
|
||||
}
|
||||
if (baseUrl) {
|
||||
return new URL(path, baseUrl).toString();
|
||||
}
|
||||
@@ -20,11 +31,21 @@ export function getUrl(path, baseUrl) {
|
||||
return new URL("../" + path, import.meta.url).toString();
|
||||
}
|
||||
}
|
||||
export async function loadTemplate(path, baseUrl) {
|
||||
export const loadTemplate = withErrorHandling(async function (path, baseUrl) {
|
||||
if (!path) {
|
||||
throw createValidationError("Path is required", { path });
|
||||
}
|
||||
const url = getUrl(path, baseUrl);
|
||||
log.debug('Loading template:', { path, url });
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load template: ${url}`);
|
||||
throw createNetworkError(`Failed to load template: ${url}`, {
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
});
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
const content = await response.text();
|
||||
log.debug('Template loaded successfully:', { path, contentLength: content.length });
|
||||
return content;
|
||||
}, 'loadTemplate');
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
|
||||
const log = createModuleLogger('WebSocketManager');
|
||||
class WebSocketManager {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
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.connect = withErrorHandling(() => {
|
||||
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;
|
||||
}
|
||||
if (!this.url) {
|
||||
throw createValidationError("WebSocket URL is required", { url: this.url });
|
||||
}
|
||||
this.isConnecting = true;
|
||||
log.info(`Connecting to WebSocket at ${this.url}...`);
|
||||
this.socket = new WebSocket(this.url);
|
||||
this.socket.onopen = () => {
|
||||
this.isConnecting = false;
|
||||
@@ -61,14 +54,71 @@ class WebSocketManager {
|
||||
};
|
||||
this.socket.onerror = (error) => {
|
||||
this.isConnecting = false;
|
||||
log.error("WebSocket error:", error);
|
||||
throw createNetworkError("WebSocket connection error", { error, url: this.url });
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
this.isConnecting = false;
|
||||
log.error("Failed to create WebSocket connection:", error);
|
||||
this.handleReconnect();
|
||||
}
|
||||
}, 'WebSocketManager.connect');
|
||||
this.sendMessage = withErrorHandling(async (data, requiresAck = false) => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw createValidationError("Message data is required", { data });
|
||||
}
|
||||
const nodeId = data.nodeId;
|
||||
if (requiresAck && !nodeId) {
|
||||
throw createValidationError("A nodeId is required for messages that need acknowledgment", { data, requiresAck });
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
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(createNetworkError(`ACK timeout for nodeId ${nodeId}`, { nodeId, timeout: 10000 }));
|
||||
log.warn(`ACK timeout for nodeId ${nodeId}.`);
|
||||
}, 10000); // 10-second timeout
|
||||
this.ackCallbacks.set(nodeId, {
|
||||
resolve: (responseData) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(responseData);
|
||||
},
|
||||
reject: (error) => {
|
||||
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(createNetworkError("Cannot send message with ACK required while disconnected", {
|
||||
socketState: this.socket?.readyState,
|
||||
isConnecting: this.isConnecting
|
||||
}));
|
||||
}
|
||||
else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 'WebSocketManager.sendMessage');
|
||||
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();
|
||||
}
|
||||
handleReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
@@ -80,53 +130,6 @@ class WebSocketManager {
|
||||
log.error("Max reconnect attempts reached. Giving up.");
|
||||
}
|
||||
}
|
||||
sendMessage(data, requiresAck = false) {
|
||||
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) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(responseData);
|
||||
},
|
||||
reject: (error) => {
|
||||
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) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
const log = createModuleLogger('MaskUtils');
|
||||
export function new_editor(app) {
|
||||
if (!app)
|
||||
@@ -125,47 +126,25 @@ export function press_maskeditor_cancel(app) {
|
||||
* @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, maskImage, sendCleanImage = true) {
|
||||
if (!canvasInstance || !maskImage) {
|
||||
log.error('Canvas instance and mask image are required');
|
||||
return;
|
||||
export const start_mask_editor_with_predefined_mask = withErrorHandling(function (canvasInstance, maskImage, sendCleanImage = true) {
|
||||
if (!canvasInstance) {
|
||||
throw createValidationError('Canvas instance is required', { canvasInstance });
|
||||
}
|
||||
if (!maskImage) {
|
||||
throw createValidationError('Mask image is required', { maskImage });
|
||||
}
|
||||
canvasInstance.startMaskEditor(maskImage, sendCleanImage);
|
||||
}
|
||||
}, 'start_mask_editor_with_predefined_mask');
|
||||
/**
|
||||
* Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska)
|
||||
* @param {Canvas} canvasInstance - Instancja Canvas
|
||||
*/
|
||||
export function start_mask_editor_auto(canvasInstance) {
|
||||
export const start_mask_editor_auto = withErrorHandling(function (canvasInstance) {
|
||||
if (!canvasInstance) {
|
||||
log.error('Canvas instance is required');
|
||||
return;
|
||||
throw createValidationError('Canvas instance is required', { canvasInstance });
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
}, 'start_mask_editor_auto');
|
||||
// Duplikowane funkcje zostały przeniesione do ImageUtils.ts:
|
||||
// - create_mask_from_image_src -> createMaskFromImageSrc
|
||||
// - canvas_to_mask_image -> canvasToMaskImage
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
[project]
|
||||
name = "layerforge"
|
||||
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
||||
version = "1.3.8"
|
||||
license = {file = "LICENSE"}
|
||||
version = "1.5.1"
|
||||
license = { text = "MIT License" }
|
||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/Azornes/Comfyui-LayerForge"
|
||||
# Used by Comfy Registry https://registry.comfy.org
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "azornes"
|
||||
DisplayName = "Comfyui-LayerForge"
|
||||
Icon = ""
|
||||
includes = []
|
||||
Icon = ""
|
||||
@@ -169,7 +169,10 @@ export class BatchPreviewManager {
|
||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.remove('primary');
|
||||
toggleBtn.textContent = "Hide Mask";
|
||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container') as HTMLElement;
|
||||
if (iconContainer) {
|
||||
iconContainer.style.opacity = '0.5';
|
||||
}
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
@@ -191,6 +194,11 @@ export class BatchPreviewManager {
|
||||
this.worldY += paddingInWorld;
|
||||
}
|
||||
|
||||
// Hide all batch layers initially, then show only the first one
|
||||
this.layers.forEach((layer: Layer) => {
|
||||
layer.visible = false;
|
||||
});
|
||||
|
||||
this._update();
|
||||
}
|
||||
|
||||
@@ -213,12 +221,24 @@ export class BatchPreviewManager {
|
||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.add('primary');
|
||||
toggleBtn.textContent = "Show Mask";
|
||||
const iconContainer = toggleBtn.querySelector('.mask-icon-container') as HTMLElement;
|
||||
if (iconContainer) {
|
||||
iconContainer.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
this.maskWasVisible = false;
|
||||
|
||||
this.canvas.layers.forEach((l: Layer) => (l as any).visible = true);
|
||||
// Only make visible the layers that were part of the batch preview
|
||||
this.layers.forEach((layer: Layer) => {
|
||||
layer.visible = true;
|
||||
});
|
||||
|
||||
// Update the layers panel to reflect visibility changes
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
@@ -264,14 +284,27 @@ export class BatchPreviewManager {
|
||||
|
||||
private _focusOnLayer(layer: Layer): void {
|
||||
if (!layer) return;
|
||||
log.debug(`Focusing on layer ${layer.id}`);
|
||||
log.debug(`Focusing on layer ${layer.id} using visibility toggle`);
|
||||
|
||||
// Move the selected layer to the top of the layer stack
|
||||
this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 });
|
||||
// Hide all batch layers first
|
||||
this.layers.forEach((l: Layer) => {
|
||||
l.visible = false;
|
||||
});
|
||||
|
||||
// Show only the current layer
|
||||
layer.visible = true;
|
||||
|
||||
// Deselect only this layer if it is selected
|
||||
const selected = this.canvas.canvasSelection.selectedLayers;
|
||||
if (selected && selected.includes(layer)) {
|
||||
this.canvas.updateSelection(selected.filter((l: Layer) => l !== layer));
|
||||
}
|
||||
|
||||
// Update the layers panel to reflect visibility changes
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
|
||||
this.canvas.updateSelection([layer]);
|
||||
|
||||
// Render is called by moveLayers, but we call it again to be safe
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
|
||||
118
src/Canvas.ts
118
src/Canvas.ts
@@ -7,6 +7,8 @@ import {ComfyApp} from "../../scripts/app.js";
|
||||
|
||||
import {removeImage} from "./db.js";
|
||||
import {MaskTool} from "./MaskTool.js";
|
||||
import {ShapeTool} from "./ShapeTool.js";
|
||||
import {CustomShapeMenu} from "./CustomShapeMenu.js";
|
||||
import {CanvasState} from "./CanvasState.js";
|
||||
import {CanvasInteractions} from "./CanvasInteractions.js";
|
||||
import {CanvasLayers} from "./CanvasLayers.js";
|
||||
@@ -16,10 +18,10 @@ 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 { debounce, createCanvas } from "./utils/CommonUtils.js";
|
||||
import {MaskEditorIntegration} from "./MaskEditorIntegration.js";
|
||||
import {CanvasSelection} from "./CanvasSelection.js";
|
||||
import type { ComfyNode, Layer, Viewport, Point, AddMode } from './types';
|
||||
import type { ComfyNode, Layer, Viewport, Point, AddMode, Shape, OutputAreaBounds } from './types';
|
||||
|
||||
const useChainCallback = (original: any, next: any) => {
|
||||
if (original === undefined || original === null) {
|
||||
@@ -45,11 +47,12 @@ const log = createModuleLogger('Canvas');
|
||||
export class Canvas {
|
||||
batchPreviewManagers: BatchPreviewManager[];
|
||||
canvas: HTMLCanvasElement;
|
||||
canvasContainer: HTMLDivElement | null;
|
||||
canvasIO: CanvasIO;
|
||||
canvasInteractions: CanvasInteractions;
|
||||
canvasLayers: CanvasLayers;
|
||||
canvasLayersPanel: CanvasLayersPanel;
|
||||
canvasMask: CanvasMask;
|
||||
maskEditorIntegration: MaskEditorIntegration;
|
||||
canvasRenderer: CanvasRenderer;
|
||||
canvasSelection: CanvasSelection;
|
||||
canvasState: CanvasState;
|
||||
@@ -63,10 +66,26 @@ export class Canvas {
|
||||
lastMousePosition: Point;
|
||||
layers: Layer[];
|
||||
maskTool: MaskTool;
|
||||
shapeTool: ShapeTool;
|
||||
customShapeMenu: CustomShapeMenu;
|
||||
outputAreaShape: Shape | null;
|
||||
autoApplyShapeMask: boolean;
|
||||
shapeMaskExpansion: boolean;
|
||||
shapeMaskExpansionValue: number;
|
||||
shapeMaskFeather: boolean;
|
||||
shapeMaskFeatherValue: number;
|
||||
outputAreaExtensions: { top: number, bottom: number, left: number, right: number };
|
||||
outputAreaExtensionEnabled: boolean;
|
||||
outputAreaExtensionPreview: { top: number, bottom: number, left: number, right: number } | null;
|
||||
lastOutputAreaExtensions: { top: number, bottom: number, left: number, right: number };
|
||||
originalCanvasSize: { width: number, height: number };
|
||||
originalOutputAreaPosition: { x: number, y: number };
|
||||
outputAreaBounds: OutputAreaBounds;
|
||||
node: ComfyNode;
|
||||
offscreenCanvas: HTMLCanvasElement;
|
||||
offscreenCtx: CanvasRenderingContext2D | null;
|
||||
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
|
||||
onViewportChange: (() => void) | null;
|
||||
onStateChange: (() => void) | undefined;
|
||||
pendingBatchContext: any;
|
||||
pendingDataCheck: number | null;
|
||||
@@ -79,35 +98,60 @@ export class Canvas {
|
||||
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});
|
||||
const { canvas, ctx } = createCanvas(0, 0, '2d', {willReadFrequently: true});
|
||||
if (!ctx) throw new Error("Could not create canvas context");
|
||||
this.canvas = canvas;
|
||||
this.ctx = ctx;
|
||||
this.width = 512;
|
||||
this.height = 512;
|
||||
this.layers = [];
|
||||
this.onStateChange = callbacks.onStateChange;
|
||||
this.onHistoryChange = callbacks.onHistoryChange;
|
||||
this.onViewportChange = null;
|
||||
this.lastMousePosition = {x: 0, y: 0};
|
||||
|
||||
this.viewport = {
|
||||
x: -(this.width / 4),
|
||||
y: -(this.height / 4),
|
||||
x: -(this.width / 1.5),
|
||||
y: -(this.height / 2),
|
||||
zoom: 0.8,
|
||||
};
|
||||
|
||||
this.offscreenCanvas = document.createElement('canvas');
|
||||
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
|
||||
alpha: false
|
||||
const { canvas: offscreenCanvas, ctx: offscreenCtx } = createCanvas(0, 0, '2d', {
|
||||
alpha: false,
|
||||
willReadFrequently: true
|
||||
});
|
||||
this.offscreenCanvas = offscreenCanvas;
|
||||
this.offscreenCtx = offscreenCtx;
|
||||
this.canvasContainer = null;
|
||||
|
||||
this.dataInitialized = false;
|
||||
this.pendingDataCheck = null;
|
||||
this.imageCache = new Map();
|
||||
|
||||
this.requestSaveState = () => {};
|
||||
this.outputAreaShape = null;
|
||||
this.autoApplyShapeMask = false;
|
||||
this.shapeMaskExpansion = false;
|
||||
this.shapeMaskExpansionValue = 0;
|
||||
this.shapeMaskFeather = false;
|
||||
this.shapeMaskFeatherValue = 0;
|
||||
this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
this.outputAreaExtensionEnabled = false;
|
||||
this.outputAreaExtensionPreview = null;
|
||||
this.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
this.originalCanvasSize = { width: this.width, height: this.height };
|
||||
this.originalOutputAreaPosition = { x: -(this.width / 4), y: -(this.height / 4) };
|
||||
// Initialize outputAreaBounds centered in viewport, similar to how canvas resize/move work
|
||||
this.outputAreaBounds = {
|
||||
x: -(this.width / 4),
|
||||
y: -(this.height / 4),
|
||||
width: this.width,
|
||||
height: this.height
|
||||
};
|
||||
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
|
||||
this.canvasMask = new CanvasMask(this);
|
||||
this.shapeTool = new ShapeTool(this);
|
||||
this.customShapeMenu = new CustomShapeMenu(this);
|
||||
this.maskEditorIntegration = new MaskEditorIntegration(this);
|
||||
this.canvasState = new CanvasState(this);
|
||||
this.canvasSelection = new CanvasSelection(this);
|
||||
this.canvasInteractions = new CanvasInteractions(this);
|
||||
@@ -135,7 +179,6 @@ export class Canvas {
|
||||
this.previewVisible = false;
|
||||
}
|
||||
|
||||
|
||||
async waitForWidget(name: any, node: any, interval = 100, timeout = 20000) {
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -155,7 +198,6 @@ export class Canvas {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Kontroluje widoczność podglądu canvas
|
||||
* @param {boolean} visible - Czy podgląd ma być widoczny
|
||||
@@ -232,7 +274,6 @@ export class Canvas {
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Ładuje stan canvas z bazy danych
|
||||
*/
|
||||
@@ -284,7 +325,6 @@ export class Canvas {
|
||||
log.debug('Undo completed, layers count:', this.layers.length);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Ponów cofniętą operację
|
||||
*/
|
||||
@@ -357,13 +397,6 @@ export class Canvas {
|
||||
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.
|
||||
@@ -380,6 +413,10 @@ export class Canvas {
|
||||
return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||
}
|
||||
|
||||
defineOutputAreaWithShape(shape: Shape): void {
|
||||
this.canvasInteractions.defineOutputAreaWithShape(shape);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zmienia rozmiar obszaru wyjściowego
|
||||
* @param {number} width - Nowa szerokość
|
||||
@@ -387,7 +424,20 @@ export class Canvas {
|
||||
* @param {boolean} saveHistory - Czy zapisać w historii
|
||||
*/
|
||||
updateOutputAreaSize(width: number, height: number, saveHistory = true) {
|
||||
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
|
||||
const result = this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
|
||||
|
||||
// Update mask canvas to ensure it covers the new output area
|
||||
this.maskTool.updateMaskCanvasForOutputArea();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustawia nowy rozmiar output area zgodnie z nowym systemem (resetuje rozszerzenia, pozycję, rozmiar)
|
||||
* (Fasada: deleguje do CanvasLayers)
|
||||
*/
|
||||
setOutputAreaSize(width: number, height: number) {
|
||||
this.canvasLayers.setOutputAreaSize(width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -425,17 +475,17 @@ export class Canvas {
|
||||
lastExecutionStartTime = Date.now();
|
||||
// Store a snapshot of the context for the upcoming batch
|
||||
this.pendingBatchContext = {
|
||||
// For the menu position
|
||||
// For the menu position - position relative to outputAreaBounds, not canvas center
|
||||
spawnPosition: {
|
||||
x: this.width / 2,
|
||||
y: this.height
|
||||
x: this.outputAreaBounds.x + this.outputAreaBounds.width / 2,
|
||||
y: this.outputAreaBounds.y + this.outputAreaBounds.height
|
||||
},
|
||||
// For the image placement
|
||||
// For the image placement - use actual outputAreaBounds instead of hardcoded (0,0)
|
||||
outputArea: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.width,
|
||||
height: this.height
|
||||
x: this.outputAreaBounds.x,
|
||||
y: this.outputAreaBounds.y,
|
||||
width: this.outputAreaBounds.width,
|
||||
height: this.outputAreaBounds.height
|
||||
}
|
||||
};
|
||||
log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext);
|
||||
@@ -487,17 +537,15 @@ export class Canvas {
|
||||
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);
|
||||
return this.maskEditorIntegration.startMaskEditor(predefinedMask as any, sendCleanImage);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Inicjalizuje podstawowe właściwości canvas
|
||||
*/
|
||||
|
||||
365
src/CanvasIO.ts
365
src/CanvasIO.ts
@@ -1,8 +1,9 @@
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||
import { webSocketManager } from "./utils/WebSocketManager.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer } from './types';
|
||||
import type { Layer, Shape } from './types';
|
||||
|
||||
const log = createModuleLogger('CanvasIO');
|
||||
|
||||
@@ -61,10 +62,10 @@ export class CanvasIO {
|
||||
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 });
|
||||
const originalShape = this.canvas.outputAreaShape;
|
||||
this.canvas.outputAreaShape = null;
|
||||
|
||||
const { canvas: visibilityCanvas, ctx: visibilityCtx } = createCanvas(this.canvas.width, this.canvas.height, '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");
|
||||
@@ -75,7 +76,6 @@ export class CanvasIO {
|
||||
|
||||
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);
|
||||
@@ -87,57 +87,43 @@ export class CanvasIO {
|
||||
}
|
||||
|
||||
maskCtx.putImageData(maskData, 0, 0);
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||
|
||||
this.canvas.outputAreaShape = originalShape;
|
||||
|
||||
// Use optimized getMaskForOutputArea() instead of getMask() for better performance
|
||||
// This only processes chunks that overlap with the output area
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea();
|
||||
if (toolMaskCanvas) {
|
||||
log.debug(`Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height}) instead of full mask`);
|
||||
|
||||
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
|
||||
);
|
||||
// The optimized mask is already sized and positioned for the output area
|
||||
// So we can draw it directly without complex positioning calculations
|
||||
const tempMaskData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height);
|
||||
if (tempMaskData) {
|
||||
// Ensure the mask data is in the correct format (white with alpha)
|
||||
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;
|
||||
}
|
||||
|
||||
// Create a temporary canvas to hold the processed mask
|
||||
const { canvas: tempMaskCanvas, ctx: tempMaskCtx } = createCanvas(this.canvas.width, this.canvas.height, '2d', { willReadFrequently: true });
|
||||
if (!tempMaskCtx) throw new Error("Could not create temp mask context");
|
||||
|
||||
// Put the processed mask data into a canvas that matches the output area size
|
||||
const { canvas: outputMaskCanvas, ctx: outputMaskCtx } = createCanvas(toolMaskCanvas.width, toolMaskCanvas.height, '2d', { willReadFrequently: true });
|
||||
if (!outputMaskCtx) throw new Error("Could not create output mask context");
|
||||
|
||||
outputMaskCtx.putImageData(tempMaskData, 0, 0);
|
||||
|
||||
// Draw the optimized mask at the correct position (output area bounds)
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
tempMaskCtx.drawImage(outputMaskCanvas, bounds.x, bounds.y);
|
||||
|
||||
maskCtx.globalCompositeOperation = 'source-over';
|
||||
maskCtx.drawImage(tempMaskCanvas, 0, 0);
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -229,84 +215,38 @@ export class CanvasIO {
|
||||
}
|
||||
|
||||
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});
|
||||
log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
|
||||
|
||||
// Użyj zunifikowanych funkcji z CanvasLayers
|
||||
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
|
||||
|
||||
if (!imageBlob || !maskBlob) {
|
||||
throw new Error("Failed to generate canvas or mask blobs");
|
||||
}
|
||||
|
||||
// Konwertuj blob na data URL
|
||||
const imageDataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(imageBlob);
|
||||
});
|
||||
|
||||
const maskDataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(maskBlob);
|
||||
});
|
||||
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
log.info(`=== OUTPUT DATA GENERATED ===`);
|
||||
log.info(`Image size: ${bounds.width}x${bounds.height}`);
|
||||
log.info(`Image data URL length: ${imageDataUrl.length}`);
|
||||
log.info(`Mask data URL length: ${maskDataUrl.length}`);
|
||||
|
||||
return { image: imageDataUrl, mask: maskDataUrl };
|
||||
}
|
||||
|
||||
async sendDataViaWebSocket(nodeId: number): Promise<boolean> {
|
||||
@@ -354,14 +294,15 @@ export class CanvasIO {
|
||||
image.src = tempCanvas.toDataURL();
|
||||
});
|
||||
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const scale = Math.min(
|
||||
this.canvas.width / inputImage.width * 0.8,
|
||||
this.canvas.height / inputImage.height * 0.8
|
||||
bounds.width / inputImage.width * 0.8,
|
||||
bounds.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,
|
||||
x: bounds.x + (bounds.width - inputImage.width * scale) / 2,
|
||||
y: bounds.y + (bounds.height - inputImage.height * scale) / 2,
|
||||
width: inputImage.width * scale,
|
||||
height: inputImage.height * scale,
|
||||
});
|
||||
@@ -387,11 +328,8 @@ export class CanvasIO {
|
||||
throw new Error("Invalid tensor data");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '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),
|
||||
@@ -536,7 +474,8 @@ export class CanvasIO {
|
||||
originalWidth: image.width,
|
||||
originalHeight: image.height,
|
||||
blendMode: 'normal',
|
||||
opacity: 1
|
||||
opacity: 1,
|
||||
visible: true
|
||||
};
|
||||
|
||||
this.canvas.layers.push(layer);
|
||||
@@ -599,10 +538,7 @@ export class CanvasIO {
|
||||
|
||||
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 });
|
||||
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
|
||||
if (!ctx) throw new Error("Could not create canvas context");
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
@@ -613,21 +549,6 @@ export class CanvasIO {
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -653,83 +574,6 @@ export class CanvasIO {
|
||||
}
|
||||
}
|
||||
|
||||
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...");
|
||||
@@ -753,7 +597,7 @@ export class CanvasIO {
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error("Error importing latest image:", error);
|
||||
alert(`Failed to import latest image: ${error.message}`);
|
||||
showErrorNotification(`Failed to import latest image: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -775,7 +619,15 @@ export class CanvasIO {
|
||||
img.onerror = reject;
|
||||
img.src = imageData;
|
||||
});
|
||||
const newLayer = await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit', targetArea);
|
||||
|
||||
let processedImage = img;
|
||||
|
||||
// If there's a custom shape, clip the image to that shape
|
||||
if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) {
|
||||
processedImage = await this.clipImageToShape(img, this.canvas.outputAreaShape);
|
||||
}
|
||||
|
||||
const newLayer = await this.canvas.canvasLayers.addLayerWithImage(processedImage, {}, 'fit', targetArea);
|
||||
newLayers.push(newLayer);
|
||||
}
|
||||
log.info("All new images imported and placed on canvas successfully.");
|
||||
@@ -789,8 +641,43 @@ export class CanvasIO {
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error("Error importing latest images:", error);
|
||||
alert(`Failed to import latest images: ${error.message}`);
|
||||
showErrorNotification(`Failed to import latest images: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async clipImageToShape(image: HTMLImageElement, shape: Shape): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { canvas, ctx } = createCanvas(image.width, image.height);
|
||||
if (!ctx) {
|
||||
reject(new Error("Could not create canvas context for clipping"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Draw the image first
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
// Calculate custom shape position accounting for extensions
|
||||
// Custom shape should maintain its relative position within the original canvas area
|
||||
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
|
||||
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
|
||||
|
||||
// Create a clipping mask using the shape with extension offset
|
||||
ctx.globalCompositeOperation = 'destination-in';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
|
||||
for (let i = 1; i < shape.points.length; i++) {
|
||||
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Create a new image from the clipped canvas
|
||||
const clippedImage = new Image();
|
||||
clippedImage.onload = () => resolve(clippedImage);
|
||||
clippedImage.onerror = () => reject(new Error("Failed to create clipped image"));
|
||||
clippedImage.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@ import type { Layer, Point } from './types';
|
||||
|
||||
const log = createModuleLogger('CanvasInteractions');
|
||||
|
||||
interface MouseCoordinates {
|
||||
world: Point;
|
||||
view: Point;
|
||||
}
|
||||
|
||||
interface InteractionState {
|
||||
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag';
|
||||
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag' | 'drawingShape';
|
||||
panStart: Point;
|
||||
dragStart: Point;
|
||||
transformOrigin: Partial<Layer> & { centerX?: number, centerY?: number };
|
||||
@@ -15,6 +20,8 @@ interface InteractionState {
|
||||
canvasResizeStart: Point;
|
||||
isCtrlPressed: boolean;
|
||||
isAltPressed: boolean;
|
||||
isShiftPressed: boolean;
|
||||
isSPressed: boolean;
|
||||
hasClonedInDrag: boolean;
|
||||
lastClickTime: number;
|
||||
transformingLayer: Layer | null;
|
||||
@@ -40,6 +47,8 @@ export class CanvasInteractions {
|
||||
canvasResizeStart: { x: 0, y: 0 },
|
||||
isCtrlPressed: false,
|
||||
isAltPressed: false,
|
||||
isShiftPressed: false,
|
||||
isSPressed: false,
|
||||
hasClonedInDrag: false,
|
||||
lastClickTime: 0,
|
||||
transformingLayer: null,
|
||||
@@ -50,6 +59,52 @@ export class CanvasInteractions {
|
||||
this.originalLayerPositions = new Map();
|
||||
}
|
||||
|
||||
// Helper functions to eliminate code duplication
|
||||
|
||||
private getMouseCoordinates(e: MouseEvent | WheelEvent): MouseCoordinates {
|
||||
return {
|
||||
world: this.canvas.getMouseWorldCoordinates(e),
|
||||
view: this.canvas.getMouseViewCoordinates(e)
|
||||
};
|
||||
}
|
||||
|
||||
private preventEventDefaults(e: Event): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
private performZoomOperation(worldCoords: Point, zoomFactor: number): void {
|
||||
const rect = this.canvas.canvas.getBoundingClientRect();
|
||||
const mouseBufferX = (worldCoords.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const mouseBufferY = (worldCoords.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
|
||||
const newZoom = Math.max(0.1, Math.min(10, this.canvas.viewport.zoom * zoomFactor));
|
||||
|
||||
this.canvas.viewport.zoom = 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.onViewportChange?.();
|
||||
}
|
||||
|
||||
private renderAndSave(shouldSave: boolean = false): void {
|
||||
this.canvas.render();
|
||||
if (shouldSave) {
|
||||
this.canvas.saveState();
|
||||
this.canvas.canvasState.saveStateToDB();
|
||||
}
|
||||
}
|
||||
|
||||
private setDragDropStyling(active: boolean): void {
|
||||
if (active) {
|
||||
this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)';
|
||||
this.canvas.canvas.style.border = '2px dashed #2d5aa0';
|
||||
} else {
|
||||
this.canvas.canvas.style.backgroundColor = '';
|
||||
this.canvas.canvas.style.border = '';
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners(): void {
|
||||
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this) as EventListener);
|
||||
@@ -59,6 +114,9 @@ export class CanvasInteractions {
|
||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this) as EventListener);
|
||||
|
||||
// Add a blur event listener to the window to reset key states
|
||||
window.addEventListener('blur', this.handleBlur.bind(this));
|
||||
|
||||
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
||||
|
||||
this.canvas.canvas.addEventListener('mouseenter', (e: MouseEvent) => {
|
||||
@@ -78,6 +136,33 @@ export class CanvasInteractions {
|
||||
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this) as EventListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdza czy punkt znajduje się w obszarze któregokolwiek z zaznaczonych layerów
|
||||
*/
|
||||
isPointInSelectedLayers(worldX: number, worldY: number): boolean {
|
||||
for (const layer of this.canvas.canvasSelection.selectedLayers) {
|
||||
if (!layer.visible) continue;
|
||||
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
|
||||
// Przekształć punkt do lokalnego układu współrzędnych layera
|
||||
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);
|
||||
|
||||
// Sprawdź czy punkt jest wewnątrz prostokąta layera
|
||||
if (Math.abs(rotatedX) <= layer.width / 2 &&
|
||||
Math.abs(rotatedY) <= layer.height / 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
resetInteractionState(): void {
|
||||
this.interaction.mode = 'none';
|
||||
this.interaction.resizeHandle = null;
|
||||
@@ -91,33 +176,49 @@ export class CanvasInteractions {
|
||||
|
||||
handleMouseDown(e: MouseEvent): void {
|
||||
this.canvas.canvas.focus();
|
||||
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
|
||||
this.canvas.maskTool.handleMouseDown(coords.world, coords.view);
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.canvas.shapeTool.isActive) {
|
||||
this.canvas.shapeTool.addPoint(coords.world);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||
|
||||
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||
if (e.shiftKey && e.ctrlKey) {
|
||||
this.startCanvasMove(worldCoords);
|
||||
this.startCanvasMove(coords.world);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
this.startCanvasResize(worldCoords);
|
||||
// Clear custom shape when starting canvas resize
|
||||
if (this.canvas.outputAreaShape) {
|
||||
// If auto-apply shape mask is enabled, remove the mask before clearing the shape
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
log.info("Removing shape mask before clearing custom shape for canvas resize");
|
||||
this.canvas.maskTool.removeShapeMask();
|
||||
}
|
||||
this.canvas.outputAreaShape = null;
|
||||
this.canvas.render();
|
||||
}
|
||||
this.startCanvasResize(coords.world);
|
||||
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);
|
||||
this.preventEventDefaults(e);
|
||||
|
||||
// Sprawdź czy kliknięto w obszarze któregokolwiek z zaznaczonych layerów (niezależnie od przykrycia)
|
||||
if (this.isPointInSelectedLayers(coords.world.x, coords.world.y)) {
|
||||
// Nowa logika przekazuje tylko współrzędne świata, menu pozycjonuje się samo
|
||||
this.canvas.canvasLayers.showBlendModeMenu(coords.world.x, coords.world.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -127,15 +228,15 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
// 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(coords.world.x, coords.world.y);
|
||||
if (transformTarget) {
|
||||
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
|
||||
this.startLayerTransform(transformTarget.layer, transformTarget.handle, coords.world);
|
||||
return;
|
||||
}
|
||||
|
||||
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
||||
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(coords.world.x, coords.world.y);
|
||||
if (clickedLayerResult) {
|
||||
this.prepareForDrag(clickedLayerResult.layer, worldCoords);
|
||||
this.prepareForDrag(clickedLayerResult.layer, coords.world);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -144,14 +245,13 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
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
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
this.canvas.lastMousePosition = coords.world; // 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;
|
||||
const dx = coords.world.x - this.interaction.dragStart.x;
|
||||
const dy = coords.world.y - this.interaction.dragStart.y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli
|
||||
this.interaction.mode = 'dragging';
|
||||
this.originalLayerPositions.clear();
|
||||
@@ -163,37 +263,43 @@ export class CanvasInteractions {
|
||||
|
||||
switch (this.interaction.mode) {
|
||||
case 'drawingMask':
|
||||
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
|
||||
this.canvas.maskTool.handleMouseMove(coords.world, coords.view);
|
||||
this.canvas.render();
|
||||
break;
|
||||
case 'panning':
|
||||
this.panViewport(e);
|
||||
break;
|
||||
case 'dragging':
|
||||
this.dragLayers(worldCoords);
|
||||
this.dragLayers(coords.world);
|
||||
break;
|
||||
case 'resizing':
|
||||
this.resizeLayerFromHandle(worldCoords, e.shiftKey);
|
||||
this.resizeLayerFromHandle(coords.world, e.shiftKey);
|
||||
break;
|
||||
case 'rotating':
|
||||
this.rotateLayerFromHandle(worldCoords, e.shiftKey);
|
||||
this.rotateLayerFromHandle(coords.world, e.shiftKey);
|
||||
break;
|
||||
case 'resizingCanvas':
|
||||
this.updateCanvasResize(worldCoords);
|
||||
this.updateCanvasResize(coords.world);
|
||||
break;
|
||||
case 'movingCanvas':
|
||||
this.updateCanvasMove(worldCoords);
|
||||
this.updateCanvasMove(coords.world);
|
||||
break;
|
||||
default:
|
||||
this.updateCursor(worldCoords);
|
||||
this.updateCursor(coords.world);
|
||||
break;
|
||||
}
|
||||
|
||||
// --- DYNAMICZNY PODGLĄD LINII CUSTOM SHAPE ---
|
||||
if (this.canvas.shapeTool.isActive && !this.canvas.shapeTool.shape.isClosed) {
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseUp(e: MouseEvent): void {
|
||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseUp(viewCoords);
|
||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
@@ -205,25 +311,46 @@ export class CanvasInteractions {
|
||||
this.finalizeCanvasMove();
|
||||
}
|
||||
|
||||
// Log layer positions when dragging ends
|
||||
if (this.interaction.mode === 'dragging' && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
this.logDragCompletion(coords);
|
||||
}
|
||||
|
||||
// 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.renderAndSave(true);
|
||||
}
|
||||
|
||||
this.resetInteractionState();
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
private logDragCompletion(coords: MouseCoordinates): void {
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
log.info("=== LAYER DRAG COMPLETED ===");
|
||||
log.info(`Mouse position: world(${coords.world.x.toFixed(1)}, ${coords.world.y.toFixed(1)}) view(${coords.view.x.toFixed(1)}, ${coords.view.y.toFixed(1)})`);
|
||||
log.info(`Output Area Bounds: x=${bounds.x}, y=${bounds.y}, w=${bounds.width}, h=${bounds.height}`);
|
||||
log.info(`Viewport: x=${this.canvas.viewport.x.toFixed(1)}, y=${this.canvas.viewport.y.toFixed(1)}, zoom=${this.canvas.viewport.zoom.toFixed(2)}`);
|
||||
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer, index: number) => {
|
||||
const relativeToOutput = {
|
||||
x: layer.x - bounds.x,
|
||||
y: layer.y - bounds.y
|
||||
};
|
||||
log.info(`Layer ${index + 1} "${layer.name}": world(${layer.x.toFixed(1)}, ${layer.y.toFixed(1)}) relative_to_output(${relativeToOutput.x.toFixed(1)}, ${relativeToOutput.y.toFixed(1)}) size(${layer.width.toFixed(1)}x${layer.height.toFixed(1)})`);
|
||||
});
|
||||
log.info("=== END LAYER DRAG ===");
|
||||
}
|
||||
|
||||
handleMouseLeave(e: MouseEvent): void {
|
||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
this.canvas.maskTool.handleMouseLeave();
|
||||
if (this.canvas.maskTool.isDrawing) {
|
||||
this.canvas.maskTool.handleMouseUp(viewCoords);
|
||||
this.canvas.maskTool.handleMouseUp(coords.view);
|
||||
}
|
||||
this.canvas.render();
|
||||
return;
|
||||
@@ -246,110 +373,124 @@ export class CanvasInteractions {
|
||||
}
|
||||
|
||||
handleContextMenu(e: MouseEvent): void {
|
||||
// Always prevent browser context menu - we handle all right-click interactions ourselves
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
this.preventEventDefaults(e);
|
||||
const coords = this.getMouseCoordinates(e);
|
||||
|
||||
if (this.canvas.maskTool.isActive || this.canvas.canvasSelection.selectedLayers.length === 0) {
|
||||
// Zoom operation for mask tool or when no layers selected
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.performZoomOperation(coords.world, zoomFactor);
|
||||
} 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);
|
||||
// Layer transformation when layers are selected
|
||||
this.handleLayerWheelTransformation(e);
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
if (!this.canvas.maskTool.isActive) {
|
||||
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
|
||||
this.canvas.requestSaveState();
|
||||
}
|
||||
}
|
||||
|
||||
private handleLayerWheelTransformation(e: WheelEvent): void {
|
||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||
const direction = e.deltaY < 0 ? 1 : -1;
|
||||
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||
if (e.shiftKey) {
|
||||
this.handleLayerRotation(layer, e.ctrlKey, direction, rotationStep);
|
||||
} else {
|
||||
this.handleLayerScaling(layer, e.ctrlKey, e.deltaY);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleLayerRotation(layer: Layer, isCtrlPressed: boolean, direction: number, rotationStep: number): void {
|
||||
if (isCtrlPressed) {
|
||||
// Snap to absolute values
|
||||
const snapAngle = 5;
|
||||
if (direction > 0) {
|
||||
layer.rotation = Math.ceil((layer.rotation + 0.1) / snapAngle) * snapAngle;
|
||||
} else {
|
||||
layer.rotation = Math.floor((layer.rotation - 0.1) / snapAngle) * snapAngle;
|
||||
}
|
||||
} else {
|
||||
// Fixed step rotation
|
||||
layer.rotation += rotationStep;
|
||||
}
|
||||
}
|
||||
|
||||
private handleLayerScaling(layer: Layer, isCtrlPressed: boolean, deltaY: number): void {
|
||||
const oldWidth = layer.width;
|
||||
const oldHeight = layer.height;
|
||||
let scaleFactor;
|
||||
|
||||
if (isCtrlPressed) {
|
||||
const direction = deltaY > 0 ? -1 : 1;
|
||||
const baseDimension = Math.max(layer.width, layer.height);
|
||||
const newBaseDimension = baseDimension + direction;
|
||||
if (newBaseDimension < 10) return;
|
||||
scaleFactor = newBaseDimension / baseDimension;
|
||||
} else {
|
||||
scaleFactor = this.calculateGridBasedScaling(oldHeight, deltaY);
|
||||
}
|
||||
|
||||
if (scaleFactor && isFinite(scaleFactor)) {
|
||||
layer.width *= scaleFactor;
|
||||
layer.height *= scaleFactor;
|
||||
layer.x += (oldWidth - layer.width) / 2;
|
||||
layer.y += (oldHeight - layer.height) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
private calculateGridBasedScaling(oldHeight: number, deltaY: number): number {
|
||||
const gridSize = 64;
|
||||
const direction = 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 0;
|
||||
}
|
||||
|
||||
return targetHeight / oldHeight;
|
||||
}
|
||||
|
||||
handleKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
|
||||
if (e.key === 'Shift') this.interaction.isShiftPressed = true;
|
||||
if (e.key === 'Alt') {
|
||||
this.interaction.isAltPressed = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
if (e.key.toLowerCase() === 's') {
|
||||
this.interaction.isSPressed = true;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Check if Shift+S is being held down
|
||||
if (this.interaction.isShiftPressed && this.interaction.isSPressed && !this.interaction.isCtrlPressed && !this.canvas.shapeTool.isActive) {
|
||||
this.canvas.shapeTool.activate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Globalne skróty (Undo/Redo/Copy/Paste)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
let handled = true;
|
||||
@@ -417,7 +558,14 @@ export class CanvasInteractions {
|
||||
|
||||
handleKeyUp(e: KeyboardEvent): void {
|
||||
if (e.key === 'Control') this.interaction.isCtrlPressed = false;
|
||||
if (e.key === 'Shift') this.interaction.isShiftPressed = false;
|
||||
if (e.key === 'Alt') this.interaction.isAltPressed = false;
|
||||
if (e.key.toLowerCase() === 's') this.interaction.isSPressed = false;
|
||||
|
||||
// Deactivate shape tool when Shift or S is released
|
||||
if (this.canvas.shapeTool.isActive && (!this.interaction.isShiftPressed || !this.interaction.isSPressed)) {
|
||||
this.canvas.shapeTool.deactivate();
|
||||
}
|
||||
|
||||
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
||||
if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) {
|
||||
@@ -426,6 +574,33 @@ export class CanvasInteractions {
|
||||
}
|
||||
}
|
||||
|
||||
handleBlur(): void {
|
||||
log.debug('Window lost focus, resetting key states.');
|
||||
this.interaction.isCtrlPressed = false;
|
||||
this.interaction.isAltPressed = false;
|
||||
this.interaction.isShiftPressed = false;
|
||||
this.interaction.isSPressed = false;
|
||||
this.interaction.keyMovementInProgress = false;
|
||||
|
||||
// Deactivate shape tool when window loses focus
|
||||
if (this.canvas.shapeTool.isActive) {
|
||||
this.canvas.shapeTool.deactivate();
|
||||
}
|
||||
|
||||
// Also reset any interaction that relies on a key being held down
|
||||
if (this.interaction.mode === 'dragging' && this.interaction.hasClonedInDrag) {
|
||||
// If we were in the middle of a cloning drag, finalize it
|
||||
this.canvas.saveState();
|
||||
this.canvas.canvasState.saveStateToDB();
|
||||
}
|
||||
|
||||
// Reset interaction mode if it's something that can get "stuck"
|
||||
if (this.interaction.mode !== 'none' && this.interaction.mode !== 'drawingMask') {
|
||||
this.resetInteractionState();
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
|
||||
updateCursor(worldCoords: Point): void {
|
||||
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||
|
||||
@@ -512,28 +687,22 @@ export class CanvasInteractions {
|
||||
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);
|
||||
|
||||
// Po prostu przesuwamy outputAreaBounds
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
this.interaction.canvasMoveRect = {
|
||||
x: snapToGrid(bounds.x + dx),
|
||||
y: snapToGrid(bounds.y + dy),
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
};
|
||||
|
||||
this.canvas.render();
|
||||
}
|
||||
@@ -541,43 +710,19 @@ export class CanvasInteractions {
|
||||
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;
|
||||
if (moveRect) {
|
||||
// Po prostu aktualizujemy outputAreaBounds na nową pozycję
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: moveRect.x,
|
||||
y: moveRect.y,
|
||||
width: moveRect.width,
|
||||
height: moveRect.height
|
||||
};
|
||||
|
||||
// Update mask canvas to ensure it covers the new output area position
|
||||
this.canvas.maskTool.updateMaskCanvasForOutputArea();
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
@@ -597,6 +742,7 @@ export class CanvasInteractions {
|
||||
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
||||
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
||||
this.canvas.render();
|
||||
this.canvas.onViewportChange?.();
|
||||
}
|
||||
|
||||
dragLayers(worldCoords: Point): void {
|
||||
@@ -753,85 +899,54 @@ export class CanvasInteractions {
|
||||
const finalX = this.interaction.canvasResizeRect.x;
|
||||
const finalY = this.interaction.canvasResizeRect.y;
|
||||
|
||||
// Po prostu aktualizujemy outputAreaBounds na nowy obszar
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
|
||||
handleDragOver(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||
this.preventEventDefaults(e);
|
||||
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';
|
||||
this.preventEventDefaults(e);
|
||||
this.setDragDropStyling(true);
|
||||
}
|
||||
|
||||
handleDragLeave(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||
|
||||
this.preventEventDefaults(e);
|
||||
if (!this.canvas.canvas.contains(e.relatedTarget as Node)) {
|
||||
this.canvas.canvas.style.backgroundColor = '';
|
||||
this.canvas.canvas.style.border = '';
|
||||
this.setDragDropStyling(false);
|
||||
}
|
||||
}
|
||||
|
||||
async handleDrop(e: DragEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow
|
||||
|
||||
this.preventEventDefaults(e);
|
||||
log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading");
|
||||
|
||||
this.canvas.canvas.style.backgroundColor = '';
|
||||
this.canvas.canvas.style.border = '';
|
||||
this.setDragDropStyling(false);
|
||||
|
||||
if (!e.dataTransfer) return;
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||
const coords = this.getMouseCoordinates(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 (${coords.world.x}, ${coords.world.y})`);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
try {
|
||||
await this.loadDroppedImageFile(file, worldCoords);
|
||||
await this.loadDroppedImageFile(file, coords.world);
|
||||
log.info(`Successfully loaded dropped image: ${file.name}`);
|
||||
} catch (error) {
|
||||
log.error(`Failed to load dropped image ${file.name}:`, error);
|
||||
@@ -866,6 +981,82 @@ export class CanvasInteractions {
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
defineOutputAreaWithShape(shape: any): void {
|
||||
const boundingBox = this.canvas.shapeTool.getBoundingBox();
|
||||
if (boundingBox && boundingBox.width > 1 && boundingBox.height > 1) {
|
||||
this.canvas.saveState();
|
||||
|
||||
// If there's an existing custom shape and auto-apply shape mask is enabled, remove the previous mask
|
||||
if (this.canvas.outputAreaShape && this.canvas.autoApplyShapeMask) {
|
||||
log.info("Removing previous shape mask before defining new custom shape");
|
||||
this.canvas.maskTool.removeShapeMask();
|
||||
}
|
||||
|
||||
this.canvas.outputAreaShape = {
|
||||
...shape,
|
||||
points: shape.points.map((p: any) => ({
|
||||
x: p.x - boundingBox.x,
|
||||
y: p.y - boundingBox.y
|
||||
}))
|
||||
};
|
||||
|
||||
const newWidth = Math.round(boundingBox.width);
|
||||
const newHeight = Math.round(boundingBox.height);
|
||||
const newX = Math.round(boundingBox.x);
|
||||
const newY = Math.round(boundingBox.y);
|
||||
|
||||
// Store the original canvas size for extension calculations
|
||||
this.canvas.originalCanvasSize = { width: newWidth, height: newHeight };
|
||||
|
||||
// Store the original position where custom shape was drawn for extension calculations
|
||||
this.canvas.originalOutputAreaPosition = { x: newX, y: newY };
|
||||
|
||||
// If extensions are enabled, we need to recalculate outputAreaBounds with current extensions
|
||||
if (this.canvas.outputAreaExtensionEnabled) {
|
||||
const ext = this.canvas.outputAreaExtensions;
|
||||
const extendedWidth = newWidth + ext.left + ext.right;
|
||||
const extendedHeight = newHeight + ext.top + ext.bottom;
|
||||
|
||||
// Update canvas size with extensions
|
||||
this.canvas.updateOutputAreaSize(extendedWidth, extendedHeight, false);
|
||||
|
||||
// Set outputAreaBounds accounting for extensions
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: newX - ext.left, // Adjust position by left extension
|
||||
y: newY - ext.top, // Adjust position by top extension
|
||||
width: extendedWidth,
|
||||
height: extendedHeight
|
||||
};
|
||||
|
||||
log.info(`New custom shape with extensions: original(${newX}, ${newY}) extended(${newX - ext.left}, ${newY - ext.top}) size(${extendedWidth}x${extendedHeight})`);
|
||||
} else {
|
||||
// No extensions - use original size and position
|
||||
this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
|
||||
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: newX,
|
||||
y: newY,
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
|
||||
log.info(`New custom shape without extensions: position(${newX}, ${newY}) size(${newWidth}x${newHeight})`);
|
||||
}
|
||||
|
||||
// Update mask canvas to ensure it covers the new output area position
|
||||
this.canvas.maskTool.updateMaskCanvasForOutputArea();
|
||||
|
||||
// If auto-apply shape mask is enabled, automatically apply the mask with current settings
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
log.info("Auto-applying shape mask to new custom shape with current settings");
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
}
|
||||
|
||||
this.canvas.saveState();
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
|
||||
async handlePasteEvent(e: ClipboardEvent): Promise<void> {
|
||||
|
||||
const shouldHandle = this.canvas.isMouseOver ||
|
||||
@@ -920,4 +1111,5 @@ export class CanvasInteractions {
|
||||
|
||||
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer } from './types';
|
||||
|
||||
@@ -28,9 +30,103 @@ export class CanvasLayersPanel {
|
||||
this.handleDragEnd = this.handleDragEnd.bind(this);
|
||||
this.handleDrop = this.handleDrop.bind(this);
|
||||
|
||||
// Preload icons
|
||||
this.initializeIcons();
|
||||
|
||||
log.info('CanvasLayersPanel initialized');
|
||||
}
|
||||
|
||||
private async initializeIcons(): Promise<void> {
|
||||
try {
|
||||
await iconLoader.preloadToolIcons();
|
||||
log.debug('Icons preloaded successfully');
|
||||
} catch (error) {
|
||||
log.warn('Failed to preload icons, using fallbacks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private createIconElement(toolName: string, size: number = 16): HTMLElement {
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.style.cssText = `
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const icon = iconLoader.getIcon(toolName);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode() as HTMLImageElement;
|
||||
img.style.cssText = `
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
filter: brightness(0) invert(1);
|
||||
`;
|
||||
iconContainer.appendChild(img);
|
||||
} else if (icon instanceof HTMLCanvasElement) {
|
||||
const { canvas, ctx } = createCanvas(size, size);
|
||||
if (ctx) {
|
||||
ctx.drawImage(icon, 0, 0, size, size);
|
||||
}
|
||||
iconContainer.appendChild(canvas);
|
||||
}
|
||||
} else {
|
||||
// Fallback text
|
||||
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
||||
iconContainer.style.fontSize = `${size * 0.6}px`;
|
||||
iconContainer.style.color = '#ffffff';
|
||||
}
|
||||
|
||||
return iconContainer;
|
||||
}
|
||||
|
||||
private createVisibilityIcon(isVisible: boolean): HTMLElement {
|
||||
if (isVisible) {
|
||||
return this.createIconElement(LAYERFORGE_TOOLS.VISIBILITY, 16);
|
||||
} else {
|
||||
// Create a "hidden" version of the visibility icon
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.5;
|
||||
`;
|
||||
|
||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode() as HTMLImageElement;
|
||||
img.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.3;
|
||||
`;
|
||||
iconContainer.appendChild(img);
|
||||
} else if (icon instanceof HTMLCanvasElement) {
|
||||
const { canvas, ctx } = createCanvas(16, 16);
|
||||
if (ctx) {
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.drawImage(icon, 0, 0, 16, 16);
|
||||
}
|
||||
iconContainer.appendChild(canvas);
|
||||
}
|
||||
} else {
|
||||
// Fallback
|
||||
iconContainer.textContent = 'H';
|
||||
iconContainer.style.fontSize = '10px';
|
||||
iconContainer.style.color = '#888888';
|
||||
}
|
||||
|
||||
return iconContainer;
|
||||
}
|
||||
}
|
||||
|
||||
createPanelStructure(): HTMLElement {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'layers-panel';
|
||||
@@ -39,7 +135,7 @@ export class CanvasLayersPanel {
|
||||
<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>
|
||||
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layers-container" id="layers-container">
|
||||
@@ -253,6 +349,23 @@ export class CanvasLayersPanel {
|
||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layer-visibility-toggle {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.layer-visibility-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(style);
|
||||
@@ -263,6 +376,12 @@ export class CanvasLayersPanel {
|
||||
if (!this.container) return;
|
||||
const deleteBtn = this.container.querySelector('#delete-layer-btn');
|
||||
|
||||
// Add delete icon to button
|
||||
if (deleteBtn) {
|
||||
const deleteIcon = this.createIconElement(LAYERFORGE_TOOLS.DELETE, 16);
|
||||
deleteBtn.appendChild(deleteIcon);
|
||||
}
|
||||
|
||||
deleteBtn?.addEventListener('click', () => {
|
||||
log.info('Delete layer button clicked');
|
||||
this.deleteSelectedLayers();
|
||||
@@ -313,10 +432,18 @@ export class CanvasLayersPanel {
|
||||
}
|
||||
|
||||
layerRow.innerHTML = `
|
||||
<div class="layer-visibility-toggle" data-layer-index="${index}" title="Toggle layer visibility"></div>
|
||||
<div class="layer-thumbnail" data-layer-index="${index}"></div>
|
||||
<span class="layer-name" data-layer-index="${index}">${layer.name}</span>
|
||||
`;
|
||||
|
||||
// Add visibility icon
|
||||
const visibilityToggle = layerRow.querySelector<HTMLElement>('.layer-visibility-toggle');
|
||||
if (visibilityToggle) {
|
||||
const visibilityIcon = this.createVisibilityIcon(layer.visible);
|
||||
visibilityToggle.appendChild(visibilityIcon);
|
||||
}
|
||||
|
||||
const thumbnailContainer = layerRow.querySelector<HTMLElement>('.layer-thumbnail');
|
||||
if (thumbnailContainer) {
|
||||
this.generateThumbnail(layer, thumbnailContainer);
|
||||
@@ -333,11 +460,8 @@ export class CanvasLayersPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const { canvas, ctx } = createCanvas(48, 48, '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;
|
||||
@@ -363,6 +487,17 @@ export class CanvasLayersPanel {
|
||||
this.handleLayerClick(e, layer, index);
|
||||
});
|
||||
|
||||
// --- PRAWY PRZYCISK: ODJAZNACZ LAYER ---
|
||||
layerRow.addEventListener('contextmenu', (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
||||
this.canvas.updateSelection(newSelection);
|
||||
this.updateSelectionAppearance();
|
||||
}
|
||||
});
|
||||
|
||||
layerRow.addEventListener('dblclick', (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -372,6 +507,16 @@ export class CanvasLayersPanel {
|
||||
}
|
||||
});
|
||||
|
||||
// Add visibility toggle event listener
|
||||
const visibilityToggle = layerRow.querySelector<HTMLElement>('.layer-visibility-toggle');
|
||||
if (visibilityToggle) {
|
||||
visibilityToggle.addEventListener('click', (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.toggleLayerVisibility(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));
|
||||
@@ -392,7 +537,6 @@ export class CanvasLayersPanel {
|
||||
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');
|
||||
@@ -430,7 +574,6 @@ export class CanvasLayersPanel {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
ensureUniqueName(proposedName: string, currentLayer: Layer): string {
|
||||
const existingNames = this.canvas.layers
|
||||
.filter((layer: Layer) => layer !== currentLayer)
|
||||
@@ -464,6 +607,24 @@ export class CanvasLayersPanel {
|
||||
return uniqueName;
|
||||
}
|
||||
|
||||
toggleLayerVisibility(layer: Layer): void {
|
||||
layer.visible = !layer.visible;
|
||||
|
||||
// If layer became invisible and is selected, deselect it
|
||||
if (!layer.visible && this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
||||
this.canvas.updateSelection(newSelection);
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
this.canvas.requestSaveState();
|
||||
|
||||
// Update the eye icon in the panel
|
||||
this.renderLayers();
|
||||
|
||||
log.info(`Layer "${layer.name}" visibility toggled to: ${layer.visible}`);
|
||||
}
|
||||
|
||||
deleteSelectedLayers(): void {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
|
||||
log.debug('No layers selected for deletion');
|
||||
@@ -571,7 +732,6 @@ export class CanvasLayersPanel {
|
||||
this.draggedElements = [];
|
||||
}
|
||||
|
||||
|
||||
onLayersChanged(): void {
|
||||
this.renderLayers();
|
||||
}
|
||||
|
||||
@@ -16,6 +16,80 @@ export class CanvasRenderer {
|
||||
this.isDirty = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to draw text with background at world coordinates
|
||||
* @param ctx Canvas context
|
||||
* @param text Text to display
|
||||
* @param worldX World X coordinate
|
||||
* @param worldY World Y coordinate
|
||||
* @param options Optional styling options
|
||||
*/
|
||||
drawTextWithBackground(ctx: any, text: string, worldX: number, worldY: number, options: any = {}) {
|
||||
const {
|
||||
font = "14px sans-serif",
|
||||
textColor = "white",
|
||||
backgroundColor = "rgba(0, 0, 0, 0.7)",
|
||||
padding = 10,
|
||||
lineHeight = 18
|
||||
} = options;
|
||||
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
const screenX = (worldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (worldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
|
||||
ctx.font = font;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
const lines = text.split('\n');
|
||||
const textMetrics = lines.map(line => ctx.measureText(line));
|
||||
const bgWidth = Math.max(...textMetrics.map(m => m.width)) + padding;
|
||||
const bgHeight = lines.length * lineHeight + 4;
|
||||
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight);
|
||||
|
||||
ctx.fillStyle = textColor;
|
||||
lines.forEach((line, index) => {
|
||||
const yPos = screenY - (bgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2;
|
||||
ctx.fillText(line, screenX, yPos);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to draw rectangle with stroke style
|
||||
* @param ctx Canvas context
|
||||
* @param rect Rectangle bounds {x, y, width, height}
|
||||
* @param options Styling options
|
||||
*/
|
||||
drawStyledRect(ctx: any, rect: any, options: any = {}) {
|
||||
const {
|
||||
strokeStyle = "rgba(255, 255, 255, 0.8)",
|
||||
lineWidth = 2,
|
||||
dashPattern = null
|
||||
} = options;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = strokeStyle;
|
||||
ctx.lineWidth = lineWidth / this.canvas.viewport.zoom;
|
||||
|
||||
if (dashPattern) {
|
||||
const scaledDash = dashPattern.map((d: number) => d / this.canvas.viewport.zoom);
|
||||
ctx.setLineDash(scaledDash);
|
||||
}
|
||||
|
||||
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
||||
|
||||
if (dashPattern) {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.renderAnimationFrame) {
|
||||
this.isDirty = true;
|
||||
@@ -58,46 +132,12 @@ export class CanvasRenderer {
|
||||
|
||||
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
|
||||
// Use CanvasLayers to draw layers with proper blend area support
|
||||
this.canvas.canvasLayers.drawLayersToContext(ctx, this.canvas.layers);
|
||||
|
||||
// Draw mask AFTER layers but BEFORE all preview outlines
|
||||
const maskImage = this.canvas.maskTool.getMask();
|
||||
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||
|
||||
ctx.save();
|
||||
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
@@ -108,14 +148,53 @@ export class CanvasRenderer {
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y);
|
||||
// Renderuj maskę w jej pozycji światowej (bez przesunięcia względem bounds)
|
||||
const maskWorldX = this.canvas.maskTool.x;
|
||||
const maskWorldY = this.canvas.maskTool.y;
|
||||
ctx.drawImage(maskImage, maskWorldX, maskWorldY);
|
||||
|
||||
ctx.globalAlpha = 1.0;
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Draw selection frames for selected layers
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach(layer => {
|
||||
if (!layer.image || !layer.visible) return;
|
||||
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
ctx.save();
|
||||
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);
|
||||
}
|
||||
|
||||
this.drawSelectionFrame(ctx, layer);
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
|
||||
this.drawCanvasOutline(ctx);
|
||||
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
|
||||
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
||||
|
||||
this.renderInteractionElements(ctx);
|
||||
this.canvas.shapeTool.render(ctx);
|
||||
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
|
||||
this.renderLayerInfo(ctx);
|
||||
|
||||
// Update custom shape menu position and visibility
|
||||
if (this.canvas.outputAreaShape) {
|
||||
this.canvas.customShapeMenu.show();
|
||||
this.canvas.customShapeMenu.updateScreenPosition();
|
||||
} else {
|
||||
this.canvas.customShapeMenu.hide();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
|
||||
@@ -139,72 +218,44 @@ export class CanvasRenderer {
|
||||
|
||||
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();
|
||||
this.drawStyledRect(ctx, rect, {
|
||||
strokeStyle: 'rgba(0, 255, 0, 0.8)',
|
||||
lineWidth: 2,
|
||||
dashPattern: [8, 4]
|
||||
});
|
||||
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();
|
||||
this.drawTextWithBackground(ctx, text, textWorldX, textWorldY, {
|
||||
backgroundColor: "rgba(0, 128, 0, 0.7)"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
this.drawStyledRect(ctx, rect, {
|
||||
strokeStyle: 'rgba(0, 150, 255, 0.8)',
|
||||
lineWidth: 2,
|
||||
dashPattern: [10, 5]
|
||||
});
|
||||
|
||||
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();
|
||||
this.drawTextWithBackground(ctx, text, textWorldX, textWorldY, {
|
||||
backgroundColor: "rgba(0, 100, 170, 0.7)"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderLayerInfo(ctx: any) {
|
||||
if (this.canvas.canvasSelection.selectedLayer) {
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: any) => {
|
||||
if (!layer.image) return;
|
||||
if (!layer.image || !layer.visible) return;
|
||||
|
||||
const layerIndex = this.canvas.layers.indexOf(layer);
|
||||
const currentWidth = Math.round(layer.width);
|
||||
@@ -242,31 +293,8 @@ export class CanvasRenderer {
|
||||
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();
|
||||
|
||||
this.drawTextWithBackground(ctx, text, textWorldX, textWorldY);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -297,16 +325,208 @@ export class CanvasRenderer {
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if custom shape overlaps with any active batch preview areas
|
||||
*/
|
||||
isCustomShapeOverlappingWithBatchAreas(): boolean {
|
||||
if (!this.canvas.outputAreaShape || !this.canvas.batchPreviewManagers || this.canvas.batchPreviewManagers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get custom shape bounds
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
const shapeOffsetX = bounds.x + ext.left;
|
||||
const shapeOffsetY = bounds.y + ext.top;
|
||||
|
||||
const shape = this.canvas.outputAreaShape;
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
|
||||
// Calculate shape bounding box
|
||||
shape.points.forEach((point: { x: number; y: number }) => {
|
||||
const worldX = shapeOffsetX + point.x;
|
||||
const worldY = shapeOffsetY + point.y;
|
||||
minX = Math.min(minX, worldX);
|
||||
maxX = Math.max(maxX, worldX);
|
||||
minY = Math.min(minY, worldY);
|
||||
maxY = Math.max(maxY, worldY);
|
||||
});
|
||||
|
||||
const shapeBounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||||
|
||||
// Check overlap with each active batch preview area
|
||||
for (const manager of this.canvas.batchPreviewManagers) {
|
||||
if (manager.generationArea) {
|
||||
const area = manager.generationArea;
|
||||
// Check if rectangles overlap
|
||||
if (!(shapeBounds.x + shapeBounds.width < area.x ||
|
||||
area.x + area.width < shapeBounds.x ||
|
||||
shapeBounds.y + shapeBounds.height < area.y ||
|
||||
area.y + area.height < shapeBounds.y)) {
|
||||
return true; // Overlap detected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
// Rysuj outline w pozycji outputAreaBounds
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Display dimensions under outputAreaBounds
|
||||
const dimensionsText = `${Math.round(bounds.width)}x${Math.round(bounds.height)}`;
|
||||
const textWorldX = bounds.x + bounds.width / 2;
|
||||
const textWorldY = bounds.y + bounds.height + (20 / this.canvas.viewport.zoom);
|
||||
|
||||
this.drawTextWithBackground(ctx, dimensionsText, textWorldX, textWorldY);
|
||||
|
||||
// Only draw custom shape if it doesn't overlap with batch preview areas
|
||||
if (this.canvas.outputAreaShape && !this.isCustomShapeOverlappingWithBatchAreas()) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
|
||||
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
ctx.setLineDash([]);
|
||||
const shape = this.canvas.outputAreaShape;
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
|
||||
// Calculate custom shape position accounting for extensions
|
||||
// Custom shape should maintain its relative position within the original canvas area
|
||||
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
const shapeOffsetX = bounds.x + ext.left; // Add left extension to maintain relative position
|
||||
const shapeOffsetY = bounds.y + ext.top; // Add top extension to maintain relative position
|
||||
|
||||
ctx.beginPath();
|
||||
// Render custom shape with extension offset to maintain relative position
|
||||
ctx.moveTo(shapeOffsetX + shape.points[0].x, shapeOffsetY + shape.points[0].y);
|
||||
for (let i = 1; i < shape.points.length; i++) {
|
||||
ctx.lineTo(shapeOffsetX + shape.points[i].x, shapeOffsetY + shape.points[i].y);
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdza czy punkt w świecie jest przykryty przez warstwy o wyższym zIndex
|
||||
*/
|
||||
isPointCoveredByHigherLayers(worldX: number, worldY: number, currentLayer: any): boolean {
|
||||
// Znajdź warstwy o wyższym zIndex niż aktualny layer
|
||||
const higherLayers = this.canvas.layers.filter((l: any) =>
|
||||
l.zIndex > currentLayer.zIndex && l.visible && l !== currentLayer
|
||||
);
|
||||
|
||||
for (const higherLayer of higherLayers) {
|
||||
// Sprawdź czy punkt jest wewnątrz tego layera
|
||||
const centerX = higherLayer.x + higherLayer.width / 2;
|
||||
const centerY = higherLayer.y + higherLayer.height / 2;
|
||||
|
||||
// Przekształć punkt do lokalnego układu współrzędnych layera
|
||||
const dx = worldX - centerX;
|
||||
const dy = worldY - centerY;
|
||||
|
||||
const rad = -higherLayer.rotation * Math.PI / 180;
|
||||
const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad);
|
||||
const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad);
|
||||
|
||||
// Sprawdź czy punkt jest wewnątrz prostokąta layera
|
||||
if (Math.abs(rotatedX) <= higherLayer.width / 2 &&
|
||||
Math.abs(rotatedY) <= higherLayer.height / 2) {
|
||||
|
||||
// Sprawdź przezroczystość layera - jeśli ma znaczącą nieprzezroczystość, uznaj za przykryty
|
||||
if (higherLayer.opacity > 0.1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rysuje linię z automatycznym przełączaniem między ciągłą a przerywaną w zależności od przykrycia
|
||||
*/
|
||||
drawAdaptiveLine(ctx: any, startX: number, startY: number, endX: number, endY: number, layer: any) {
|
||||
const segmentLength = 8 / this.canvas.viewport.zoom; // Długość segmentu do sprawdzania
|
||||
const dashLength = 6 / this.canvas.viewport.zoom;
|
||||
const gapLength = 4 / this.canvas.viewport.zoom;
|
||||
|
||||
const totalLength = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
|
||||
const segments = Math.max(1, Math.floor(totalLength / segmentLength));
|
||||
|
||||
let currentX = startX;
|
||||
let currentY = startY;
|
||||
let lastCovered = null;
|
||||
let segmentStart = { x: startX, y: startY };
|
||||
|
||||
for (let i = 0; i <= segments; i++) {
|
||||
const t = i / segments;
|
||||
const x = startX + (endX - startX) * t;
|
||||
const y = startY + (endY - startY) * t;
|
||||
|
||||
// Przekształć współrzędne lokalne na światowe
|
||||
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 worldX = centerX + (x * cos - y * sin);
|
||||
const worldY = centerY + (x * sin + y * cos);
|
||||
|
||||
const isCovered = this.isPointCoveredByHigherLayers(worldX, worldY, layer);
|
||||
|
||||
// Jeśli stan się zmienił lub to ostatni segment, narysuj poprzedni odcinek
|
||||
if (lastCovered !== null && (lastCovered !== isCovered || i === segments)) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(segmentStart.x, segmentStart.y);
|
||||
ctx.lineTo(currentX, currentY);
|
||||
|
||||
if (lastCovered) {
|
||||
// Przykryty - linia przerywana
|
||||
ctx.setLineDash([dashLength, gapLength]);
|
||||
} else {
|
||||
// Nie przykryty - linia ciągła
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
segmentStart = { x: currentX, y: currentY };
|
||||
}
|
||||
|
||||
lastCovered = isCovered;
|
||||
currentX = x;
|
||||
currentY = y;
|
||||
}
|
||||
|
||||
// Narysuj ostatni segment jeśli potrzeba
|
||||
if (lastCovered !== null) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(segmentStart.x, segmentStart.y);
|
||||
ctx.lineTo(endX, endY);
|
||||
|
||||
if (lastCovered) {
|
||||
ctx.setLineDash([dashLength, gapLength]);
|
||||
} else {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Resetuj dash pattern
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
drawSelectionFrame(ctx: any, layer: any) {
|
||||
@@ -314,13 +534,28 @@ export class CanvasRenderer {
|
||||
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();
|
||||
|
||||
// Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia)
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
|
||||
// Górna krawędź
|
||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
||||
// Prawa krawędź
|
||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
||||
// Dolna krawędź
|
||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
||||
// Lewa krawędź
|
||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
||||
|
||||
// Rysuj linię do uchwytu rotacji (zawsze ciągła)
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -layer.height / 2);
|
||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
||||
ctx.stroke();
|
||||
|
||||
// Rysuj uchwyty
|
||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
@@ -342,35 +577,94 @@ export class CanvasRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
drawOutputAreaExtensionPreview(ctx: any) {
|
||||
if (!this.canvas.outputAreaExtensionPreview) {
|
||||
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();
|
||||
// Calculate preview bounds based on original canvas size + preview extensions
|
||||
const baseWidth = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.width : this.canvas.width;
|
||||
const baseHeight = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.height : this.canvas.height;
|
||||
|
||||
const ext = this.canvas.outputAreaExtensionPreview;
|
||||
|
||||
// Calculate preview bounds relative to original custom shape position, not (0,0)
|
||||
const originalPos = this.canvas.originalOutputAreaPosition;
|
||||
const previewBounds = {
|
||||
x: originalPos.x - ext.left, // ✅ Względem oryginalnej pozycji custom shape
|
||||
y: originalPos.y - ext.top, // ✅ Względem oryginalnej pozycji custom shape
|
||||
width: baseWidth + ext.left + ext.right,
|
||||
height: baseHeight + ext.top + ext.bottom
|
||||
};
|
||||
|
||||
this.drawStyledRect(ctx, previewBounds, {
|
||||
strokeStyle: 'rgba(255, 255, 0, 0.8)',
|
||||
lineWidth: 3,
|
||||
dashPattern: [8, 4]
|
||||
});
|
||||
}
|
||||
|
||||
drawPendingGenerationAreas(ctx: any) {
|
||||
const pendingAreas = [];
|
||||
|
||||
// 1. Get all pending generation areas (from pendingBatchContext)
|
||||
if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) {
|
||||
pendingAreas.push(this.canvas.pendingBatchContext.outputArea);
|
||||
}
|
||||
|
||||
// 2. Draw only those pending areas, które NIE mają aktywnego batch preview managera dla tego samego obszaru
|
||||
const isAreaCoveredByBatch = (area: any) => {
|
||||
if (!this.canvas.batchPreviewManagers) return false;
|
||||
return this.canvas.batchPreviewManagers.some((manager: any) => {
|
||||
if (!manager.generationArea) return false;
|
||||
// Sprawdź czy obszary się pokrywają (prosty overlap AABB)
|
||||
const a = area;
|
||||
const b = manager.generationArea;
|
||||
return !(a.x + a.width < b.x || b.x + b.width < a.x || a.y + a.height < b.y || b.y + b.height < a.y);
|
||||
});
|
||||
};
|
||||
|
||||
pendingAreas.forEach(area => {
|
||||
if (!isAreaCoveredByBatch(area)) {
|
||||
this.drawStyledRect(ctx, area, {
|
||||
strokeStyle: 'rgba(0, 150, 255, 0.9)',
|
||||
lineWidth: 3,
|
||||
dashPattern: [12, 6]
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
drawMaskAreaBounds(ctx: any) {
|
||||
// Only show mask area bounds when mask tool is active
|
||||
if (!this.canvas.maskTool.isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maskTool = this.canvas.maskTool;
|
||||
|
||||
// Get mask canvas bounds in world coordinates
|
||||
const maskBounds = {
|
||||
x: maskTool.x,
|
||||
y: maskTool.y,
|
||||
width: maskTool.getMask().width,
|
||||
height: maskTool.getMask().height
|
||||
};
|
||||
|
||||
this.drawStyledRect(ctx, maskBounds, {
|
||||
strokeStyle: 'rgba(255, 100, 100, 0.7)',
|
||||
lineWidth: 2,
|
||||
dashPattern: [6, 6]
|
||||
});
|
||||
|
||||
// Add text label to show this is the mask drawing area
|
||||
const textWorldX = maskBounds.x + maskBounds.width / 2;
|
||||
const textWorldY = maskBounds.y - (10 / this.canvas.viewport.zoom);
|
||||
|
||||
this.drawTextWithBackground(ctx, "Mask Drawing Area", textWorldX, textWorldY, {
|
||||
font: "12px sans-serif",
|
||||
backgroundColor: "rgba(255, 100, 100, 0.8)",
|
||||
padding: 8
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { generateUUID } from "./utils/CommonUtils.js";
|
||||
|
||||
const log = createModuleLogger('CanvasSelection');
|
||||
|
||||
@@ -26,7 +27,7 @@ export class CanvasSelection {
|
||||
sortedLayers.forEach(layer => {
|
||||
const newLayer = {
|
||||
...layer,
|
||||
id: `layer_${+new Date()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
id: generateUUID(),
|
||||
zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu
|
||||
};
|
||||
this.canvas.layers.push(newLayer);
|
||||
@@ -52,7 +53,8 @@ export class CanvasSelection {
|
||||
*/
|
||||
updateSelection(newSelection: any) {
|
||||
const previousSelection = this.selectedLayers.length;
|
||||
this.selectedLayers = newSelection || [];
|
||||
// Filter out invisible layers from selection
|
||||
this.selectedLayers = (newSelection || []).filter((layer: any) => layer.visible !== false);
|
||||
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
|
||||
|
||||
// Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {getCanvasState, setCanvasState, saveImage, getImage} from "./db.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import {generateUUID, cloneLayers, getStateSignature, debounce} from "./utils/CommonUtils.js";
|
||||
import {showAlertNotification, showAllNotificationTypes} from "./utils/NotificationUtils.js";
|
||||
import {generateUUID, cloneLayers, getStateSignature, debounce, createCanvas} from "./utils/CommonUtils.js";
|
||||
import {withErrorHandling} from "./ErrorHandler.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer, ComfyNode } from './types';
|
||||
@@ -98,6 +99,21 @@ export class CanvasState {
|
||||
zoom: 0.8
|
||||
};
|
||||
|
||||
// Restore outputAreaBounds if saved, otherwise use default
|
||||
if (savedState.outputAreaBounds) {
|
||||
this.canvas.outputAreaBounds = savedState.outputAreaBounds;
|
||||
log.debug(`Output Area bounds restored: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${this.canvas.outputAreaBounds.width}, h=${this.canvas.outputAreaBounds.height}`);
|
||||
} else {
|
||||
// Fallback to default positioning for legacy saves
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: -(this.canvas.width / 4),
|
||||
y: -(this.canvas.height / 4),
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height
|
||||
};
|
||||
log.debug(`Output Area bounds set to default: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${this.canvas.outputAreaBounds.width}, h=${this.canvas.outputAreaBounds.height}`);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -230,10 +246,7 @@ export class CanvasState {
|
||||
};
|
||||
img.src = imageSrc;
|
||||
} else {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imageSrc.width;
|
||||
canvas.height = imageSrc.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const { canvas, ctx } = createCanvas(imageSrc.width, imageSrc.height);
|
||||
if (ctx) {
|
||||
ctx.drawImage(imageSrc, 0, 0);
|
||||
const img = new Image();
|
||||
@@ -260,6 +273,23 @@ export class CanvasState {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-correct node_id widget if needed before saving state
|
||||
if (this.canvas.node && this.canvas.node.widgets) {
|
||||
const nodeIdWidget = this.canvas.node.widgets.find((w: any) => w.name === "node_id");
|
||||
if (nodeIdWidget) {
|
||||
const correctId = String(this.canvas.node.id);
|
||||
if (nodeIdWidget.value !== correctId) {
|
||||
const prevValue = nodeIdWidget.value;
|
||||
nodeIdWidget.value = correctId;
|
||||
log.warn(`[CanvasState] node_id widget value (${prevValue}) did not match node.id (${correctId}) - auto-corrected (saveStateToDB).`);
|
||||
showAlertNotification(
|
||||
`The value of node_id (${prevValue}) did not match the node number (${correctId}) and was automatically corrected.
|
||||
If you see dark images or masks in the output, make sure node_id is set to ${correctId}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Preparing state to be sent to worker...");
|
||||
const layers = await this._prepareLayers();
|
||||
const state = {
|
||||
@@ -267,6 +297,7 @@ export class CanvasState {
|
||||
viewport: this.canvas.viewport,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
outputAreaBounds: this.canvas.outputAreaBounds,
|
||||
};
|
||||
|
||||
if (state.layers.length === 0) {
|
||||
@@ -358,10 +389,7 @@ export class CanvasState {
|
||||
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 });
|
||||
const { canvas: clonedCanvas, ctx: clonedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', { willReadFrequently: true });
|
||||
if (clonedCtx) {
|
||||
clonedCtx.drawImage(maskCanvas, 0, 0);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,11 @@ import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js"
|
||||
import {Canvas} from "./Canvas.js";
|
||||
import {clearAllCanvasStates} from "./db.js";
|
||||
import {ImageCache} from "./ImageCache.js";
|
||||
import {generateUniqueFileName} from "./utils/CommonUtils.js";
|
||||
import {generateUniqueFileName, createCanvas} from "./utils/CommonUtils.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import {showErrorNotification, showSuccessNotification, showInfoNotification, showWarningNotification} from "./utils/NotificationUtils.js";
|
||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||
import { registerImageInClipspace, startSAMDetectorMonitoring, setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
|
||||
import type { ComfyNode, Layer, AddMode } from './types';
|
||||
|
||||
const log = createModuleLogger('Canvas_view');
|
||||
@@ -87,20 +90,14 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
},
|
||||
}, [
|
||||
$el("div.painter-button-group", {}, [
|
||||
$el("button.painter-button", {
|
||||
$el("button.painter-button.icon-button", {
|
||||
id: `open-editor-btn-${node.id}`,
|
||||
textContent: "⛶",
|
||||
title: "Open in Editor",
|
||||
style: {minWidth: "40px", maxWidth: "40px", fontWeight: "bold"},
|
||||
}),
|
||||
$el("button.painter-button", {
|
||||
$el("button.painter-button.icon-button", {
|
||||
textContent: "?",
|
||||
title: "Show shortcuts",
|
||||
style: {
|
||||
minWidth: "30px",
|
||||
maxWidth: "30px",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
onmouseenter: (e: MouseEvent) => {
|
||||
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
||||
showTooltip(e.target as HTMLElement, content);
|
||||
@@ -152,37 +149,68 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
canvas.canvasLayers.handlePaste(addMode);
|
||||
}
|
||||
}),
|
||||
$el("button.painter-button", {
|
||||
id: `clipboard-toggle-${node.id}`,
|
||||
textContent: "📋 System",
|
||||
title: "Toggle clipboard source: System Clipboard",
|
||||
style: {
|
||||
minWidth: "100px",
|
||||
fontSize: "11px",
|
||||
backgroundColor: "#4a4a4a"
|
||||
},
|
||||
onclick: (e: MouseEvent) => {
|
||||
const button = e.target as HTMLButtonElement;
|
||||
if (canvas.canvasLayers.clipboardPreference === 'system') {
|
||||
canvas.canvasLayers.clipboardPreference = 'clipspace';
|
||||
button.textContent = "📋 Clipspace";
|
||||
button.title = "Toggle clipboard source: ComfyUI Clipspace";
|
||||
button.style.backgroundColor = "#4a6cd4";
|
||||
} else {
|
||||
canvas.canvasLayers.clipboardPreference = 'system';
|
||||
button.textContent = "📋 System";
|
||||
button.title = "Toggle clipboard source: System Clipboard";
|
||||
button.style.backgroundColor = "#4a4a4a";
|
||||
}
|
||||
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
|
||||
},
|
||||
onmouseenter: (e: MouseEvent) => {
|
||||
const currentPreference = canvas.canvasLayers.clipboardPreference;
|
||||
const tooltipContent = currentPreference === 'system' ? systemClipboardTooltip : clipspaceClipboardTooltip;
|
||||
showTooltip(e.target as HTMLElement, tooltipContent);
|
||||
},
|
||||
onmouseleave: hideTooltip
|
||||
})
|
||||
(() => {
|
||||
// Modern clipboard switch
|
||||
// Initial state: checked = clipspace, unchecked = system
|
||||
const isClipspace = canvas.canvasLayers.clipboardPreference === 'clipspace';
|
||||
const switchId = `clipboard-switch-${node.id}`;
|
||||
const switchEl = $el("label.clipboard-switch", { id: switchId }, [
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: isClipspace,
|
||||
onchange: (e: Event) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
canvas.canvasLayers.clipboardPreference = checked ? 'clipspace' : 'system';
|
||||
// For accessibility, update ARIA label
|
||||
switchEl.setAttribute('aria-label', checked ? "Clipboard: Clipspace" : "Clipboard: System");
|
||||
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
|
||||
}
|
||||
}),
|
||||
$el("span.switch-track"),
|
||||
$el("span.switch-labels", {}, [
|
||||
$el("span.text-clipspace", {}, ["Clipspace"]),
|
||||
$el("span.text-system", {}, ["System"])
|
||||
]),
|
||||
$el("span.switch-knob", {}, [
|
||||
$el("span.switch-icon")
|
||||
])
|
||||
]);
|
||||
|
||||
// Tooltip logic
|
||||
switchEl.addEventListener("mouseenter", (e: MouseEvent) => {
|
||||
const checked = (switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement).checked;
|
||||
const tooltipContent = checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
|
||||
showTooltip(switchEl, tooltipContent);
|
||||
});
|
||||
switchEl.addEventListener("mouseleave", hideTooltip);
|
||||
|
||||
// Dynamic icon and text update on toggle
|
||||
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon') as HTMLElement;
|
||||
|
||||
const updateSwitchView = (isClipspace: boolean) => {
|
||||
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD;
|
||||
const icon = iconLoader.getIcon(iconTool);
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
knobIcon.innerHTML = '';
|
||||
const clonedIcon = icon.cloneNode() as HTMLImageElement;
|
||||
clonedIcon.style.width = '20px';
|
||||
clonedIcon.style.height = '20px';
|
||||
knobIcon.appendChild(clonedIcon);
|
||||
} else {
|
||||
knobIcon.textContent = isClipspace ? "🗂️" : "📋";
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('change', () => updateSwitchView(input.checked));
|
||||
|
||||
// Initial state
|
||||
iconLoader.preloadToolIcons().then(() => {
|
||||
updateSwitchView(isClipspace);
|
||||
});
|
||||
|
||||
return switchEl;
|
||||
})()
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -265,9 +293,8 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
const heightInput = document.getElementById('canvas-height') as HTMLInputElement;
|
||||
const width = parseInt(widthInput.value) || canvas.width;
|
||||
const height = parseInt(heightInput.value) || canvas.height;
|
||||
canvas.updateOutputAreaSize(width, height);
|
||||
canvas.setOutputAreaSize(width, height);
|
||||
document.body.removeChild(dialog);
|
||||
|
||||
};
|
||||
|
||||
(document.getElementById('cancel-size') as HTMLButtonElement).onclick = () => {
|
||||
@@ -338,9 +365,13 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
const spinner = $el("div.matting-spinner") as HTMLDivElement;
|
||||
button.appendChild(spinner);
|
||||
button.classList.add('loading');
|
||||
|
||||
showInfoNotification("Starting background removal process...", 2000);
|
||||
|
||||
try {
|
||||
if (canvas.canvasSelection.selectedLayers.length !== 1) throw new Error("Please select exactly one image layer for matting.");
|
||||
if (canvas.canvasSelection.selectedLayers.length !== 1) {
|
||||
throw new Error("Please select exactly one image layer for matting.");
|
||||
}
|
||||
|
||||
const selectedLayer = canvas.canvasSelection.selectedLayers[0];
|
||||
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
|
||||
@@ -356,24 +387,28 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
if (!response.ok) {
|
||||
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
|
||||
if (result && result.error) {
|
||||
errorMsg = `Error: ${result.error}\n\nDetails: ${result.details}`;
|
||||
errorMsg = `Error: ${result.error}. Details: ${result.details || 'Check console'}`;
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const mattedImage = new Image();
|
||||
mattedImage.src = result.matted_image;
|
||||
await mattedImage.decode();
|
||||
|
||||
const newLayer = {...selectedLayer, image: mattedImage, flipH: false, flipV: false} as Layer;
|
||||
delete (newLayer as any).imageId;
|
||||
|
||||
canvas.layers[selectedLayerIndex] = newLayer;
|
||||
canvas.canvasSelection.updateSelection([newLayer]);
|
||||
canvas.render();
|
||||
canvas.saveState();
|
||||
showSuccessNotification("Background removed successfully!");
|
||||
|
||||
} catch (error: any) {
|
||||
log.error("Matting error:", error);
|
||||
const errorMessage = error.message || "An unknown error occurred.";
|
||||
const errorDetails = error.stack || (error.details ? JSON.stringify(error.details, null, 2) : "No details available.");
|
||||
showErrorDialog(errorMessage, errorDetails);
|
||||
showErrorNotification(`Matting Failed: ${errorMessage}`);
|
||||
} finally {
|
||||
button.classList.remove('loading');
|
||||
if (button.contains(spinner)) {
|
||||
@@ -399,24 +434,65 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
]),
|
||||
$el("div.painter-separator"),
|
||||
$el("div.painter-button-group", {id: "mask-controls"}, [
|
||||
$el("button.painter-button.primary", {
|
||||
id: `toggle-mask-btn-${node.id}`,
|
||||
textContent: "Show Mask",
|
||||
title: "Toggle mask overlay visibility",
|
||||
onclick: (e: MouseEvent) => {
|
||||
const button = e.target as HTMLButtonElement;
|
||||
canvas.maskTool.toggleOverlayVisibility();
|
||||
canvas.render();
|
||||
|
||||
if (canvas.maskTool.isOverlayVisible) {
|
||||
button.classList.add('primary');
|
||||
button.textContent = "Show Mask";
|
||||
} else {
|
||||
button.classList.remove('primary');
|
||||
button.textContent = "Hide Mask";
|
||||
$el("label.clipboard-switch.mask-switch", {
|
||||
id: `toggle-mask-switch-${node.id}`,
|
||||
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" }
|
||||
}, [
|
||||
$el("input", {
|
||||
type: "checkbox",
|
||||
checked: canvas.maskTool.isOverlayVisible,
|
||||
onchange: (e: Event) => {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
canvas.maskTool.isOverlayVisible = checked;
|
||||
canvas.render();
|
||||
}
|
||||
}),
|
||||
$el("span.switch-track"),
|
||||
$el("span.switch-labels", { style: { fontSize: "11px" } }, [
|
||||
$el("span.text-clipspace", { style: { paddingRight: "22px" } }, ["On"]),
|
||||
$el("span.text-system", { style: { paddingLeft: "20px" } }, ["Off"])
|
||||
]),
|
||||
$el("span.switch-knob", {}, [
|
||||
(() => {
|
||||
// Ikona maski (SVG lub obrazek)
|
||||
const iconContainer = document.createElement('span') as HTMLElement;
|
||||
iconContainer.className = 'switch-icon';
|
||||
iconContainer.style.display = 'flex';
|
||||
iconContainer.style.alignItems = 'center';
|
||||
iconContainer.style.justifyContent = 'center';
|
||||
iconContainer.style.width = '16px';
|
||||
iconContainer.style.height = '16px';
|
||||
// Pobierz ikonę maski z iconLoader
|
||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.MASK);
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode() as HTMLImageElement;
|
||||
img.style.width = "16px";
|
||||
img.style.height = "16px";
|
||||
// Ustaw filtr w zależności od stanu checkboxa
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById(`toggle-mask-switch-${node.id}`)?.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
const updateIconFilter = () => {
|
||||
if (input && img) {
|
||||
img.style.filter = input.checked
|
||||
? "brightness(0) invert(1)"
|
||||
: "grayscale(1) brightness(0.7) opacity(0.6)";
|
||||
}
|
||||
};
|
||||
if (input) {
|
||||
input.addEventListener('change', updateIconFilter);
|
||||
updateIconFilter();
|
||||
}
|
||||
}),
|
||||
}, 0);
|
||||
iconContainer.appendChild(img);
|
||||
} else {
|
||||
iconContainer.textContent = "M";
|
||||
iconContainer.style.fontSize = "12px";
|
||||
iconContainer.style.color = "#fff";
|
||||
}
|
||||
return iconContainer;
|
||||
})()
|
||||
])
|
||||
]),
|
||||
$el("button.painter-button", {
|
||||
textContent: "Edit Mask",
|
||||
title: "Open the current canvas view in the mask editor",
|
||||
@@ -453,8 +529,14 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
min: "1",
|
||||
max: "200",
|
||||
value: "20",
|
||||
oninput: (e: Event) => canvas.maskTool.setBrushSize(parseInt((e.target as HTMLInputElement).value))
|
||||
})
|
||||
oninput: (e: Event) => {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
canvas.maskTool.setBrushSize(parseInt(value));
|
||||
const valueEl = document.getElementById('brush-size-value');
|
||||
if (valueEl) valueEl.textContent = `${value}px`;
|
||||
}
|
||||
}),
|
||||
$el("div.slider-value", {id: "brush-size-value"}, ["20px"])
|
||||
]),
|
||||
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
|
||||
$el("label", {for: "brush-strength-slider", textContent: "Strength:"}),
|
||||
@@ -465,8 +547,14 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
max: "1",
|
||||
step: "0.05",
|
||||
value: "0.5",
|
||||
oninput: (e: Event) => canvas.maskTool.setBrushStrength(parseFloat((e.target as HTMLInputElement).value))
|
||||
})
|
||||
oninput: (e: Event) => {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
canvas.maskTool.setBrushStrength(parseFloat(value));
|
||||
const valueEl = document.getElementById('brush-strength-value');
|
||||
if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
|
||||
}
|
||||
}),
|
||||
$el("div.slider-value", {id: "brush-strength-value"}, ["50%"])
|
||||
]),
|
||||
$el("div.painter-slider-container.mask-control", {style: {display: 'none'}}, [
|
||||
$el("label", {for: "brush-hardness-slider", textContent: "Hardness:"}),
|
||||
@@ -477,8 +565,14 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
max: "1",
|
||||
step: "0.05",
|
||||
value: "0.5",
|
||||
oninput: (e: Event) => canvas.maskTool.setBrushHardness(parseFloat((e.target as HTMLInputElement).value))
|
||||
})
|
||||
oninput: (e: Event) => {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
canvas.maskTool.setBrushHardness(parseFloat(value));
|
||||
const valueEl = document.getElementById('brush-hardness-value');
|
||||
if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
|
||||
}
|
||||
}),
|
||||
$el("div.slider-value", {id: "brush-hardness-value"}, ["50%"])
|
||||
]),
|
||||
$el("button.painter-button.mask-control", {
|
||||
textContent: "Clear Mask",
|
||||
@@ -495,10 +589,9 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
|
||||
$el("div.painter-separator"),
|
||||
$el("div.painter-button-group", {}, [
|
||||
$el("button.painter-button", {
|
||||
$el("button.painter-button.success", {
|
||||
textContent: "Run GC",
|
||||
title: "Run Garbage Collection to clean unused images",
|
||||
style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"},
|
||||
onclick: async () => {
|
||||
try {
|
||||
const stats = canvas.imageReferenceManager.getStats();
|
||||
@@ -509,25 +602,24 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
const newStats = canvas.imageReferenceManager.getStats();
|
||||
log.info("GC Stats after cleanup:", newStats);
|
||||
|
||||
alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${canvas.imageReferenceManager.operationCount}/${canvas.imageReferenceManager.operationThreshold}`);
|
||||
showSuccessNotification(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${canvas.imageReferenceManager.operationCount}/${canvas.imageReferenceManager.operationThreshold}`);
|
||||
} catch (e) {
|
||||
log.error("Failed to run garbage collection:", e);
|
||||
alert("Error running garbage collection. Check the console for details.");
|
||||
showErrorNotification("Error running garbage collection. Check the console for details.");
|
||||
}
|
||||
}
|
||||
}),
|
||||
$el("button.painter-button", {
|
||||
$el("button.painter-button.danger", {
|
||||
textContent: "Clear Cache",
|
||||
title: "Clear all saved canvas states from browser storage",
|
||||
style: {backgroundColor: "#c54747", borderColor: "#a53737"},
|
||||
onclick: async () => {
|
||||
if (confirm("Are you sure you want to clear all saved canvas states? This action cannot be undone.")) {
|
||||
try {
|
||||
await clearAllCanvasStates();
|
||||
alert("Canvas cache cleared successfully!");
|
||||
showSuccessNotification("Canvas cache cleared successfully!");
|
||||
} catch (e) {
|
||||
log.error("Failed to clear canvas cache:", e);
|
||||
alert("Error clearing canvas cache. Check the console for details.");
|
||||
showErrorNotification("Error clearing canvas cache. Check the console for details.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -538,6 +630,45 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
]);
|
||||
|
||||
|
||||
// Function to create mask icon
|
||||
const createMaskIcon = (): HTMLElement => {
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.className = 'mask-icon-container';
|
||||
iconContainer.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.MASK);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode() as HTMLImageElement;
|
||||
img.style.cssText = `
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
filter: brightness(0) invert(1);
|
||||
`;
|
||||
iconContainer.appendChild(img);
|
||||
} else if (icon instanceof HTMLCanvasElement) {
|
||||
const { canvas, ctx } = createCanvas(16, 16);
|
||||
if (ctx) {
|
||||
ctx.drawImage(icon, 0, 0, 16, 16);
|
||||
}
|
||||
iconContainer.appendChild(canvas);
|
||||
}
|
||||
} else {
|
||||
// Fallback text
|
||||
iconContainer.textContent = 'M';
|
||||
iconContainer.style.fontSize = '12px';
|
||||
iconContainer.style.color = '#ffffff';
|
||||
}
|
||||
|
||||
return iconContainer;
|
||||
};
|
||||
|
||||
const updateButtonStates = () => {
|
||||
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
||||
const hasSelection = selectionCount > 0;
|
||||
@@ -568,27 +699,109 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
updateButtonStates();
|
||||
canvas.updateHistoryButtons();
|
||||
|
||||
// Add mask icon to toggle mask button after icons are loaded
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await iconLoader.preloadToolIcons();
|
||||
const toggleMaskBtn = controlPanel.querySelector(`#toggle-mask-btn-${node.id}`) as HTMLButtonElement;
|
||||
if (toggleMaskBtn && !toggleMaskBtn.querySelector('.mask-icon-container')) {
|
||||
// Clear fallback text
|
||||
toggleMaskBtn.textContent = '';
|
||||
|
||||
const maskIcon = createMaskIcon();
|
||||
toggleMaskBtn.appendChild(maskIcon);
|
||||
|
||||
// Set initial state based on mask visibility
|
||||
if (canvas.maskTool.isOverlayVisible) {
|
||||
toggleMaskBtn.classList.add('primary');
|
||||
maskIcon.style.opacity = '1';
|
||||
} else {
|
||||
toggleMaskBtn.classList.remove('primary');
|
||||
maskIcon.style.opacity = '0.5';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('Failed to load mask icon:', error);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
// Debounce timer for updateOutput to prevent excessive updates
|
||||
let updateOutputTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const updateOutput = async (node: ComfyNode, canvas: Canvas) => {
|
||||
// Check if preview is disabled - if so, skip updateOutput entirely
|
||||
|
||||
|
||||
const triggerWidget = node.widgets.find((w) => w.name === "trigger");
|
||||
if (triggerWidget) {
|
||||
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
|
||||
}
|
||||
|
||||
try {
|
||||
const new_preview = new Image();
|
||||
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (blob) {
|
||||
new_preview.src = URL.createObjectURL(blob);
|
||||
await new Promise(r => new_preview.onload = r);
|
||||
node.imgs = [new_preview];
|
||||
} else {
|
||||
node.imgs = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating node preview:", error);
|
||||
const showPreviewWidget = node.widgets.find((w) => w.name === "show_preview");
|
||||
if (showPreviewWidget && !showPreviewWidget.value) {
|
||||
log.debug("Preview disabled, skipping updateOutput");
|
||||
const PLACEHOLDER_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
||||
const placeholder = new Image();
|
||||
placeholder.src = PLACEHOLDER_IMAGE;
|
||||
node.imgs = [placeholder];
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear previous timer
|
||||
if (updateOutputTimer) {
|
||||
clearTimeout(updateOutputTimer);
|
||||
}
|
||||
|
||||
// Debounce the update to prevent excessive processing during rapid changes
|
||||
updateOutputTimer = setTimeout(async () => {
|
||||
try {
|
||||
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (blob) {
|
||||
// For large images, use blob URL for better performance
|
||||
if (blob.size > 2 * 1024 * 1024) { // 2MB threshold
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
node.imgs = [img];
|
||||
log.debug(`Using blob URL for large image (${(blob.size / 1024 / 1024).toFixed(1)}MB): ${blobUrl.substring(0, 50)}...`);
|
||||
// Clean up old blob URLs to prevent memory leaks
|
||||
if (node.imgs.length > 1) {
|
||||
const oldImg = node.imgs[0];
|
||||
if (oldImg.src.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(oldImg.src);
|
||||
}
|
||||
}
|
||||
};
|
||||
img.src = blobUrl;
|
||||
} else {
|
||||
// For smaller images, use data URI as before
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
node.imgs = [img];
|
||||
log.debug(`Using data URI for small image (${(blob.size / 1024).toFixed(1)}KB): ${dataUrl.substring(0, 50)}...`);
|
||||
};
|
||||
img.src = dataUrl;
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
} else {
|
||||
node.imgs = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating node preview:", error);
|
||||
}
|
||||
}, 250); // 150ms debounce delay
|
||||
};
|
||||
|
||||
// Store previous temp filenames for cleanup (make it globally accessible)
|
||||
if (!(window as any).layerForgeTempFileTracker) {
|
||||
(window as any).layerForgeTempFileTracker = new Map<string, string>();
|
||||
}
|
||||
const tempFileTracker = (window as any).layerForgeTempFileTracker;
|
||||
|
||||
const layersPanel = canvas.canvasLayersPanel.createPanelStructure();
|
||||
|
||||
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
|
||||
@@ -602,6 +815,8 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
}
|
||||
}, [canvas.canvas]) as HTMLDivElement;
|
||||
|
||||
canvas.canvasContainer = canvasContainer;
|
||||
|
||||
const layersPanelContainer = $el("div.painterLayersPanelContainer", {
|
||||
style: {
|
||||
position: "absolute",
|
||||
@@ -651,6 +866,39 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
let backdrop: HTMLDivElement | null = null;
|
||||
let originalParent: HTMLElement | null = null;
|
||||
let isEditorOpen = false;
|
||||
let viewportAdjustment = { x: 0, y: 0 };
|
||||
|
||||
/**
|
||||
* Adjusts the viewport when entering fullscreen mode.
|
||||
*/
|
||||
const adjustViewportOnOpen = (originalRect: DOMRect) => {
|
||||
const fullscreenRect = canvasContainer.getBoundingClientRect();
|
||||
|
||||
const widthDiff = fullscreenRect.width - originalRect.width;
|
||||
const heightDiff = fullscreenRect.height - originalRect.height;
|
||||
|
||||
const adjustX = (widthDiff / 2) / canvas.viewport.zoom;
|
||||
const adjustY = (heightDiff / 2) / canvas.viewport.zoom;
|
||||
|
||||
// Store the adjustment
|
||||
viewportAdjustment = { x: adjustX, y: adjustY };
|
||||
|
||||
// Apply the adjustment
|
||||
canvas.viewport.x -= viewportAdjustment.x;
|
||||
canvas.viewport.y -= viewportAdjustment.y;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores the viewport when exiting fullscreen mode.
|
||||
*/
|
||||
const adjustViewportOnClose = () => {
|
||||
// Apply the stored adjustment in reverse
|
||||
canvas.viewport.x += viewportAdjustment.x;
|
||||
canvas.viewport.y += viewportAdjustment.y;
|
||||
|
||||
// Reset adjustment
|
||||
viewportAdjustment = { x: 0, y: 0 };
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
if (originalParent && backdrop) {
|
||||
@@ -662,7 +910,11 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
openEditorBtn.textContent = "⛶";
|
||||
openEditorBtn.title = "Open in Editor";
|
||||
|
||||
// Remove ESC key listener when editor closes
|
||||
document.removeEventListener('keydown', handleEscKey);
|
||||
|
||||
setTimeout(() => {
|
||||
adjustViewportOnClose();
|
||||
canvas.render();
|
||||
if (node.onResize) {
|
||||
node.onResize();
|
||||
@@ -670,12 +922,23 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// ESC key handler for closing fullscreen editor
|
||||
const handleEscKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isEditorOpen) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeEditor();
|
||||
}
|
||||
};
|
||||
|
||||
openEditorBtn.onclick = () => {
|
||||
if (isEditorOpen) {
|
||||
closeEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
const originalRect = canvasContainer.getBoundingClientRect();
|
||||
|
||||
originalParent = mainContainer.parentElement;
|
||||
if (!originalParent) {
|
||||
log.error("Could not find original parent of the canvas container!");
|
||||
@@ -691,9 +954,14 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
|
||||
isEditorOpen = true;
|
||||
openEditorBtn.textContent = "X";
|
||||
openEditorBtn.title = "Close Editor";
|
||||
openEditorBtn.title = "Close Editor (ESC)";
|
||||
|
||||
// Add ESC key listener when editor opens
|
||||
document.addEventListener('keydown', handleEscKey);
|
||||
|
||||
setTimeout(() => {
|
||||
adjustViewportOnOpen(originalRect);
|
||||
|
||||
canvas.render();
|
||||
if (node.onResize) {
|
||||
node.onResize();
|
||||
@@ -743,57 +1011,6 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
||||
};
|
||||
}
|
||||
|
||||
function showErrorDialog(message: string, details: string) {
|
||||
const dialog = $el("div.painter-dialog.error-dialog", {
|
||||
style: {
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: '9999',
|
||||
padding: '20px',
|
||||
background: '#282828',
|
||||
border: '1px solid #ff4444',
|
||||
borderRadius: '8px',
|
||||
minWidth: '400px',
|
||||
maxWidth: '80vw',
|
||||
}
|
||||
}, [
|
||||
$el("h3", { textContent: "Matting Error", style: { color: "#ff4444", marginTop: "0" } }),
|
||||
$el("p", { textContent: message, style: { color: "white" } }),
|
||||
$el("pre.error-details", {
|
||||
textContent: details,
|
||||
style: {
|
||||
background: "#1e1e1e",
|
||||
border: "1px solid #444",
|
||||
padding: "10px",
|
||||
maxHeight: "300px",
|
||||
overflowY: "auto",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all",
|
||||
color: "#ccc"
|
||||
}
|
||||
}),
|
||||
$el("div.dialog-buttons", { style: { textAlign: "right", marginTop: "20px" } }, [
|
||||
$el("button", {
|
||||
textContent: "Copy Details",
|
||||
onclick: () => {
|
||||
navigator.clipboard.writeText(details)
|
||||
.then(() => alert("Error details copied to clipboard!"))
|
||||
.catch(err => alert("Failed to copy details: " + err));
|
||||
}
|
||||
}),
|
||||
$el("button", {
|
||||
textContent: "Close",
|
||||
style: { marginLeft: "10px" },
|
||||
onclick: () => document.body.removeChild(dialog)
|
||||
})
|
||||
])
|
||||
]);
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
}
|
||||
|
||||
const canvasNodeInstances = new Map<number, CanvasWidget>();
|
||||
|
||||
app.registerExtension({
|
||||
@@ -825,7 +1042,7 @@ app.registerExtension({
|
||||
log.info("All canvas data has been sent and acknowledged by the server.");
|
||||
} catch (error: any) {
|
||||
log.error("Failed to send canvas data for one or more nodes. Aborting prompt.", error);
|
||||
alert(`CanvasNode Error: ${error.message}`);
|
||||
showErrorNotification(`CanvasNode Error: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -879,6 +1096,14 @@ app.registerExtension({
|
||||
nodeType.prototype.onRemoved = function (this: ComfyNode) {
|
||||
log.info(`Cleaning up canvas node ${this.id}`);
|
||||
|
||||
// Clean up temp file tracker for this node (just remove from tracker)
|
||||
const nodeKey = `node-${this.id}`;
|
||||
const tempFileTracker = (window as any).layerForgeTempFileTracker;
|
||||
if (tempFileTracker && tempFileTracker.has(nodeKey)) {
|
||||
tempFileTracker.delete(nodeKey);
|
||||
log.debug(`Removed temp file tracker for node ${this.id}`);
|
||||
}
|
||||
|
||||
canvasNodeInstances.delete(this.id);
|
||||
log.info(`Deregistered CanvasNode instance for ID: ${this.id}`);
|
||||
|
||||
@@ -904,15 +1129,97 @@ app.registerExtension({
|
||||
|
||||
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
||||
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
|
||||
// FIRST: Call original to let other extensions add their options
|
||||
originalGetExtraMenuOptions?.apply(this, arguments as any);
|
||||
|
||||
const self = this;
|
||||
|
||||
// Debug: Log all menu options AFTER other extensions have added theirs
|
||||
log.info("Available menu options AFTER original call:", options.map((opt, idx) => ({
|
||||
index: idx,
|
||||
content: opt?.content,
|
||||
hasCallback: !!opt?.callback
|
||||
})));
|
||||
|
||||
// Debug: Check node data to see what Impact Pack sees
|
||||
const nodeData = (self as any).constructor.nodeData || {};
|
||||
log.info("Node data for Impact Pack check:", {
|
||||
output: nodeData.output,
|
||||
outputType: typeof nodeData.output,
|
||||
isArray: Array.isArray(nodeData.output),
|
||||
nodeType: (self as any).type,
|
||||
comfyClass: (self as any).comfyClass
|
||||
});
|
||||
|
||||
// Additional debug: Check if any option contains common Impact Pack keywords
|
||||
const impactOptions = options.filter((opt, idx) => {
|
||||
if (!opt || !opt.content) return false;
|
||||
const content = opt.content.toLowerCase();
|
||||
return content.includes('impact') ||
|
||||
content.includes('sam') ||
|
||||
content.includes('detector') ||
|
||||
content.includes('segment') ||
|
||||
content.includes('mask') ||
|
||||
content.includes('open in');
|
||||
});
|
||||
|
||||
if (impactOptions.length > 0) {
|
||||
log.info("Found potential Impact Pack options:", impactOptions.map(opt => opt.content));
|
||||
} else {
|
||||
log.info("No Impact Pack-related options found in menu");
|
||||
}
|
||||
|
||||
// Debug: Check if Impact Pack extension is loaded
|
||||
const impactExtensions = app.extensions.filter((ext: any) =>
|
||||
ext.name && ext.name.toLowerCase().includes('impact')
|
||||
);
|
||||
log.info("Impact Pack extensions found:", impactExtensions.map((ext: any) => ext.name));
|
||||
|
||||
// Debug: Check menu options again after a delay to see if Impact Pack adds options later
|
||||
setTimeout(() => {
|
||||
log.info("Menu options after 100ms delay:", options.map((opt, idx) => ({
|
||||
index: idx,
|
||||
content: opt?.content,
|
||||
hasCallback: !!opt?.callback
|
||||
})));
|
||||
|
||||
// Try to find SAM Detector again
|
||||
const delayedSamDetectorIndex = options.findIndex((option) =>
|
||||
option && option.content && (
|
||||
option.content.includes("SAM Detector") ||
|
||||
option.content.includes("SAM") ||
|
||||
option.content.includes("Detector") ||
|
||||
option.content.toLowerCase().includes("sam") ||
|
||||
option.content.toLowerCase().includes("detector")
|
||||
)
|
||||
);
|
||||
|
||||
if (delayedSamDetectorIndex !== -1) {
|
||||
log.info(`Found SAM Detector after delay at index ${delayedSamDetectorIndex}: "${options[delayedSamDetectorIndex].content}"`);
|
||||
} else {
|
||||
log.info("SAM Detector still not found after delay");
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Debug: Let's also check what the Impact Pack extension actually does
|
||||
const samExtension = app.extensions.find((ext: any) => ext.name === 'Comfy.Impact.SAMEditor');
|
||||
if (samExtension) {
|
||||
log.info("SAM Extension details:", {
|
||||
name: samExtension.name,
|
||||
hasBeforeRegisterNodeDef: !!samExtension.beforeRegisterNodeDef,
|
||||
hasInit: !!samExtension.init
|
||||
});
|
||||
}
|
||||
|
||||
// Remove our old MaskEditor if it exists
|
||||
const maskEditorIndex = options.findIndex((option) => option && option.content === "Open in MaskEditor");
|
||||
if (maskEditorIndex !== -1) {
|
||||
options.splice(maskEditorIndex, 1);
|
||||
}
|
||||
|
||||
// Hook into "Open in SAM Detector" using the new integration module
|
||||
setupSAMDetectorHook(self, options);
|
||||
|
||||
const newOptions = [
|
||||
{
|
||||
content: "Open in MaskEditor",
|
||||
@@ -923,11 +1230,11 @@ app.registerExtension({
|
||||
await (self as any).canvasWidget.startMaskEditor(null, true);
|
||||
} else {
|
||||
log.error("Canvas widget not available");
|
||||
alert("Canvas not ready. Please try again.");
|
||||
showErrorNotification("Canvas not ready. Please try again.");
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error("Error opening MaskEditor:", e);
|
||||
alert(`Failed to open MaskEditor: ${e.message}`);
|
||||
showErrorNotification(`Failed to open MaskEditor: ${e.message}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -973,7 +1280,7 @@ app.registerExtension({
|
||||
log.info("Image copied to clipboard.");
|
||||
} catch (e) {
|
||||
log.error("Error copying image:", e);
|
||||
alert("Failed to copy image to clipboard.");
|
||||
showErrorNotification("Failed to copy image to clipboard.");
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -989,7 +1296,7 @@ app.registerExtension({
|
||||
log.info("Image with mask alpha copied to clipboard.");
|
||||
} catch (e) {
|
||||
log.error("Error copying image with mask:", e);
|
||||
alert("Failed to copy image with mask to clipboard.");
|
||||
showErrorNotification("Failed to copy image with mask to clipboard.");
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
729
src/CustomShapeMenu.ts
Normal file
729
src/CustomShapeMenu.ts
Normal file
@@ -0,0 +1,729 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
|
||||
const log = createModuleLogger('CustomShapeMenu');
|
||||
|
||||
export class CustomShapeMenu {
|
||||
private canvas: Canvas;
|
||||
private element: HTMLDivElement | null;
|
||||
private worldX: number;
|
||||
private worldY: number;
|
||||
private uiInitialized: boolean;
|
||||
private tooltip: HTMLDivElement | null;
|
||||
private isMinimized: boolean = false;
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
this.canvas = canvas;
|
||||
this.element = null;
|
||||
this.worldX = 0;
|
||||
this.worldY = 0;
|
||||
this.uiInitialized = false;
|
||||
this.tooltip = null;
|
||||
}
|
||||
|
||||
show(): void {
|
||||
if (!this.canvas.outputAreaShape) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._createUI();
|
||||
|
||||
if (this.element) {
|
||||
this.element.style.display = 'block';
|
||||
this._updateMinimizedState();
|
||||
}
|
||||
|
||||
// Position in top-left corner of viewport (closer to edge)
|
||||
const viewLeft = this.canvas.viewport.x;
|
||||
const viewTop = this.canvas.viewport.y;
|
||||
this.worldX = viewLeft + (8 / this.canvas.viewport.zoom);
|
||||
this.worldY = viewTop + (8 / this.canvas.viewport.zoom);
|
||||
|
||||
this.updateScreenPosition();
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
this.element = null;
|
||||
this.uiInitialized = false;
|
||||
}
|
||||
this.hideTooltip();
|
||||
}
|
||||
|
||||
updateScreenPosition(): void {
|
||||
if (!this.element) return;
|
||||
|
||||
const screenX = (this.worldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (this.worldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
|
||||
this.element.style.transform = `translate(${screenX}px, ${screenY}px)`;
|
||||
}
|
||||
|
||||
private _createUI(): void {
|
||||
if (this.uiInitialized) return;
|
||||
|
||||
addStylesheet(getUrl('./css/custom_shape_menu.css'));
|
||||
|
||||
this.element = document.createElement('div');
|
||||
this.element.id = 'layerforge-custom-shape-menu';
|
||||
|
||||
// --- MINIMIZED BAR ---
|
||||
const minimizedBar = document.createElement('div');
|
||||
minimizedBar.className = 'custom-shape-minimized-bar';
|
||||
minimizedBar.textContent = "Custom Output Area Active";
|
||||
minimizedBar.style.display = 'none';
|
||||
minimizedBar.style.cursor = 'pointer';
|
||||
minimizedBar.onclick = () => {
|
||||
this.isMinimized = false;
|
||||
this._updateMinimizedState();
|
||||
};
|
||||
this.element.appendChild(minimizedBar);
|
||||
|
||||
// --- FULL MENU ---
|
||||
const fullMenu = document.createElement('div');
|
||||
fullMenu.className = 'custom-shape-full-menu';
|
||||
|
||||
// Minimize button (top right)
|
||||
const minimizeBtn = document.createElement('button');
|
||||
minimizeBtn.innerHTML = "–";
|
||||
minimizeBtn.title = "Minimize menu";
|
||||
minimizeBtn.className = 'custom-shape-minimize-btn';
|
||||
minimizeBtn.style.position = 'absolute';
|
||||
minimizeBtn.style.top = '4px';
|
||||
minimizeBtn.style.right = '4px';
|
||||
minimizeBtn.style.width = '24px';
|
||||
minimizeBtn.style.height = '24px';
|
||||
minimizeBtn.style.border = 'none';
|
||||
minimizeBtn.style.background = 'transparent';
|
||||
minimizeBtn.style.color = '#888';
|
||||
minimizeBtn.style.fontSize = '20px';
|
||||
minimizeBtn.style.cursor = 'pointer';
|
||||
minimizeBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.isMinimized = true;
|
||||
this._updateMinimizedState();
|
||||
};
|
||||
fullMenu.appendChild(minimizeBtn);
|
||||
|
||||
// Create menu content
|
||||
const lines = [
|
||||
"Custom Output Area Active"
|
||||
];
|
||||
|
||||
lines.forEach(line => {
|
||||
const lineElement = document.createElement('div');
|
||||
lineElement.textContent = line;
|
||||
lineElement.className = 'menu-line';
|
||||
fullMenu.appendChild(lineElement);
|
||||
});
|
||||
|
||||
// Create a container for the entire shape mask feature set
|
||||
const featureContainer = document.createElement('div');
|
||||
featureContainer.id = 'shape-mask-feature-container';
|
||||
featureContainer.className = 'feature-container';
|
||||
|
||||
// Add main auto-apply checkbox to the new container
|
||||
const checkboxContainer = this._createCheckbox(
|
||||
'auto-apply-checkbox',
|
||||
() => this.canvas.autoApplyShapeMask,
|
||||
'Auto-apply shape mask',
|
||||
(e) => {
|
||||
this.canvas.autoApplyShapeMask = (e.target as HTMLInputElement).checked;
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
log.info("Auto-apply shape mask enabled - mask applied automatically");
|
||||
} else {
|
||||
this.canvas.maskTool.removeShapeMask();
|
||||
this.canvas.shapeMaskExpansion = false;
|
||||
this.canvas.shapeMaskFeather = false;
|
||||
log.info("Auto-apply shape mask disabled - mask area removed and sub-options reset.");
|
||||
}
|
||||
this._updateUI();
|
||||
this.canvas.render();
|
||||
},
|
||||
"Automatically applies a mask based on the custom output area shape. When enabled, the mask will be applied to all layers within the shape boundary."
|
||||
);
|
||||
featureContainer.appendChild(checkboxContainer);
|
||||
|
||||
// Add expansion checkbox
|
||||
const expansionContainer = this._createCheckbox(
|
||||
'expansion-checkbox',
|
||||
() => this.canvas.shapeMaskExpansion,
|
||||
'Expand/Contract mask',
|
||||
(e) => {
|
||||
this.canvas.shapeMaskExpansion = (e.target as HTMLInputElement).checked;
|
||||
this._updateUI();
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.hideShapePreview();
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
this.canvas.render();
|
||||
}
|
||||
},
|
||||
"Dilate (expand) or erode (contract) the shape mask. Positive values expand the mask outward, negative values shrink it inward."
|
||||
);
|
||||
featureContainer.appendChild(expansionContainer);
|
||||
|
||||
// Add expansion slider container
|
||||
const expansionSliderContainer = document.createElement('div');
|
||||
expansionSliderContainer.id = 'expansion-slider-container';
|
||||
expansionSliderContainer.className = 'slider-container';
|
||||
|
||||
const expansionSliderLabel = document.createElement('div');
|
||||
expansionSliderLabel.textContent = 'Expansion amount:';
|
||||
expansionSliderLabel.className = 'slider-label';
|
||||
|
||||
const expansionSlider = document.createElement('input');
|
||||
expansionSlider.type = 'range';
|
||||
expansionSlider.min = '-300';
|
||||
expansionSlider.max = '300';
|
||||
expansionSlider.value = String(this.canvas.shapeMaskExpansionValue);
|
||||
|
||||
const expansionValueDisplay = document.createElement('div');
|
||||
expansionValueDisplay.className = 'slider-value-display';
|
||||
|
||||
let expansionValueBeforeDrag = this.canvas.shapeMaskExpansionValue;
|
||||
|
||||
const updateExpansionSliderDisplay = () => {
|
||||
const value = parseInt(expansionSlider.value);
|
||||
this.canvas.shapeMaskExpansionValue = value;
|
||||
expansionValueDisplay.textContent = value > 0 ? `+${value}px` : `${value}px`;
|
||||
};
|
||||
|
||||
let isExpansionDragging = false;
|
||||
|
||||
expansionSlider.onmousedown = () => {
|
||||
isExpansionDragging = true;
|
||||
expansionValueBeforeDrag = this.canvas.shapeMaskExpansionValue; // Store value before dragging
|
||||
};
|
||||
|
||||
expansionSlider.oninput = () => {
|
||||
updateExpansionSliderDisplay();
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
if (isExpansionDragging) {
|
||||
const featherValue = this.canvas.shapeMaskFeather ? this.canvas.shapeMaskFeatherValue : 0;
|
||||
this.canvas.maskTool.showShapePreview(this.canvas.shapeMaskExpansionValue, featherValue);
|
||||
} else {
|
||||
this.canvas.maskTool.hideShapePreview();
|
||||
this.canvas.maskTool.applyShapeMask(false);
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expansionSlider.onmouseup = () => {
|
||||
isExpansionDragging = false;
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
const finalValue = parseInt(expansionSlider.value);
|
||||
|
||||
// If value changed during drag, remove old mask with previous expansion value
|
||||
if (expansionValueBeforeDrag !== finalValue) {
|
||||
// Temporarily set the previous value to remove the old mask properly
|
||||
const tempValue = this.canvas.shapeMaskExpansionValue;
|
||||
this.canvas.shapeMaskExpansionValue = expansionValueBeforeDrag;
|
||||
this.canvas.maskTool.removeShapeMask();
|
||||
this.canvas.shapeMaskExpansionValue = tempValue; // Restore current value
|
||||
log.info(`Removed old shape mask with expansion: ${expansionValueBeforeDrag}px before applying new value: ${finalValue}px`);
|
||||
}
|
||||
|
||||
this.canvas.maskTool.hideShapePreview();
|
||||
this.canvas.maskTool.applyShapeMask(true);
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
|
||||
updateExpansionSliderDisplay();
|
||||
|
||||
expansionSliderContainer.appendChild(expansionSliderLabel);
|
||||
expansionSliderContainer.appendChild(expansionSlider);
|
||||
expansionSliderContainer.appendChild(expansionValueDisplay);
|
||||
featureContainer.appendChild(expansionSliderContainer);
|
||||
|
||||
// Add feather checkbox
|
||||
const featherContainer = this._createCheckbox(
|
||||
'feather-checkbox',
|
||||
() => this.canvas.shapeMaskFeather,
|
||||
'Feather edges',
|
||||
(e) => {
|
||||
this.canvas.shapeMaskFeather = (e.target as HTMLInputElement).checked;
|
||||
this._updateUI();
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.hideShapePreview();
|
||||
this.canvas.maskTool.applyShapeMask();
|
||||
this.canvas.render();
|
||||
}
|
||||
},
|
||||
"Softens the edges of the shape mask by creating a gradual transition from opaque to transparent."
|
||||
);
|
||||
featureContainer.appendChild(featherContainer);
|
||||
|
||||
// Add feather slider container
|
||||
const featherSliderContainer = document.createElement('div');
|
||||
featherSliderContainer.id = 'feather-slider-container';
|
||||
featherSliderContainer.className = 'slider-container';
|
||||
|
||||
const featherSliderLabel = document.createElement('div');
|
||||
featherSliderLabel.textContent = 'Feather amount:';
|
||||
featherSliderLabel.className = 'slider-label';
|
||||
|
||||
const featherSlider = document.createElement('input');
|
||||
featherSlider.type = 'range';
|
||||
featherSlider.min = '0';
|
||||
featherSlider.max = '300';
|
||||
featherSlider.value = String(this.canvas.shapeMaskFeatherValue);
|
||||
|
||||
const featherValueDisplay = document.createElement('div');
|
||||
featherValueDisplay.className = 'slider-value-display';
|
||||
|
||||
const updateFeatherSliderDisplay = () => {
|
||||
const value = parseInt(featherSlider.value);
|
||||
this.canvas.shapeMaskFeatherValue = value;
|
||||
featherValueDisplay.textContent = `${value}px`;
|
||||
};
|
||||
|
||||
let isFeatherDragging = false;
|
||||
|
||||
featherSlider.onmousedown = () => { isFeatherDragging = true; };
|
||||
|
||||
featherSlider.oninput = () => {
|
||||
updateFeatherSliderDisplay();
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
if (isFeatherDragging) {
|
||||
const expansionValue = this.canvas.shapeMaskExpansion ? this.canvas.shapeMaskExpansionValue : 0;
|
||||
this.canvas.maskTool.showShapePreview(expansionValue, this.canvas.shapeMaskFeatherValue);
|
||||
} else {
|
||||
this.canvas.maskTool.hideShapePreview();
|
||||
this.canvas.maskTool.applyShapeMask(false);
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
featherSlider.onmouseup = () => {
|
||||
isFeatherDragging = false;
|
||||
if (this.canvas.autoApplyShapeMask) {
|
||||
this.canvas.maskTool.hideShapePreview();
|
||||
this.canvas.maskTool.applyShapeMask(true); // true = save state
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
|
||||
updateFeatherSliderDisplay();
|
||||
|
||||
featherSliderContainer.appendChild(featherSliderLabel);
|
||||
featherSliderContainer.appendChild(featherSlider);
|
||||
featherSliderContainer.appendChild(featherValueDisplay);
|
||||
featureContainer.appendChild(featherSliderContainer);
|
||||
|
||||
fullMenu.appendChild(featureContainer);
|
||||
|
||||
// Create output area extension container
|
||||
const extensionContainer = document.createElement('div');
|
||||
extensionContainer.id = 'output-area-extension-container';
|
||||
extensionContainer.className = 'feature-container';
|
||||
|
||||
// Add main extension checkbox
|
||||
const extensionCheckboxContainer = this._createCheckbox(
|
||||
'extension-checkbox',
|
||||
() => this.canvas.outputAreaExtensionEnabled,
|
||||
'Extend output area',
|
||||
(e) => {
|
||||
this.canvas.outputAreaExtensionEnabled = (e.target as HTMLInputElement).checked;
|
||||
if (this.canvas.outputAreaExtensionEnabled) {
|
||||
this.canvas.originalCanvasSize = { width: this.canvas.width, height: this.canvas.height };
|
||||
this.canvas.outputAreaExtensions = { ...this.canvas.lastOutputAreaExtensions };
|
||||
} else {
|
||||
this.canvas.lastOutputAreaExtensions = { ...this.canvas.outputAreaExtensions };
|
||||
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
|
||||
}
|
||||
this._updateExtensionUI();
|
||||
this._updateCanvasSize();
|
||||
this.canvas.render();
|
||||
},
|
||||
"Allows extending the output area boundaries in all directions without changing the custom shape."
|
||||
);
|
||||
extensionContainer.appendChild(extensionCheckboxContainer);
|
||||
|
||||
// Create sliders container
|
||||
const slidersContainer = document.createElement('div');
|
||||
slidersContainer.id = 'extension-sliders-container';
|
||||
slidersContainer.className = 'slider-container';
|
||||
|
||||
// Helper function to create a slider with preview system
|
||||
const createExtensionSlider = (label: string, direction: 'top' | 'bottom' | 'left' | 'right') => {
|
||||
const sliderContainer = document.createElement('div');
|
||||
sliderContainer.className = 'extension-slider-container';
|
||||
|
||||
const sliderLabel = document.createElement('div');
|
||||
sliderLabel.textContent = label;
|
||||
sliderLabel.className = 'slider-label';
|
||||
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.min = '0';
|
||||
slider.max = '500';
|
||||
slider.value = String(this.canvas.outputAreaExtensions[direction]);
|
||||
|
||||
const valueDisplay = document.createElement('div');
|
||||
valueDisplay.className = 'slider-value-display';
|
||||
|
||||
const updateDisplay = () => {
|
||||
const value = parseInt(slider.value);
|
||||
valueDisplay.textContent = `${value}px`;
|
||||
};
|
||||
|
||||
let isDragging = false;
|
||||
|
||||
slider.onmousedown = () => {
|
||||
isDragging = true;
|
||||
};
|
||||
|
||||
slider.oninput = () => {
|
||||
updateDisplay();
|
||||
|
||||
if (isDragging) {
|
||||
// During dragging, show preview
|
||||
const previewExtensions = { ...this.canvas.outputAreaExtensions };
|
||||
previewExtensions[direction] = parseInt(slider.value);
|
||||
this.canvas.outputAreaExtensionPreview = previewExtensions;
|
||||
this.canvas.render();
|
||||
} else {
|
||||
// Not dragging, apply immediately (for keyboard navigation)
|
||||
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
|
||||
this._updateCanvasSize();
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
|
||||
slider.onmouseup = () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
// Apply the final value and clear preview
|
||||
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
|
||||
this.canvas.outputAreaExtensionPreview = null;
|
||||
this._updateCanvasSize();
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle mouse leave (in case user drags outside)
|
||||
slider.onmouseleave = () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
// Apply the final value and clear preview
|
||||
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
|
||||
this.canvas.outputAreaExtensionPreview = null;
|
||||
this._updateCanvasSize();
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
|
||||
updateDisplay();
|
||||
|
||||
sliderContainer.appendChild(sliderLabel);
|
||||
sliderContainer.appendChild(slider);
|
||||
sliderContainer.appendChild(valueDisplay);
|
||||
return sliderContainer;
|
||||
};
|
||||
|
||||
// Add all four sliders
|
||||
slidersContainer.appendChild(createExtensionSlider('Top extension:', 'top'));
|
||||
slidersContainer.appendChild(createExtensionSlider('Bottom extension:', 'bottom'));
|
||||
slidersContainer.appendChild(createExtensionSlider('Left extension:', 'left'));
|
||||
slidersContainer.appendChild(createExtensionSlider('Right extension:', 'right'));
|
||||
|
||||
extensionContainer.appendChild(slidersContainer);
|
||||
fullMenu.appendChild(extensionContainer);
|
||||
|
||||
this.element.appendChild(fullMenu);
|
||||
|
||||
// Add to DOM
|
||||
if (this.canvas.canvas.parentElement) {
|
||||
this.canvas.canvas.parentElement.appendChild(this.element);
|
||||
} else {
|
||||
log.error("Could not find parent node to attach custom shape menu.");
|
||||
}
|
||||
|
||||
this.uiInitialized = true;
|
||||
this._updateUI();
|
||||
this._updateMinimizedState();
|
||||
|
||||
// Add viewport change listener to update shape preview when zooming/panning
|
||||
this._addViewportChangeListener();
|
||||
}
|
||||
|
||||
private _createCheckbox(
|
||||
id: string,
|
||||
getChecked: () => boolean,
|
||||
text: string,
|
||||
clickHandler: (e: Event) => void,
|
||||
tooltipText?: string
|
||||
): HTMLLabelElement {
|
||||
const container = document.createElement('label');
|
||||
container.className = 'checkbox-container';
|
||||
container.htmlFor = id;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.id = id;
|
||||
input.checked = getChecked();
|
||||
|
||||
const customCheckbox = document.createElement('div');
|
||||
customCheckbox.className = 'custom-checkbox';
|
||||
|
||||
const labelText = document.createElement('span');
|
||||
labelText.textContent = text;
|
||||
|
||||
container.appendChild(input);
|
||||
container.appendChild(customCheckbox);
|
||||
container.appendChild(labelText);
|
||||
|
||||
// Stop propagation to prevent menu from closing, but allow default checkbox behavior
|
||||
container.onclick = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
input.onchange = (e: Event) => {
|
||||
clickHandler(e);
|
||||
};
|
||||
|
||||
if (tooltipText) {
|
||||
this._addTooltip(container, tooltipText);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
private _updateUI(): void {
|
||||
if (!this.element) return;
|
||||
|
||||
// Always update only the full menu part
|
||||
const fullMenu = this.element.querySelector('.custom-shape-full-menu') as HTMLElement;
|
||||
if (!fullMenu) return;
|
||||
|
||||
const setChecked = (id: string, checked: boolean) => {
|
||||
const input = fullMenu.querySelector(`#${id}`) as HTMLInputElement;
|
||||
if (input) input.checked = checked;
|
||||
};
|
||||
|
||||
setChecked('auto-apply-checkbox', this.canvas.autoApplyShapeMask);
|
||||
setChecked('expansion-checkbox', this.canvas.shapeMaskExpansion);
|
||||
setChecked('feather-checkbox', this.canvas.shapeMaskFeather);
|
||||
setChecked('extension-checkbox', this.canvas.outputAreaExtensionEnabled);
|
||||
|
||||
const expansionCheckbox = fullMenu.querySelector('#expansion-checkbox')?.parentElement as HTMLElement;
|
||||
if (expansionCheckbox) {
|
||||
expansionCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const featherCheckbox = fullMenu.querySelector('#feather-checkbox')?.parentElement as HTMLElement;
|
||||
if (featherCheckbox) {
|
||||
featherCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const expansionSliderContainer = fullMenu.querySelector('#expansion-slider-container') as HTMLElement;
|
||||
if (expansionSliderContainer) {
|
||||
expansionSliderContainer.style.display = (this.canvas.autoApplyShapeMask && this.canvas.shapeMaskExpansion) ? 'block' : 'none';
|
||||
}
|
||||
|
||||
const featherSliderContainer = fullMenu.querySelector('#feather-slider-container') as HTMLElement;
|
||||
if (featherSliderContainer) {
|
||||
featherSliderContainer.style.display = (this.canvas.autoApplyShapeMask && this.canvas.shapeMaskFeather) ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
private _updateMinimizedState(): void {
|
||||
if (!this.element) return;
|
||||
const minimizedBar = this.element.querySelector('.custom-shape-minimized-bar') as HTMLElement;
|
||||
const fullMenu = this.element.querySelector('.custom-shape-full-menu') as HTMLElement;
|
||||
if (this.isMinimized) {
|
||||
minimizedBar.style.display = 'block';
|
||||
fullMenu.style.display = 'none';
|
||||
} else {
|
||||
minimizedBar.style.display = 'none';
|
||||
fullMenu.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
private _updateExtensionUI(): void {
|
||||
if (!this.element) return;
|
||||
|
||||
// Toggle visibility of extension sliders based on the extension checkbox state
|
||||
const extensionSlidersContainer = this.element.querySelector('#extension-sliders-container') as HTMLElement;
|
||||
if (extensionSlidersContainer) {
|
||||
extensionSlidersContainer.style.display = this.canvas.outputAreaExtensionEnabled ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Update slider values if they exist
|
||||
if (this.canvas.outputAreaExtensionEnabled) {
|
||||
const sliders = extensionSlidersContainer?.querySelectorAll('input[type="range"]');
|
||||
const directions: ('top' | 'bottom' | 'left' | 'right')[] = ['top', 'bottom', 'left', 'right'];
|
||||
|
||||
sliders?.forEach((slider, index) => {
|
||||
const direction = directions[index];
|
||||
if (direction) {
|
||||
(slider as HTMLInputElement).value = String(this.canvas.outputAreaExtensions[direction]);
|
||||
// Update the corresponding value display
|
||||
const valueDisplay = slider.parentElement?.querySelector('div:last-child');
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = `${this.canvas.outputAreaExtensions[direction]}px`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add viewport change listener to update shape preview when zooming/panning
|
||||
*/
|
||||
private _addViewportChangeListener(): void {
|
||||
// Store previous viewport state to detect changes
|
||||
let previousViewport = {
|
||||
x: this.canvas.viewport.x,
|
||||
y: this.canvas.viewport.y,
|
||||
zoom: this.canvas.viewport.zoom
|
||||
};
|
||||
|
||||
// Check for viewport changes in render loop
|
||||
const checkViewportChange = () => {
|
||||
if (this.canvas.maskTool.shapePreviewVisible) {
|
||||
const current = this.canvas.viewport;
|
||||
|
||||
// Check if viewport has changed
|
||||
if (current.x !== previousViewport.x ||
|
||||
current.y !== previousViewport.y ||
|
||||
current.zoom !== previousViewport.zoom) {
|
||||
|
||||
// Update shape preview with current expansion/feather values
|
||||
const expansionValue = this.canvas.shapeMaskExpansionValue || 0;
|
||||
const featherValue = this.canvas.shapeMaskFeather ? (this.canvas.shapeMaskFeatherValue || 0) : 0;
|
||||
this.canvas.maskTool.showShapePreview(expansionValue, featherValue);
|
||||
|
||||
// Update previous viewport state
|
||||
previousViewport = {
|
||||
x: current.x,
|
||||
y: current.y,
|
||||
zoom: current.zoom
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Continue checking if UI is still active
|
||||
if (this.uiInitialized) {
|
||||
requestAnimationFrame(checkViewportChange);
|
||||
}
|
||||
};
|
||||
|
||||
// Start the viewport change detection
|
||||
requestAnimationFrame(checkViewportChange);
|
||||
}
|
||||
|
||||
private _addTooltip(element: HTMLElement, text: string): void {
|
||||
element.addEventListener('mouseenter', (e) => {
|
||||
this.showTooltip(text, e);
|
||||
});
|
||||
|
||||
element.addEventListener('mouseleave', () => {
|
||||
this.hideTooltip();
|
||||
});
|
||||
|
||||
element.addEventListener('mousemove', (e) => {
|
||||
if (this.tooltip && this.tooltip.style.display === 'block') {
|
||||
this.updateTooltipPosition(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private showTooltip(text: string, event: MouseEvent): void {
|
||||
this.hideTooltip(); // Hide any existing tooltip
|
||||
|
||||
this.tooltip = document.createElement('div');
|
||||
this.tooltip.textContent = text;
|
||||
this.tooltip.className = 'layerforge-tooltip';
|
||||
|
||||
document.body.appendChild(this.tooltip);
|
||||
this.updateTooltipPosition(event);
|
||||
|
||||
// Fade in the tooltip
|
||||
requestAnimationFrame(() => {
|
||||
if (this.tooltip) {
|
||||
this.tooltip.style.opacity = '1';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateTooltipPosition(event: MouseEvent): void {
|
||||
if (!this.tooltip) return;
|
||||
|
||||
const tooltipRect = this.tooltip.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let x = event.clientX + 10;
|
||||
let y = event.clientY - 10;
|
||||
|
||||
// Adjust if tooltip would go off the right edge
|
||||
if (x + tooltipRect.width > viewportWidth) {
|
||||
x = event.clientX - tooltipRect.width - 10;
|
||||
}
|
||||
|
||||
// Adjust if tooltip would go off the bottom edge
|
||||
if (y + tooltipRect.height > viewportHeight) {
|
||||
y = event.clientY - tooltipRect.height - 10;
|
||||
}
|
||||
|
||||
// Ensure tooltip doesn't go off the left or top edges
|
||||
x = Math.max(5, x);
|
||||
y = Math.max(5, y);
|
||||
|
||||
this.tooltip.style.left = `${x}px`;
|
||||
this.tooltip.style.top = `${y}px`;
|
||||
}
|
||||
|
||||
private hideTooltip(): void {
|
||||
if (this.tooltip) {
|
||||
this.tooltip.remove();
|
||||
this.tooltip = null;
|
||||
}
|
||||
}
|
||||
|
||||
public _updateCanvasSize(): void {
|
||||
if (!this.canvas.outputAreaExtensionEnabled) {
|
||||
// When extensions are disabled, return to original custom shape position
|
||||
// Use originalOutputAreaPosition instead of current bounds position
|
||||
const originalPos = this.canvas.originalOutputAreaPosition;
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: originalPos.x, // ✅ Return to original custom shape position
|
||||
y: originalPos.y, // ✅ Return to original custom shape position
|
||||
width: this.canvas.originalCanvasSize.width,
|
||||
height: this.canvas.originalCanvasSize.height
|
||||
};
|
||||
this.canvas.updateOutputAreaSize(
|
||||
this.canvas.originalCanvasSize.width,
|
||||
this.canvas.originalCanvasSize.height,
|
||||
false
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = this.canvas.outputAreaExtensions;
|
||||
const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right;
|
||||
const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom;
|
||||
|
||||
// When extensions are enabled, calculate new bounds relative to original custom shape position
|
||||
const originalPos = this.canvas.originalOutputAreaPosition;
|
||||
this.canvas.outputAreaBounds = {
|
||||
x: originalPos.x - ext.left, // Adjust position by left extension from original position
|
||||
y: originalPos.y - ext.top, // Adjust position by top extension from original position
|
||||
width: newWidth,
|
||||
height: newHeight
|
||||
};
|
||||
|
||||
// Zmień rozmiar canvas (fizyczny rozmiar dla renderowania)
|
||||
this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
|
||||
|
||||
log.info(`Output area bounds updated: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${newWidth}, h=${newHeight}`);
|
||||
log.info(`Extensions: top=${ext.top}, bottom=${ext.bottom}, left=${ext.left}, right=${ext.right}`);
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,17 @@ import {ComfyApp} from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import {api} from "../../scripts/api.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { showErrorNotification } from "./utils/NotificationUtils.js";
|
||||
import { uploadCanvasAsImage, uploadCanvasWithMaskAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.js";
|
||||
import { processImageToMask, processMaskForViewport } from "./utils/MaskProcessingUtils.js";
|
||||
import { convertToImage } from "./utils/ImageUtils.js";
|
||||
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
||||
import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js";
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
|
||||
const log = createModuleLogger('CanvasMask');
|
||||
const log = createModuleLogger('MaskEditorIntegration');
|
||||
|
||||
export class CanvasMask {
|
||||
export class MaskEditorIntegration {
|
||||
canvas: any;
|
||||
editorWasShowing: any;
|
||||
maskEditorCancelled: any;
|
||||
@@ -61,7 +67,7 @@ export class CanvasMask {
|
||||
blob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
} else {
|
||||
log.debug('Getting flattened canvas for mask editor (with mask)');
|
||||
blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor();
|
||||
blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
}
|
||||
|
||||
if (!blob) {
|
||||
@@ -72,34 +78,12 @@ export class CanvasMask {
|
||||
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,
|
||||
// Use ImageUploadUtils to upload the blob
|
||||
const uploadResult = await uploadImageBlob(blob, {
|
||||
filenamePrefix: 'layerforge-mask-edit'
|
||||
});
|
||||
|
||||
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];
|
||||
this.node.imgs = [uploadResult.imageElement];
|
||||
|
||||
log.info('Opening ComfyUI mask editor');
|
||||
ComfyApp.copyToClipspace(this.node);
|
||||
@@ -118,11 +102,58 @@ export class CanvasMask {
|
||||
|
||||
} catch (error) {
|
||||
log.error("Error preparing image for mask editor:", error);
|
||||
alert(`Error: ${(error as Error).message}`);
|
||||
showErrorNotification(`Error: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Oblicza dynamiczny czas oczekiwania na podstawie rozmiaru obrazu
|
||||
* @returns {number} Czas oczekiwania w milisekundach
|
||||
*/
|
||||
calculateDynamicWaitTime(): number {
|
||||
try {
|
||||
// Get canvas dimensions from output area bounds
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const width = bounds.width;
|
||||
const height = bounds.height;
|
||||
|
||||
// Calculate total pixels
|
||||
const totalPixels = width * height;
|
||||
|
||||
// Define wait time based on image size
|
||||
let waitTime = 500; // Base wait time for small images
|
||||
|
||||
if (totalPixels <= 1000 * 1000) {
|
||||
// Below 1MP (1000x1000) - 500ms
|
||||
waitTime = 500;
|
||||
} else if (totalPixels <= 2000 * 2000) {
|
||||
// 1MP to 4MP (2000x2000) - 1000ms
|
||||
waitTime = 1000;
|
||||
} else if (totalPixels <= 4000 * 4000) {
|
||||
// 4MP to 16MP (4000x4000) - 2000ms
|
||||
waitTime = 2000;
|
||||
} else if (totalPixels <= 6000 * 6000) {
|
||||
// 16MP to 36MP (6000x6000) - 4000ms
|
||||
waitTime = 4000;
|
||||
} else {
|
||||
// Above 36MP - 6000ms
|
||||
waitTime = 6000;
|
||||
}
|
||||
|
||||
log.debug("Calculated dynamic wait time", {
|
||||
imageSize: `${width}x${height}`,
|
||||
totalPixels: totalPixels,
|
||||
waitTime: waitTime
|
||||
});
|
||||
|
||||
return waitTime;
|
||||
} catch (error) {
|
||||
log.warn("Error calculating dynamic wait time, using default 1000ms", error);
|
||||
return 1000; // Fallback to 1 second
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Czeka na otwarcie mask editora i automatycznie nakłada predefiniowaną maskę
|
||||
*/
|
||||
@@ -178,12 +209,13 @@ export class CanvasMask {
|
||||
}
|
||||
|
||||
if (editorReady) {
|
||||
|
||||
log.info("Applying mask to editor after", attempts * 100, "ms wait");
|
||||
// Calculate dynamic wait time based on image size
|
||||
const waitTime = this.calculateDynamicWaitTime();
|
||||
log.info("Applying mask to editor after", waitTime, "ms wait (dynamic based on image size)");
|
||||
setTimeout(() => {
|
||||
this.applyMaskToEditor(this.pendingMask);
|
||||
this.pendingMask = null;
|
||||
}, 300);
|
||||
}, waitTime);
|
||||
} else if (attempts < maxAttempts) {
|
||||
|
||||
if (attempts % 10 === 0) {
|
||||
@@ -305,62 +337,24 @@ export class CanvasMask {
|
||||
* @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;
|
||||
*/
|
||||
async processMaskForEditor(maskData: any, targetWidth: any, targetHeight: any, maskColor: any) {
|
||||
// Pozycja maski w świecie względem output bounds
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const maskWorldX = this.maskTool.x;
|
||||
const maskWorldY = this.maskTool.y;
|
||||
const panX = maskWorldX - bounds.x;
|
||||
const panY = maskWorldY - bounds.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;
|
||||
}
|
||||
// Use MaskProcessingUtils for viewport processing
|
||||
return await processMaskForViewport(
|
||||
maskData,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
{ x: panX, y: panY },
|
||||
maskColor
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy obiekt Image z obecnej maski canvas
|
||||
@@ -402,10 +396,7 @@ export class CanvasMask {
|
||||
}
|
||||
|
||||
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});
|
||||
const { canvas: savedCanvas, ctx: savedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', {willReadFrequently: true});
|
||||
if (savedCtx) {
|
||||
savedCtx.drawImage(maskCanvas, 0, 0);
|
||||
}
|
||||
@@ -499,64 +490,28 @@ export class CanvasMask {
|
||||
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});
|
||||
// Process image to mask using MaskProcessingUtils
|
||||
log.debug("Processing image to mask using utils");
|
||||
const bounds = this.canvas.outputAreaBounds;
|
||||
const processedMask = await processImageToMask(resultImage, {
|
||||
targetWidth: bounds.width,
|
||||
targetHeight: bounds.height,
|
||||
invertAlpha: true
|
||||
});
|
||||
|
||||
if (tempCtx) {
|
||||
tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height);
|
||||
// Convert processed mask to image
|
||||
const maskAsImage = await convertToImage(processedMask);
|
||||
|
||||
log.debug("Processing image data to create mask");
|
||||
const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const data = imageData.data;
|
||||
log.debug("Applying mask using chunk system", {
|
||||
boundsPos: {x: bounds.x, y: bounds.y},
|
||||
maskSize: {width: bounds.width, height: bounds.height}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
// Use the chunk system instead of direct canvas manipulation
|
||||
this.maskTool.setMask(maskAsImage);
|
||||
|
||||
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();
|
||||
// Update node preview using PreviewUtils
|
||||
await updateNodePreview(this.canvas, this.node, true);
|
||||
|
||||
this.savedMaskState = null;
|
||||
log.info("Mask editor result processed successfully");
|
||||
1980
src/MaskTool.ts
1980
src/MaskTool.ts
File diff suppressed because it is too large
Load Diff
449
src/SAMDetectorIntegration.ts
Normal file
449
src/SAMDetectorIntegration.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
// @ts-ignore
|
||||
import { ComfyApp } from "../../scripts/app.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { showInfoNotification, showSuccessNotification, showErrorNotification } from "./utils/NotificationUtils.js";
|
||||
import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.js";
|
||||
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
|
||||
import { convertToImage } from "./utils/ImageUtils.js";
|
||||
import { updateNodePreview } from "./utils/PreviewUtils.js";
|
||||
import type { ComfyNode } from './types';
|
||||
|
||||
const log = createModuleLogger('SAMDetectorIntegration');
|
||||
|
||||
/**
|
||||
* SAM Detector Integration for LayerForge
|
||||
* Handles automatic clipspace integration and mask application from Impact Pack's SAM Detector
|
||||
*/
|
||||
|
||||
// Function to register image in clipspace for Impact Pack compatibility
|
||||
export const registerImageInClipspace = async (node: ComfyNode, blob: Blob): Promise<HTMLImageElement | null> => {
|
||||
try {
|
||||
// Use ImageUploadUtils to upload the blob
|
||||
const uploadResult = await uploadImageBlob(blob, {
|
||||
filenamePrefix: 'layerforge-sam',
|
||||
nodeId: node.id
|
||||
});
|
||||
|
||||
log.debug(`Image registered in clipspace for node ${node.id}: ${uploadResult.filename}`);
|
||||
return uploadResult.imageElement;
|
||||
} catch (error) {
|
||||
log.debug("Failed to register image in clipspace:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to monitor for SAM Detector modal closure and apply masks to LayerForge
|
||||
export function startSAMDetectorMonitoring(node: ComfyNode) {
|
||||
if ((node as any).samMonitoringActive) {
|
||||
log.debug("SAM Detector monitoring already active for node", node.id);
|
||||
return;
|
||||
}
|
||||
|
||||
(node as any).samMonitoringActive = true;
|
||||
log.info("Starting SAM Detector modal monitoring for node", node.id);
|
||||
|
||||
// Store original image source for comparison
|
||||
const originalImgSrc = node.imgs?.[0]?.src;
|
||||
(node as any).samOriginalImgSrc = originalImgSrc;
|
||||
|
||||
// Start monitoring for SAM Detector modal closure
|
||||
monitorSAMDetectorModal(node);
|
||||
}
|
||||
|
||||
// Function to monitor SAM Detector modal closure
|
||||
function monitorSAMDetectorModal(node: ComfyNode) {
|
||||
log.info("Starting SAM Detector modal monitoring for node", node.id);
|
||||
|
||||
// Try to find modal multiple times with increasing delays
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10; // Try for 5 seconds total
|
||||
|
||||
const findModal = () => {
|
||||
attempts++;
|
||||
log.debug(`Looking for SAM Detector modal, attempt ${attempts}/${maxAttempts}`);
|
||||
|
||||
// Look for SAM Detector specific elements instead of generic modal
|
||||
const samCanvas = document.querySelector('#samEditorMaskCanvas') as HTMLElement;
|
||||
const pointsCanvas = document.querySelector('#pointsCanvas') as HTMLElement;
|
||||
const imageCanvas = document.querySelector('#imageCanvas') as HTMLElement;
|
||||
|
||||
// Debug: Log SAM specific elements
|
||||
log.debug(`SAM specific elements found:`, {
|
||||
samCanvas: !!samCanvas,
|
||||
pointsCanvas: !!pointsCanvas,
|
||||
imageCanvas: !!imageCanvas
|
||||
});
|
||||
|
||||
// Find the modal that contains SAM Detector elements
|
||||
let modal: HTMLElement | null = null;
|
||||
if (samCanvas || pointsCanvas || imageCanvas) {
|
||||
// Find the parent modal of SAM elements
|
||||
const samElement = samCanvas || pointsCanvas || imageCanvas;
|
||||
let parent = samElement?.parentElement;
|
||||
while (parent && !parent.classList.contains('comfy-modal')) {
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
modal = parent;
|
||||
}
|
||||
|
||||
if (!modal) {
|
||||
if (attempts < maxAttempts) {
|
||||
log.debug(`SAM Detector modal not found on attempt ${attempts}, retrying in 500ms...`);
|
||||
setTimeout(findModal, 500);
|
||||
return;
|
||||
} else {
|
||||
log.warn("SAM Detector modal not found after all attempts, falling back to polling");
|
||||
// Fallback to old polling method if modal not found
|
||||
monitorSAMDetectorChanges(node);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Found SAM Detector modal, setting up observers", {
|
||||
className: modal.className,
|
||||
id: modal.id,
|
||||
display: window.getComputedStyle(modal).display,
|
||||
children: modal.children.length,
|
||||
hasSamCanvas: !!modal.querySelector('#samEditorMaskCanvas'),
|
||||
hasPointsCanvas: !!modal.querySelector('#pointsCanvas'),
|
||||
hasImageCanvas: !!modal.querySelector('#imageCanvas')
|
||||
});
|
||||
|
||||
// Create a MutationObserver to watch for modal removal or style changes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
// Check if the modal was removed from DOM
|
||||
if (mutation.type === 'childList') {
|
||||
mutation.removedNodes.forEach((removedNode) => {
|
||||
if (removedNode === modal || (removedNode as Element)?.contains?.(modal)) {
|
||||
log.info("SAM Detector modal removed from DOM");
|
||||
handleSAMDetectorModalClosed(node);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if modal style changed to hidden
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
|
||||
const target = mutation.target as HTMLElement;
|
||||
if (target === modal) {
|
||||
const display = window.getComputedStyle(modal).display;
|
||||
if (display === 'none') {
|
||||
log.info("SAM Detector modal hidden via style");
|
||||
// Add delay to allow SAM Detector to process and save the mask
|
||||
setTimeout(() => {
|
||||
handleSAMDetectorModalClosed(node);
|
||||
}, 1000); // 1 second delay
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Observe the document body for child removals (modal removal)
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['style']
|
||||
});
|
||||
|
||||
// Also observe the modal itself for style changes
|
||||
observer.observe(modal, {
|
||||
attributes: true,
|
||||
attributeFilter: ['style']
|
||||
});
|
||||
|
||||
// Store observer reference for cleanup
|
||||
(node as any).samModalObserver = observer;
|
||||
|
||||
// Fallback timeout in case observer doesn't catch the closure
|
||||
setTimeout(() => {
|
||||
if ((node as any).samMonitoringActive) {
|
||||
log.debug("SAM Detector modal monitoring timeout, cleaning up");
|
||||
observer.disconnect();
|
||||
(node as any).samMonitoringActive = false;
|
||||
}
|
||||
}, 60000); // 1 minute timeout
|
||||
|
||||
log.info("SAM Detector modal observers set up successfully");
|
||||
};
|
||||
|
||||
// Start the modal finding process
|
||||
findModal();
|
||||
}
|
||||
|
||||
// Function to handle SAM Detector modal closure
|
||||
function handleSAMDetectorModalClosed(node: ComfyNode) {
|
||||
if (!(node as any).samMonitoringActive) {
|
||||
log.debug("SAM monitoring already inactive for node", node.id);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("SAM Detector modal closed for node", node.id);
|
||||
(node as any).samMonitoringActive = false;
|
||||
|
||||
// Clean up observer
|
||||
if ((node as any).samModalObserver) {
|
||||
(node as any).samModalObserver.disconnect();
|
||||
delete (node as any).samModalObserver;
|
||||
}
|
||||
|
||||
// Check if there's a new image to process
|
||||
if (node.imgs && node.imgs.length > 0) {
|
||||
const currentImgSrc = node.imgs[0].src;
|
||||
const originalImgSrc = (node as any).samOriginalImgSrc;
|
||||
|
||||
if (currentImgSrc && currentImgSrc !== originalImgSrc) {
|
||||
log.info("SAM Detector result detected after modal closure, processing mask...");
|
||||
handleSAMDetectorResult(node, node.imgs[0]);
|
||||
} else {
|
||||
log.info("No new image detected after SAM Detector modal closure");
|
||||
|
||||
// Show info notification
|
||||
showInfoNotification("SAM Detector closed. No mask was applied.");
|
||||
}
|
||||
} else {
|
||||
log.info("No image available after SAM Detector modal closure");
|
||||
}
|
||||
|
||||
// Clean up stored references
|
||||
delete (node as any).samOriginalImgSrc;
|
||||
}
|
||||
|
||||
// Fallback function to monitor changes in node.imgs (old polling approach)
|
||||
function monitorSAMDetectorChanges(node: ComfyNode) {
|
||||
let checkCount = 0;
|
||||
const maxChecks = 300; // 30 seconds maximum monitoring
|
||||
|
||||
const checkForChanges = () => {
|
||||
checkCount++;
|
||||
|
||||
if (!((node as any).samMonitoringActive)) {
|
||||
log.debug("SAM monitoring stopped for node", node.id);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(`SAM monitoring check ${checkCount}/${maxChecks} for node ${node.id}`);
|
||||
|
||||
// Check if the node's image has been updated (this happens when "Save to node" is clicked)
|
||||
if (node.imgs && node.imgs.length > 0) {
|
||||
const currentImgSrc = node.imgs[0].src;
|
||||
const originalImgSrc = (node as any).samOriginalImgSrc;
|
||||
|
||||
if (currentImgSrc && currentImgSrc !== originalImgSrc) {
|
||||
log.info("SAM Detector result detected in node.imgs, processing mask...");
|
||||
handleSAMDetectorResult(node, node.imgs[0]);
|
||||
(node as any).samMonitoringActive = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Continue monitoring if not exceeded max checks
|
||||
if (checkCount < maxChecks && (node as any).samMonitoringActive) {
|
||||
setTimeout(checkForChanges, 100);
|
||||
} else {
|
||||
log.debug("SAM Detector monitoring timeout or stopped for node", node.id);
|
||||
(node as any).samMonitoringActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Start monitoring after a short delay
|
||||
setTimeout(checkForChanges, 500);
|
||||
}
|
||||
|
||||
// Function to handle SAM Detector result (using same logic as MaskEditorIntegration.handleMaskEditorClose)
|
||||
async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageElement) {
|
||||
try {
|
||||
log.info("Handling SAM Detector result for node", node.id);
|
||||
log.debug("Result image source:", resultImage.src.substring(0, 100) + '...');
|
||||
|
||||
const canvasWidget = (node as any).canvasWidget;
|
||||
if (!canvasWidget || !canvasWidget.canvas) {
|
||||
log.error("Canvas widget not available for SAM result processing");
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasWidget; // canvasWidget is the Canvas object, not canvasWidget.canvas
|
||||
|
||||
// Wait for the result image to load (same as MaskEditorIntegration)
|
||||
try {
|
||||
// First check if the image is already loaded
|
||||
if (resultImage.complete && resultImage.naturalWidth > 0) {
|
||||
log.debug("SAM result image already loaded", {
|
||||
width: resultImage.width,
|
||||
height: resultImage.height
|
||||
});
|
||||
} else {
|
||||
// Try to reload the image with a fresh request
|
||||
log.debug("Attempting to reload SAM result image");
|
||||
const originalSrc = resultImage.src;
|
||||
|
||||
// Add cache-busting parameter to force fresh load
|
||||
const url = new URL(originalSrc);
|
||||
url.searchParams.set('_t', Date.now().toString());
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
// Copy the loaded image data to the original image
|
||||
resultImage.src = img.src;
|
||||
resultImage.width = img.width;
|
||||
resultImage.height = img.height;
|
||||
log.debug("SAM result image reloaded successfully", {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
originalSrc: originalSrc,
|
||||
newSrc: img.src
|
||||
});
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = (error) => {
|
||||
log.error("Failed to reload SAM result image", {
|
||||
originalSrc: originalSrc,
|
||||
newSrc: url.toString(),
|
||||
error: error
|
||||
});
|
||||
reject(error);
|
||||
};
|
||||
img.src = url.toString();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to load image from SAM Detector.", error);
|
||||
showErrorNotification("Failed to load SAM Detector result. The mask file may not be available.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Process image to mask using MaskProcessingUtils
|
||||
log.debug("Processing image to mask using utils");
|
||||
const processedMask = await processImageToMask(resultImage, {
|
||||
targetWidth: resultImage.width,
|
||||
targetHeight: resultImage.height,
|
||||
invertAlpha: true
|
||||
});
|
||||
|
||||
// Convert processed mask to image
|
||||
const maskAsImage = await convertToImage(processedMask);
|
||||
|
||||
// Apply mask to LayerForge canvas using MaskTool.setMask method
|
||||
log.debug("Checking canvas and maskTool availability", {
|
||||
hasCanvas: !!canvas,
|
||||
hasMaskTool: !!canvas.maskTool,
|
||||
maskToolType: typeof canvas.maskTool,
|
||||
canvasKeys: Object.keys(canvas)
|
||||
});
|
||||
|
||||
if (!canvas.maskTool) {
|
||||
log.error("MaskTool is not available. Canvas state:", {
|
||||
hasCanvas: !!canvas,
|
||||
canvasConstructor: canvas.constructor.name,
|
||||
canvasKeys: Object.keys(canvas),
|
||||
maskToolValue: canvas.maskTool
|
||||
});
|
||||
throw new Error("Mask tool not available or not initialized");
|
||||
}
|
||||
|
||||
log.debug("Applying SAM mask to canvas using addMask method");
|
||||
|
||||
// Use the addMask method which overlays on existing mask without clearing it
|
||||
canvas.maskTool.addMask(maskAsImage);
|
||||
|
||||
// Update canvas and save state (same as MaskEditorIntegration)
|
||||
canvas.render();
|
||||
canvas.saveState();
|
||||
|
||||
// Update node preview using PreviewUtils
|
||||
await updateNodePreview(canvas, node, true);
|
||||
|
||||
log.info("SAM Detector mask applied successfully to LayerForge canvas");
|
||||
|
||||
// Show success notification
|
||||
showSuccessNotification("SAM Detector mask applied to LayerForge!");
|
||||
|
||||
} catch (error: any) {
|
||||
log.error("Error processing SAM Detector result:", error);
|
||||
|
||||
// Show error notification
|
||||
showErrorNotification(`Failed to apply SAM mask: ${error.message}`);
|
||||
} finally {
|
||||
(node as any).samMonitoringActive = false;
|
||||
(node as any).samOriginalImgSrc = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Function to setup SAM Detector hook in menu options
|
||||
export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
|
||||
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
|
||||
const hookSAMDetector = () => {
|
||||
const samDetectorIndex = options.findIndex((option) =>
|
||||
option && option.content && (
|
||||
option.content.includes("SAM Detector") ||
|
||||
option.content === "Open in SAM Detector"
|
||||
)
|
||||
);
|
||||
|
||||
if (samDetectorIndex !== -1) {
|
||||
log.info(`Found SAM Detector menu item at index ${samDetectorIndex}: "${options[samDetectorIndex].content}"`);
|
||||
const originalSamCallback = options[samDetectorIndex].callback;
|
||||
options[samDetectorIndex].callback = async () => {
|
||||
try {
|
||||
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
|
||||
|
||||
// Automatically send canvas to clipspace and start monitoring
|
||||
if ((node as any).canvasWidget && (node as any).canvasWidget.canvas) {
|
||||
const canvas = (node as any).canvasWidget; // canvasWidget IS the Canvas object
|
||||
|
||||
// Use ImageUploadUtils to upload canvas
|
||||
const uploadResult = await uploadCanvasAsImage(canvas, {
|
||||
filenamePrefix: 'layerforge-sam',
|
||||
nodeId: node.id
|
||||
});
|
||||
|
||||
// Set the image to the node for clipspace
|
||||
node.imgs = [uploadResult.imageElement];
|
||||
(node as any).clipspaceImg = uploadResult.imageElement;
|
||||
|
||||
// Copy to ComfyUI clipspace
|
||||
ComfyApp.copyToClipspace(node);
|
||||
|
||||
// Start monitoring for SAM Detector results
|
||||
startSAMDetectorMonitoring(node);
|
||||
|
||||
log.info("Canvas automatically sent to clipspace and monitoring started");
|
||||
}
|
||||
|
||||
// Call the original SAM Detector callback
|
||||
if (originalSamCallback) {
|
||||
await originalSamCallback();
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
log.error("Error in SAM Detector hook:", e);
|
||||
// Still try to call original callback
|
||||
if (originalSamCallback) {
|
||||
await originalSamCallback();
|
||||
}
|
||||
}
|
||||
};
|
||||
return true; // Found and hooked
|
||||
}
|
||||
return false; // Not found
|
||||
};
|
||||
|
||||
// Try to hook immediately
|
||||
if (!hookSAMDetector()) {
|
||||
// If not found immediately, try again after Impact Pack adds it
|
||||
setTimeout(() => {
|
||||
if (hookSAMDetector()) {
|
||||
log.info("Successfully hooked SAM Detector after delay");
|
||||
} else {
|
||||
log.debug("SAM Detector menu item not found even after delay");
|
||||
}
|
||||
}, 150); // Slightly longer delay to ensure Impact Pack has added it
|
||||
}
|
||||
}
|
||||
172
src/ShapeTool.ts
Normal file
172
src/ShapeTool.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import type { Canvas } from './Canvas.js';
|
||||
import type { Point, Layer } from './types.js';
|
||||
|
||||
const log = createModuleLogger('ShapeTool');
|
||||
|
||||
interface Shape {
|
||||
points: Point[];
|
||||
isClosed: boolean;
|
||||
}
|
||||
|
||||
export class ShapeTool {
|
||||
private canvas: Canvas;
|
||||
public shape: Shape;
|
||||
public isActive: boolean = false;
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
this.canvas = canvas;
|
||||
this.shape = {
|
||||
points: [],
|
||||
isClosed: false,
|
||||
};
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.isActive = !this.isActive;
|
||||
if (this.isActive) {
|
||||
log.info('ShapeTool activated. Press "S" to exit.');
|
||||
this.reset();
|
||||
} else {
|
||||
log.info('ShapeTool deactivated.');
|
||||
this.reset();
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (!this.isActive) {
|
||||
this.isActive = true;
|
||||
log.info('ShapeTool activated. Hold Shift+S to draw.');
|
||||
this.reset();
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
if (this.isActive) {
|
||||
this.isActive = false;
|
||||
log.info('ShapeTool deactivated.');
|
||||
this.reset();
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
|
||||
addPoint(point: Point) {
|
||||
if (this.shape.isClosed) {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
// Check if the new point is close to the start point to close the shape
|
||||
if (this.shape.points.length > 2) {
|
||||
const firstPoint = this.shape.points[0];
|
||||
const dx = point.x - firstPoint.x;
|
||||
const dy = point.y - firstPoint.y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < 10 / this.canvas.viewport.zoom) {
|
||||
this.closeShape();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.shape.points.push(point);
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
closeShape() {
|
||||
if (this.shape.points.length > 2) {
|
||||
this.shape.isClosed = true;
|
||||
log.info('Shape closed with', this.shape.points.length, 'points.');
|
||||
|
||||
this.canvas.defineOutputAreaWithShape(this.shape);
|
||||
this.reset();
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
getBoundingBox(): { x: number, y: number, width: number, height: number } | null {
|
||||
if (this.shape.points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
this.shape.points.forEach(p => {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
});
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.shape = {
|
||||
points: [],
|
||||
isClosed: false,
|
||||
};
|
||||
log.info('ShapeTool reset.');
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
render(ctx: CanvasRenderingContext2D) {
|
||||
if (this.shape.points.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
|
||||
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]);
|
||||
|
||||
ctx.beginPath();
|
||||
const startPoint = this.shape.points[0];
|
||||
ctx.moveTo(startPoint.x, startPoint.y);
|
||||
|
||||
for (let i = 1; i < this.shape.points.length; i++) {
|
||||
ctx.lineTo(this.shape.points[i].x, this.shape.points[i].y);
|
||||
}
|
||||
|
||||
if (this.shape.isClosed) {
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(0, 255, 255, 0.2)';
|
||||
ctx.fill();
|
||||
} else if (this.isActive) {
|
||||
// Draw a line to the current mouse position
|
||||
ctx.lineTo(this.canvas.lastMousePosition.x, this.canvas.lastMousePosition.y);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
|
||||
// Draw vertices
|
||||
const mouse = this.canvas.lastMousePosition;
|
||||
const firstPoint = this.shape.points[0];
|
||||
let highlightFirst = false;
|
||||
if (!this.shape.isClosed && this.shape.points.length > 2 && mouse) {
|
||||
const dx = mouse.x - firstPoint.x;
|
||||
const dy = mouse.y - firstPoint.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 10 / this.canvas.viewport.zoom) {
|
||||
highlightFirst = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.shape.points.forEach((point, index) => {
|
||||
ctx.beginPath();
|
||||
if (index === 0 && highlightFirst) {
|
||||
ctx.arc(point.x, point.y, 8 / this.canvas.viewport.zoom, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = 'yellow';
|
||||
} else {
|
||||
ctx.arc(point.x, point.y, 4 / this.canvas.viewport.zoom, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = 'rgba(0, 255, 255, 1)';
|
||||
}
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@ import { LogLevel } from "./logger";
|
||||
|
||||
// Log level for development.
|
||||
// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE'
|
||||
export const LOG_LEVEL: keyof typeof LogLevel = 'DEBUG';
|
||||
export const LOG_LEVEL: keyof typeof LogLevel = 'NONE';
|
||||
|
||||
@@ -1,54 +1,99 @@
|
||||
.painter-button {
|
||||
background: linear-gradient(to bottom, #4a4a4a, #3a3a3a);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 6px 12px;
|
||||
background-color: #444;
|
||||
border: 1px solid #555;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.2s ease-in-out;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
margin: 2px;
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
text-shadow: 0 1px 2px rgb(0,0,0);
|
||||
}
|
||||
|
||||
.painter-button:hover {
|
||||
background: linear-gradient(to bottom, #5a5a5a, #4a4a4a);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
background-color: #555;
|
||||
border-color: #666;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.painter-button:active {
|
||||
background: linear-gradient(to bottom, #3a3a3a, #4a4a4a);
|
||||
transform: translateY(1px);
|
||||
background-color: #3a3a3a;
|
||||
transform: translateY(0);
|
||||
box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.painter-button:disabled,
|
||||
.painter-button:disabled:hover {
|
||||
background: #555;
|
||||
color: #888;
|
||||
background-color: #3a3a3a;
|
||||
color: #777;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
border-color: #444;
|
||||
border-color: #4a4a4a;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.painter-button.primary {
|
||||
background: linear-gradient(to bottom, #4a6cd4, #3a5cc4);
|
||||
border-color: #2a4cb4;
|
||||
background-color: #3a76d6;
|
||||
border-color: #2a6ac4;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgb(0,0,0);
|
||||
}
|
||||
|
||||
.painter-button.primary:hover {
|
||||
background: linear-gradient(to bottom, #5a7ce4, #4a6cd4);
|
||||
background-color: #4a86e4;
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
.painter-button.success {
|
||||
border-color: #4ae27a;
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 1.5px #4ae27a88;
|
||||
}
|
||||
.painter-button.success:hover {
|
||||
border-color: #6aff9a;
|
||||
box-shadow: 0 0 0 2.5px #6aff9a88;
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.painter-button.danger {
|
||||
border-color: #e24a4a;
|
||||
background-color: #444;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0 1.5px #e24a4a88;
|
||||
}
|
||||
.painter-button.danger:hover {
|
||||
border-color: #ff6a6a;
|
||||
box-shadow: 0 0 0 2.5px #ff6a6a88;
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.painter-button.icon-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
min-width: 30px;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
line-height: 30px; /* Match height */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
background-color: #2f2f2f;
|
||||
border-bottom: 1px solid #202020;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
@@ -56,57 +101,216 @@
|
||||
|
||||
.painter-slider-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.painter-slider-container input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.painter-slider-container input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.painter-slider-container input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.painter-slider-container input[type="range"]::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
margin-top: 2px;
|
||||
min-height: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.painter-button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
padding: 4px;
|
||||
gap: 4px;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
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;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.painter-clipboard-group .painter-button {
|
||||
margin: 1px;
|
||||
height: 30px; /* Match switch height */
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* --- Clipboard Switch Modern --- */
|
||||
.clipboard-switch {
|
||||
position: relative;
|
||||
width: 90px;
|
||||
height: 30px;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(to right, #5a5a5a 30%, #3a76d6);
|
||||
border-radius: 5px;
|
||||
border: 1px solid #555;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out;
|
||||
user-select: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
}
|
||||
.clipboard-switch:hover {
|
||||
background: linear-gradient(to right, #6a6a6a 30%, #4a86e4);
|
||||
border-color: #666;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Mask switch: szaro-czarny gradient tylko dla maski */
|
||||
.clipboard-switch.mask-switch {
|
||||
background: linear-gradient(to right, #5a5a5a 30%, #e53935);
|
||||
}
|
||||
.clipboard-switch.mask-switch:hover {
|
||||
background: linear-gradient(to right, #6a6a6a 30%, #ff5252);
|
||||
}
|
||||
.clipboard-switch:active {
|
||||
background: linear-gradient(135deg, #3a76d6, #3a3a3a);
|
||||
}
|
||||
|
||||
.clipboard-switch input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clipboard-switch .switch-track {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clipboard-switch .switch-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #5a5a5a;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.clipboard-switch:hover .switch-knob {
|
||||
background-color: #6a6a6a;
|
||||
}
|
||||
|
||||
.clipboard-switch:hover .switch-knob {
|
||||
background-color: #6a6a6a;
|
||||
}
|
||||
|
||||
.clipboard-switch .switch-labels {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 550;
|
||||
color: #ffffff;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
text-shadow: 0 1px 2px rgb(0, 0, 0);
|
||||
}
|
||||
.clipboard-switch .switch-labels .text-clipspace,
|
||||
.clipboard-switch .switch-labels .text-system {
|
||||
position: absolute;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
.clipboard-switch .switch-labels .text-clipspace { opacity: 0; }
|
||||
.clipboard-switch .switch-labels .text-system { opacity: 1; padding-left: 20px; }
|
||||
|
||||
.clipboard-switch .switch-knob .switch-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clipboard-switch .switch-knob .switch-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Checked state */
|
||||
.clipboard-switch:has(input:checked) {
|
||||
background: linear-gradient(to right, #3a76d6, #5a5a5a 70%);
|
||||
border-color: #2a6ac4;
|
||||
}
|
||||
.clipboard-switch:has(input:checked):hover {
|
||||
background: linear-gradient(to right, #4a86e4, #6a6a6a 70%);
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
.clipboard-switch input:checked ~ .switch-knob {
|
||||
left: calc(100% - 26px);
|
||||
}
|
||||
.clipboard-switch input:checked ~ .switch-knob .switch-icon img {
|
||||
filter: none;
|
||||
}
|
||||
.clipboard-switch input:checked ~ .switch-labels .text-clipspace {
|
||||
opacity: 1;
|
||||
color: #fff;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.clipboard-switch input:checked ~ .switch-labels .text-system {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
.painter-separator {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background-color: #2a2a2a;
|
||||
height: 24px;
|
||||
background-color: #444;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
@@ -182,17 +386,18 @@
|
||||
.painter-tooltip {
|
||||
position: fixed;
|
||||
display: none;
|
||||
background: #3a3a3a;
|
||||
background: #2B2B2B;
|
||||
color: #f0f0f0;
|
||||
border: 1px solid #555;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #444;
|
||||
border-top: 2px solid #4a90e2;
|
||||
border-radius: 6px;
|
||||
padding: 12px 18px;
|
||||
z-index: 9999;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
width: auto;
|
||||
max-width: min(500px, calc(100vw - 40px));
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
max-width: min(450px, calc(100vw - 30px));
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||
pointer-events: none;
|
||||
transform-origin: top left;
|
||||
transition: transform 0.2s ease;
|
||||
@@ -216,8 +421,9 @@
|
||||
}
|
||||
|
||||
.painter-tooltip table td {
|
||||
padding: 2px 8px;
|
||||
padding: 4px 8px;
|
||||
vertical-align: middle;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.painter-tooltip table td:first-child {
|
||||
@@ -231,7 +437,10 @@
|
||||
}
|
||||
|
||||
.painter-tooltip table tr:nth-child(odd) td {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.painter-tooltip table tr:hover td {
|
||||
background-color: rgba(74, 144, 226, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@@ -304,10 +513,15 @@
|
||||
|
||||
.painter-tooltip h4 {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
color: #4a90e2; /* Jasnoniebieski akcent */
|
||||
border-bottom: 1px solid #555;
|
||||
margin-bottom: 6px;
|
||||
color: #4a90e2;
|
||||
border-bottom: 1px solid #4a90e2;
|
||||
padding-bottom: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.painter-tooltip h4:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.painter-tooltip ul {
|
||||
@@ -317,13 +531,18 @@
|
||||
}
|
||||
|
||||
.painter-tooltip kbd {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 3px;
|
||||
background-color: #444;
|
||||
border: 1px solid #555;
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #d0d0d0;
|
||||
font-size: 11px;
|
||||
color: #e0e0e0;
|
||||
box-shadow: 0 1px 1px rgba(0,0,0,0.15);
|
||||
margin: 0 1px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.painter-container.has-focus {
|
||||
|
||||
204
src/css/custom_shape_menu.css
Normal file
204
src/css/custom_shape_menu.css
Normal file
@@ -0,0 +1,204 @@
|
||||
#layerforge-custom-shape-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #2f2f2f;
|
||||
color: #e0e0e0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
z-index: 1001;
|
||||
border: 1px solid #202020;
|
||||
user-select: none;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .menu-line {
|
||||
font-weight: 600;
|
||||
color: #4a90e2;
|
||||
padding-bottom: 5px;
|
||||
border-bottom: 1px solid #444;
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- MINIMIZED BAR INTERACTIVE STYLE --- */
|
||||
.custom-shape-minimized-bar {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
background: #222;
|
||||
color: #4a90e2;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
|
||||
margin: 0 0 8px 0;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
border: 1px solid #444;
|
||||
transition: background 0.18s, color 0.18s, box-shadow 0.18s, border 0.18s;
|
||||
outline: none;
|
||||
text-shadow: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.custom-shape-minimized-bar:hover, .custom-shape-minimized-bar:focus {
|
||||
background: #2a2a2a;
|
||||
color: #4a90e2;
|
||||
border: 1.5px solid #4a90e2;
|
||||
box-shadow: 0 4px 16px #4a90e244;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .feature-container {
|
||||
background-color: #3a3a3a;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #4a4a4a;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
#layerforge-custom-shape-menu .feature-container:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .slider-container {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 0;
|
||||
display: none;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .slider-label {
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
#layerforge-custom-shape-menu input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
#layerforge-custom-shape-menu input[type="range"]::-webkit-slider-thumb:hover {
|
||||
background: #fff;
|
||||
}
|
||||
#layerforge-custom-shape-menu input[type="range"]::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .slider-value-display {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
color: #bbb;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .extension-slider-container {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container .custom-checkbox {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #666;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container input:checked ~ .custom-checkbox {
|
||||
background-color: #3a76d6;
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container .custom-checkbox::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 5px;
|
||||
top: 1px;
|
||||
width: 4px;
|
||||
height: 9px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
#layerforge-custom-shape-menu .checkbox-container input:checked ~ .custom-checkbox::after {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layerforge-tooltip {
|
||||
position: fixed;
|
||||
background-color: #2f2f2f;
|
||||
color: #e0e0e0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.4;
|
||||
max-width: 250px;
|
||||
word-wrap: break-word;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
border: 1px solid #202020;
|
||||
z-index: 10000;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
<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>Shift + S + Left Click</kbd></td><td>Draw custom shape for output area</td></tr>
|
||||
<tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
|
||||
<tr><td><kbd>Esc</kbd></td><td>Close fullscreen editor mode</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Clipboard & I/O</h4>
|
||||
|
||||
14
src/types.ts
14
src/types.ts
@@ -16,9 +16,11 @@ export interface Layer {
|
||||
zIndex: number;
|
||||
blendMode: string;
|
||||
opacity: number;
|
||||
visible: boolean;
|
||||
mask?: Float32Array;
|
||||
flipH?: boolean;
|
||||
flipV?: boolean;
|
||||
blendArea?: number;
|
||||
}
|
||||
|
||||
export interface ComfyNode {
|
||||
@@ -129,6 +131,18 @@ export interface Point {
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Shape {
|
||||
points: Point[];
|
||||
isClosed: boolean;
|
||||
}
|
||||
|
||||
export interface OutputAreaBounds {
|
||||
x: number; // Pozycja w świecie (może być ujemna)
|
||||
y: number; // Pozycja w świecie (może być ujemna)
|
||||
width: number; // Szerokość output area
|
||||
height: number; // Wysokość output area
|
||||
}
|
||||
|
||||
export interface Viewport {
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
||||
|
||||
// @ts-ignore
|
||||
import {api} from "../../../scripts/api.js";
|
||||
@@ -25,62 +27,51 @@ export class ClipboardManager {
|
||||
* @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}`);
|
||||
handlePaste = withErrorHandling(async (addMode: AddMode = 'mouse', preference: ClipboardPreference = 'system'): Promise<boolean> => {
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
log.info("No image found in ComfyUI Clipspace");
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Attempting paste from system clipboard");
|
||||
return await this.trySystemClipboardPaste(addMode);
|
||||
}, 'ClipboardManager.handlePaste');
|
||||
|
||||
/**
|
||||
* 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);
|
||||
tryClipspacePaste = withErrorHandling(async (addMode: AddMode): Promise<boolean> => {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, 'ClipboardManager.tryClipspacePaste');
|
||||
|
||||
/**
|
||||
* System clipboard paste - handles both image data and text paths
|
||||
@@ -289,57 +280,57 @@ export class ClipboardManager {
|
||||
* @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;
|
||||
loadFileViaBackend = withErrorHandling(async (filePath: string, addMode: AddMode): Promise<boolean> => {
|
||||
if (!filePath) {
|
||||
throw createValidationError("File path is required", { filePath });
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
throw createNetworkError(`Backend failed to load image: ${errorData.error}`, {
|
||||
filePath,
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw createFileError(`Backend returned error: ${data.error}`, { filePath, backendError: data.error });
|
||||
}
|
||||
|
||||
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;
|
||||
}, 'ClipboardManager.loadFileViaBackend');
|
||||
|
||||
/**
|
||||
* Prompts the user to select a file when a local path is detected
|
||||
@@ -401,7 +392,7 @@ export class ClipboardManager {
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
|
||||
showInfoNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
|
||||
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.click();
|
||||
@@ -415,7 +406,7 @@ export class ClipboardManager {
|
||||
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);
|
||||
showNotification(message, "#c54747", 5000);
|
||||
log.info("Showed file path limitation message to user");
|
||||
}
|
||||
|
||||
@@ -489,36 +480,4 @@ export class ClipboardManager {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
232
src/utils/IconLoader.ts
Normal file
232
src/utils/IconLoader.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { createCanvas } from "./CommonUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
|
||||
const log = createModuleLogger('IconLoader');
|
||||
|
||||
// Define tool constants for LayerForge
|
||||
export const LAYERFORGE_TOOLS = {
|
||||
VISIBILITY: 'visibility',
|
||||
MOVE: 'move',
|
||||
ROTATE: 'rotate',
|
||||
SCALE: 'scale',
|
||||
DELETE: 'delete',
|
||||
DUPLICATE: 'duplicate',
|
||||
BLEND_MODE: 'blend_mode',
|
||||
OPACITY: 'opacity',
|
||||
MASK: 'mask',
|
||||
BRUSH: 'brush',
|
||||
ERASER: 'eraser',
|
||||
SHAPE: 'shape',
|
||||
SETTINGS: 'settings',
|
||||
SYSTEM_CLIPBOARD: 'system_clipboard',
|
||||
CLIPSPACE: 'clipspace',
|
||||
} as const;
|
||||
|
||||
// SVG Icons for LayerForge tools
|
||||
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
|
||||
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <defs> <mask id="cutout"> <rect width="100%" height="100%" fill="white"/> <path d="M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" fill="black" transform="translate(4.8 4.8) scale(0.6)" /> </mask> </defs> <path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z" fill="#ffffff" mask="url(#cutout)" /></svg>`;
|
||||
|
||||
|
||||
const LAYERFORGE_TOOL_ICONS = {
|
||||
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,6V9L16,5L12,1V4A8,8 0 0,0 4,12C4,13.57 4.46,15.03 5.24,16.26L6.7,14.8C6.25,13.97 6,13 6,12A6,6 0 0,1 12,6M18.76,7.74L17.3,9.2C17.74,10.04 18,11 18,12A6,6 0 0,1 12,18V15L8,19L12,23V20A8,8 0 0,0 20,12C20,10.43 19.54,8.97 18.76,7.74Z"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.SCALE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M22,18V22H18V20H20V18H22M22,6V10H20V8H18V6H22M2,6V10H4V8H6V6H2M2,18V22H6V20H4V18H2M16,8V10H14V12H16V14H14V16H12V14H10V12H12V10H10V8H12V6H14V8H16Z"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.DELETE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M7,6H17V19H7V6M9,8V17H11V8H9M13,8V17H15V8H13Z"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.DUPLICATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.BLEND_MODE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20V4Z"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.OPACITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,20A6,6 0 0,1 6,14C6,10 12,3.25 12,3.25S18,10 18,14A6,6 0 0,1 12,20Z"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.MASK]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="#ffffff" stroke-width="2"/><circle cx="12" cy="12" r="5" fill="#ffffff"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.BRUSH]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M15.4565 9.67503L15.3144 9.53297C14.6661 8.90796 13.8549 8.43369 12.9235 8.18412C10.0168 7.40527 7.22541 9.05273 6.43185 12.0143C6.38901 12.1742 6.36574 12.3537 6.3285 12.8051C6.17423 14.6752 5.73449 16.0697 4.5286 17.4842C6.78847 18.3727 9.46572 18.9986 11.5016 18.9986C13.9702 18.9986 16.1644 17.3394 16.8126 14.9202C17.3306 12.9869 16.7513 11.0181 15.4565 9.67503ZM13.2886 6.21301L18.2278 2.37142C18.6259 2.0618 19.1922 2.09706 19.5488 2.45367L22.543 5.44787C22.8997 5.80448 22.9349 6.37082 22.6253 6.76891L18.7847 11.7068C19.0778 12.8951 19.0836 14.1721 18.7444 15.4379C17.8463 18.7897 14.8142 20.9986 11.5016 20.9986C8 20.9986 3.5 19.4967 1 17.9967C4.97978 14.9967 4.04722 13.1865 4.5 11.4967C5.55843 7.54658 9.34224 5.23935 13.2886 6.21301ZM16.7015 8.09161C16.7673 8.15506 16.8319 8.21964 16.8952 8.28533L18.0297 9.41984L20.5046 6.23786L18.7589 4.4921L15.5769 6.96698L16.7015 8.09161Z"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.ERASER]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M8.58564 8.85449L3.63589 13.8042L8.83021 18.9985L9.99985 18.9978V18.9966H11.1714L14.9496 15.2184L8.58564 8.85449ZM9.99985 7.44027L16.3638 13.8042L19.1922 10.9758L12.8283 4.61185L9.99985 7.44027ZM13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966Z"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.SHAPE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M3 4H21C21.5523 4 22 4.44772 22 5V19C22 19.5523 21.5523 20 21 20H3C2.44772 20 2 19.5523 2 19V5C2 4.44772 2.44772 4 3 4ZM4 6V18H20V6H4Z"/></svg>')}`,
|
||||
|
||||
[LAYERFORGE_TOOLS.SETTINGS]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.5,11L5.13,18.93C5.17,19.18 5.38,19.36 5.63,19.36H18.37C18.62,19.36 18.83,19.18 18.87,18.93L19.5,11L21.54,9.37Z"/></svg>')}`
|
||||
};
|
||||
|
||||
// Tool colors for LayerForge
|
||||
const LAYERFORGE_TOOL_COLORS = {
|
||||
[LAYERFORGE_TOOLS.VISIBILITY]: '#4285F4',
|
||||
[LAYERFORGE_TOOLS.MOVE]: '#34A853',
|
||||
[LAYERFORGE_TOOLS.ROTATE]: '#FBBC05',
|
||||
[LAYERFORGE_TOOLS.SCALE]: '#EA4335',
|
||||
[LAYERFORGE_TOOLS.DELETE]: '#FF6D01',
|
||||
[LAYERFORGE_TOOLS.DUPLICATE]: '#46BDC6',
|
||||
[LAYERFORGE_TOOLS.BLEND_MODE]: '#9C27B0',
|
||||
[LAYERFORGE_TOOLS.OPACITY]: '#8BC34A',
|
||||
[LAYERFORGE_TOOLS.MASK]: '#607D8B',
|
||||
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
|
||||
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
|
||||
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
|
||||
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292'
|
||||
};
|
||||
|
||||
export interface IconCache {
|
||||
[key: string]: HTMLCanvasElement | HTMLImageElement;
|
||||
}
|
||||
|
||||
export class IconLoader {
|
||||
private _iconCache: IconCache = {};
|
||||
private _loadingPromises: Map<string, Promise<HTMLImageElement>> = new Map();
|
||||
|
||||
constructor() {
|
||||
log.info('IconLoader initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload all LayerForge tool icons
|
||||
*/
|
||||
preloadToolIcons = withErrorHandling(async (): Promise<void> => {
|
||||
log.info('Starting to preload LayerForge tool icons');
|
||||
|
||||
const loadPromises = Object.keys(LAYERFORGE_TOOL_ICONS).map(tool => {
|
||||
return this.loadIcon(tool);
|
||||
});
|
||||
|
||||
await Promise.all(loadPromises);
|
||||
log.info(`Successfully preloaded ${loadPromises.length} tool icons`);
|
||||
}, 'IconLoader.preloadToolIcons');
|
||||
|
||||
/**
|
||||
* Load a specific icon by tool name
|
||||
*/
|
||||
loadIcon = withErrorHandling(async (tool: string): Promise<HTMLImageElement> => {
|
||||
if (!tool) {
|
||||
throw createValidationError("Tool name is required", { tool });
|
||||
}
|
||||
|
||||
// Check if already cached
|
||||
if (this._iconCache[tool] && this._iconCache[tool] instanceof HTMLImageElement) {
|
||||
return this._iconCache[tool] as HTMLImageElement;
|
||||
}
|
||||
|
||||
// Check if already loading
|
||||
if (this._loadingPromises.has(tool)) {
|
||||
return this._loadingPromises.get(tool)!;
|
||||
}
|
||||
|
||||
// Create fallback canvas first
|
||||
const fallbackCanvas = this.createFallbackIcon(tool);
|
||||
this._iconCache[tool] = fallbackCanvas;
|
||||
|
||||
// Start loading the SVG icon
|
||||
const loadPromise = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
this._iconCache[tool] = img;
|
||||
this._loadingPromises.delete(tool);
|
||||
log.debug(`Successfully loaded icon for tool: ${tool}`);
|
||||
resolve(img);
|
||||
};
|
||||
|
||||
img.onerror = (error) => {
|
||||
log.warn(`Failed to load SVG icon for tool: ${tool}, using fallback`);
|
||||
this._loadingPromises.delete(tool);
|
||||
// Keep the fallback canvas in cache
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const iconData = LAYERFORGE_TOOL_ICONS[tool as keyof typeof LAYERFORGE_TOOL_ICONS];
|
||||
if (iconData) {
|
||||
img.src = iconData;
|
||||
} else {
|
||||
log.warn(`No icon data found for tool: ${tool}`);
|
||||
reject(createValidationError(`No icon data for tool: ${tool}`, { tool, availableTools: Object.keys(LAYERFORGE_TOOL_ICONS) }));
|
||||
}
|
||||
});
|
||||
|
||||
this._loadingPromises.set(tool, loadPromise);
|
||||
return loadPromise;
|
||||
}, 'IconLoader.loadIcon');
|
||||
|
||||
/**
|
||||
* Create a fallback canvas icon with colored background and text
|
||||
*/
|
||||
private createFallbackIcon(tool: string): HTMLCanvasElement {
|
||||
const { canvas, ctx } = createCanvas(24, 24);
|
||||
|
||||
if (!ctx) {
|
||||
log.error('Failed to get canvas context for fallback icon');
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// Fill background with tool color
|
||||
const color = LAYERFORGE_TOOL_COLORS[tool as keyof typeof LAYERFORGE_TOOL_COLORS] || '#888888';
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, 24, 24);
|
||||
|
||||
// Add border
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(0.5, 0.5, 23, 23);
|
||||
|
||||
// Add text
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const firstChar = tool.charAt(0).toUpperCase();
|
||||
ctx.fillText(firstChar, 12, 12);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached icon (canvas or image)
|
||||
*/
|
||||
getIcon(tool: string): HTMLCanvasElement | HTMLImageElement | null {
|
||||
return this._iconCache[tool] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if icon is loaded (as image, not fallback canvas)
|
||||
*/
|
||||
isIconLoaded(tool: string): boolean {
|
||||
return this._iconCache[tool] instanceof HTMLImageElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached icons
|
||||
*/
|
||||
clearCache(): void {
|
||||
this._iconCache = {};
|
||||
this._loadingPromises.clear();
|
||||
log.info('Icon cache cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available tool names
|
||||
*/
|
||||
getAvailableTools(): string[] {
|
||||
return Object.values(LAYERFORGE_TOOLS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool color
|
||||
*/
|
||||
getToolColor(tool: string): string {
|
||||
return LAYERFORGE_TOOL_COLORS[tool as keyof typeof LAYERFORGE_TOOL_COLORS] || '#888888';
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const iconLoader = new IconLoader();
|
||||
|
||||
// Export for external use
|
||||
export { LAYERFORGE_TOOL_ICONS, LAYERFORGE_TOOL_COLORS };
|
||||
269
src/utils/ImageAnalysis.ts
Normal file
269
src/utils/ImageAnalysis.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { createCanvas } from "./CommonUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
|
||||
const log = createModuleLogger('ImageAnalysis');
|
||||
|
||||
/**
|
||||
* Creates a distance field mask based on the alpha channel of an image.
|
||||
* The mask will have gradients from the edges of visible pixels inward.
|
||||
* @param image - The source image to analyze
|
||||
* @param blendArea - The percentage (0-100) of the area to apply blending
|
||||
* @returns HTMLCanvasElement containing the distance field mask
|
||||
*/
|
||||
/**
|
||||
* Synchronous version of createDistanceFieldMask for use in synchronous rendering
|
||||
*/
|
||||
export function createDistanceFieldMaskSync(image: HTMLImageElement, blendArea: number): HTMLCanvasElement {
|
||||
if (!image) {
|
||||
log.error("Image is required for distance field mask");
|
||||
return createCanvas(1, 1).canvas;
|
||||
}
|
||||
if (typeof blendArea !== 'number' || blendArea < 0 || blendArea > 100) {
|
||||
log.error("Blend area must be a number between 0 and 100");
|
||||
return createCanvas(1, 1).canvas;
|
||||
}
|
||||
|
||||
const { canvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
|
||||
|
||||
if (!ctx) {
|
||||
log.error('Failed to create canvas context for distance field mask');
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// Draw the image to extract pixel data
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
// Check if image has transparency (any alpha < 255)
|
||||
let hasTransparency = false;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
if (data[i * 4 + 3] < 255) {
|
||||
hasTransparency = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let distanceField: Float32Array;
|
||||
let maxDistance: number;
|
||||
|
||||
if (hasTransparency) {
|
||||
// For images with transparency, use alpha-based distance transform
|
||||
const binaryMask = new Uint8Array(width * height);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
binaryMask[i] = data[i * 4 + 3] > 0 ? 1 : 0;
|
||||
}
|
||||
distanceField = calculateDistanceTransform(binaryMask, width, height);
|
||||
} else {
|
||||
// For opaque images, calculate distance from edges of the rectangle
|
||||
distanceField = calculateDistanceFromEdges(width, height);
|
||||
}
|
||||
|
||||
// Find the maximum distance to normalize
|
||||
maxDistance = 0;
|
||||
for (let i = 0; i < distanceField.length; i++) {
|
||||
if (distanceField[i] > maxDistance) {
|
||||
maxDistance = distanceField[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Create the gradient mask based on blendArea
|
||||
const maskData = ctx.createImageData(width, height);
|
||||
const threshold = maxDistance * (blendArea / 100);
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const distance = distanceField[i];
|
||||
const alpha = data[i * 4 + 3];
|
||||
|
||||
if (alpha === 0) {
|
||||
// Transparent pixels remain transparent
|
||||
maskData.data[i * 4] = 255;
|
||||
maskData.data[i * 4 + 1] = 255;
|
||||
maskData.data[i * 4 + 2] = 255;
|
||||
maskData.data[i * 4 + 3] = 0;
|
||||
} else if (distance <= threshold) {
|
||||
// Edge area - apply gradient alpha
|
||||
const gradientValue = distance / threshold;
|
||||
const alphaValue = Math.floor(gradientValue * 255);
|
||||
maskData.data[i * 4] = 255;
|
||||
maskData.data[i * 4 + 1] = 255;
|
||||
maskData.data[i * 4 + 2] = 255;
|
||||
maskData.data[i * 4 + 3] = alphaValue;
|
||||
} else {
|
||||
// Inner area - full alpha (no blending effect)
|
||||
maskData.data[i * 4] = 255;
|
||||
maskData.data[i * 4 + 1] = 255;
|
||||
maskData.data[i * 4 + 2] = 255;
|
||||
maskData.data[i * 4 + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear canvas and put the mask data
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.putImageData(maskData, 0, 0);
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version with error handling for use in async contexts
|
||||
*/
|
||||
export const createDistanceFieldMask = withErrorHandling(function(image: HTMLImageElement, blendArea: number): HTMLCanvasElement {
|
||||
return createDistanceFieldMaskSync(image, blendArea);
|
||||
}, 'createDistanceFieldMask');
|
||||
|
||||
/**
|
||||
* Calculates the Euclidean distance transform of a binary mask.
|
||||
* Uses a two-pass algorithm for efficiency.
|
||||
* @param binaryMask - Binary mask where 1 = inside, 0 = outside
|
||||
* @param width - Width of the mask
|
||||
* @param height - Height of the mask
|
||||
* @returns Float32Array containing distance values
|
||||
*/
|
||||
function calculateDistanceTransform(binaryMask: Uint8Array, width: number, height: number): Float32Array {
|
||||
const distances = new Float32Array(width * height);
|
||||
const infinity = width + height; // A value larger than any possible distance
|
||||
|
||||
// Initialize distances
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
distances[i] = binaryMask[i] === 1 ? infinity : 0;
|
||||
}
|
||||
|
||||
// Forward pass (top-left to bottom-right)
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
if (distances[idx] > 0) {
|
||||
let minDist = distances[idx];
|
||||
|
||||
// Check top neighbor
|
||||
if (y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + x] + 1);
|
||||
}
|
||||
|
||||
// Check left neighbor
|
||||
if (x > 0) {
|
||||
minDist = Math.min(minDist, distances[y * width + (x - 1)] + 1);
|
||||
}
|
||||
|
||||
// Check top-left diagonal
|
||||
if (x > 0 && y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + (x - 1)] + Math.sqrt(2));
|
||||
}
|
||||
|
||||
// Check top-right diagonal
|
||||
if (x < width - 1 && y > 0) {
|
||||
minDist = Math.min(minDist, distances[(y - 1) * width + (x + 1)] + Math.sqrt(2));
|
||||
}
|
||||
|
||||
distances[idx] = minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Backward pass (bottom-right to top-left)
|
||||
for (let y = height - 1; y >= 0; y--) {
|
||||
for (let x = width - 1; x >= 0; x--) {
|
||||
const idx = y * width + x;
|
||||
if (distances[idx] > 0) {
|
||||
let minDist = distances[idx];
|
||||
|
||||
// Check bottom neighbor
|
||||
if (y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + x] + 1);
|
||||
}
|
||||
|
||||
// Check right neighbor
|
||||
if (x < width - 1) {
|
||||
minDist = Math.min(minDist, distances[y * width + (x + 1)] + 1);
|
||||
}
|
||||
|
||||
// Check bottom-right diagonal
|
||||
if (x < width - 1 && y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + (x + 1)] + Math.sqrt(2));
|
||||
}
|
||||
|
||||
// Check bottom-left diagonal
|
||||
if (x > 0 && y < height - 1) {
|
||||
minDist = Math.min(minDist, distances[(y + 1) * width + (x - 1)] + Math.sqrt(2));
|
||||
}
|
||||
|
||||
distances[idx] = minDist;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return distances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates distance from edges of a rectangle for opaque images.
|
||||
* @param width - Width of the rectangle
|
||||
* @param height - Height of the rectangle
|
||||
* @returns Float32Array containing distance values from edges
|
||||
*/
|
||||
function calculateDistanceFromEdges(width: number, height: number): Float32Array {
|
||||
const distances = new Float32Array(width * height);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = y * width + x;
|
||||
|
||||
// Calculate distance to nearest edge
|
||||
const distToLeft = x;
|
||||
const distToRight = width - 1 - x;
|
||||
const distToTop = y;
|
||||
const distToBottom = height - 1 - y;
|
||||
|
||||
// Minimum distance to any edge
|
||||
const minDistToEdge = Math.min(distToLeft, distToRight, distToTop, distToBottom);
|
||||
distances[idx] = minDistToEdge;
|
||||
}
|
||||
}
|
||||
|
||||
return distances;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a simple radial gradient mask (fallback for rectangular areas).
|
||||
* @param width - Width of the mask
|
||||
* @param height - Height of the mask
|
||||
* @param blendArea - The percentage (0-100) of the area to apply blending
|
||||
* @returns HTMLCanvasElement containing the radial gradient mask
|
||||
*/
|
||||
export const createRadialGradientMask = withErrorHandling(function(width: number, height: number, blendArea: number): HTMLCanvasElement {
|
||||
if (typeof width !== 'number' || width <= 0) {
|
||||
throw createValidationError("Width must be a positive number", { width });
|
||||
}
|
||||
if (typeof height !== 'number' || height <= 0) {
|
||||
throw createValidationError("Height must be a positive number", { height });
|
||||
}
|
||||
if (typeof blendArea !== 'number' || blendArea < 0 || blendArea > 100) {
|
||||
throw createValidationError("Blend area must be a number between 0 and 100", { blendArea });
|
||||
}
|
||||
|
||||
const { canvas, ctx } = createCanvas(width, height);
|
||||
|
||||
if (!ctx) {
|
||||
log.error('Failed to create canvas context for radial gradient mask');
|
||||
return canvas;
|
||||
}
|
||||
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
|
||||
const innerRadius = maxRadius * (1 - blendArea / 100);
|
||||
|
||||
// Create radial gradient
|
||||
const gradient = ctx.createRadialGradient(centerX, centerY, innerRadius, centerX, centerY, maxRadius);
|
||||
gradient.addColorStop(0, 'white');
|
||||
gradient.addColorStop(1, 'black');
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
return canvas;
|
||||
}, 'createRadialGradientMask');
|
||||
179
src/utils/ImageUploadUtils.ts
Normal file
179
src/utils/ImageUploadUtils.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { api } from "../../../scripts/api.js";
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
|
||||
|
||||
const log = createModuleLogger('ImageUploadUtils');
|
||||
|
||||
/**
|
||||
* Utility functions for uploading images to ComfyUI server
|
||||
*/
|
||||
|
||||
export interface UploadImageOptions {
|
||||
/** Custom filename prefix (default: 'layerforge') */
|
||||
filenamePrefix?: string;
|
||||
/** Whether to overwrite existing files (default: true) */
|
||||
overwrite?: boolean;
|
||||
/** Upload type (default: 'temp') */
|
||||
type?: string;
|
||||
/** Node ID for unique filename generation */
|
||||
nodeId?: string | number;
|
||||
}
|
||||
|
||||
export interface UploadImageResult {
|
||||
/** Server response data */
|
||||
data: any;
|
||||
/** Generated filename */
|
||||
filename: string;
|
||||
/** Full image URL */
|
||||
imageUrl: string;
|
||||
/** Created Image element */
|
||||
imageElement: HTMLImageElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an image blob to ComfyUI server and returns image element
|
||||
* @param blob - Image blob to upload
|
||||
* @param options - Upload options
|
||||
* @returns Promise with upload result
|
||||
*/
|
||||
export const uploadImageBlob = withErrorHandling(async function(blob: Blob, options: UploadImageOptions = {}): Promise<UploadImageResult> {
|
||||
if (!blob) {
|
||||
throw createValidationError("Blob is required", { blob });
|
||||
}
|
||||
if (blob.size === 0) {
|
||||
throw createValidationError("Blob cannot be empty", { blobSize: blob.size });
|
||||
}
|
||||
|
||||
const {
|
||||
filenamePrefix = 'layerforge',
|
||||
overwrite = true,
|
||||
type = 'temp',
|
||||
nodeId
|
||||
} = options;
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const nodeIdSuffix = nodeId ? `-${nodeId}` : '';
|
||||
const filename = `${filenamePrefix}${nodeIdSuffix}-${timestamp}.png`;
|
||||
|
||||
log.debug('Uploading image blob:', {
|
||||
filename,
|
||||
blobSize: blob.size,
|
||||
type,
|
||||
overwrite
|
||||
});
|
||||
|
||||
// Create FormData
|
||||
const formData = new FormData();
|
||||
formData.append("image", blob, filename);
|
||||
formData.append("overwrite", overwrite.toString());
|
||||
formData.append("type", type);
|
||||
|
||||
// Upload to server
|
||||
const response = await api.fetchApi("/upload/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw createNetworkError(`Failed to upload image: ${response.statusText}`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
filename,
|
||||
blobSize: blob.size
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
log.debug('Image uploaded successfully:', data);
|
||||
|
||||
// Create image element with proper URL
|
||||
const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
|
||||
const imageElement = new Image();
|
||||
imageElement.crossOrigin = "anonymous";
|
||||
|
||||
// Wait for image to load
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
imageElement.onload = () => {
|
||||
log.debug("Uploaded image loaded successfully", {
|
||||
width: imageElement.width,
|
||||
height: imageElement.height,
|
||||
src: imageElement.src.substring(0, 100) + '...'
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
imageElement.onerror = (error) => {
|
||||
log.error("Failed to load uploaded image", error);
|
||||
reject(createNetworkError("Failed to load uploaded image", { error, imageUrl, filename }));
|
||||
};
|
||||
imageElement.src = imageUrl;
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
filename,
|
||||
imageUrl,
|
||||
imageElement
|
||||
};
|
||||
}, 'uploadImageBlob');
|
||||
|
||||
/**
|
||||
* Uploads canvas content as image blob
|
||||
* @param canvas - Canvas element or Canvas object with canvasLayers
|
||||
* @param options - Upload options
|
||||
* @returns Promise with upload result
|
||||
*/
|
||||
export const uploadCanvasAsImage = withErrorHandling(async function(canvas: any, options: UploadImageOptions = {}): Promise<UploadImageResult> {
|
||||
if (!canvas) {
|
||||
throw createValidationError("Canvas is required", { canvas });
|
||||
}
|
||||
|
||||
let blob: Blob | null = null;
|
||||
|
||||
// Handle different canvas types
|
||||
if (canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') {
|
||||
// LayerForge Canvas object
|
||||
blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
} else if (canvas instanceof HTMLCanvasElement) {
|
||||
// Standard HTML Canvas
|
||||
blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve));
|
||||
} else {
|
||||
throw createValidationError("Unsupported canvas type", {
|
||||
canvas,
|
||||
hasCanvasLayers: !!canvas.canvasLayers,
|
||||
isHTMLCanvas: canvas instanceof HTMLCanvasElement
|
||||
});
|
||||
}
|
||||
|
||||
if (!blob) {
|
||||
throw createValidationError("Failed to generate canvas blob", { canvas, options });
|
||||
}
|
||||
|
||||
return uploadImageBlob(blob, options);
|
||||
}, 'uploadCanvasAsImage');
|
||||
|
||||
/**
|
||||
* Uploads canvas with mask as image blob
|
||||
* @param canvas - Canvas object with canvasLayers
|
||||
* @param options - Upload options
|
||||
* @returns Promise with upload result
|
||||
*/
|
||||
export const uploadCanvasWithMaskAsImage = withErrorHandling(async function(canvas: any, options: UploadImageOptions = {}): Promise<UploadImageResult> {
|
||||
if (!canvas) {
|
||||
throw createValidationError("Canvas is required", { canvas });
|
||||
}
|
||||
if (!canvas.canvasLayers || typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob !== 'function') {
|
||||
throw createValidationError("Canvas does not support mask operations", {
|
||||
canvas,
|
||||
hasCanvasLayers: !!canvas.canvasLayers,
|
||||
hasMaskMethod: !!(canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function')
|
||||
});
|
||||
}
|
||||
|
||||
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (!blob) {
|
||||
throw createValidationError("Failed to generate canvas with mask blob", { canvas, options });
|
||||
}
|
||||
|
||||
return uploadImageBlob(blob, options);
|
||||
}, 'uploadCanvasWithMaskAsImage');
|
||||
@@ -1,5 +1,6 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
|
||||
import { createCanvas } from "./CommonUtils.js";
|
||||
import type { Tensor, ImageDataPixel } from '../types';
|
||||
|
||||
const log = createModuleLogger('ImageUtils');
|
||||
@@ -163,11 +164,7 @@ export const imageToTensor = withErrorHandling(async function (image: HTMLImageE
|
||||
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;
|
||||
const { canvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
|
||||
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0);
|
||||
@@ -197,11 +194,7 @@ export const tensorToImage = withErrorHandling(async function (tensor: 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;
|
||||
const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
@@ -234,17 +227,13 @@ export const resizeImage = withErrorHandling(async function (image: HTMLImageEle
|
||||
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;
|
||||
const { canvas, ctx } = createCanvas(newWidth, newHeight, '2d', { willReadFrequently: true });
|
||||
|
||||
if (ctx) {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
@@ -270,11 +259,9 @@ export const imageToBase64 = withErrorHandling(function (image: HTMLImageElement
|
||||
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;
|
||||
const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
|
||||
const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
|
||||
const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0);
|
||||
@@ -330,11 +317,7 @@ export function createImageFromSource(source: string): Promise<HTMLImageElement>
|
||||
}
|
||||
|
||||
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;
|
||||
const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||
|
||||
if (ctx) {
|
||||
if (color !== 'transparent') {
|
||||
@@ -351,3 +334,55 @@ export const createEmptyImage = withErrorHandling(function (width: number, heigh
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'createEmptyImage');
|
||||
|
||||
/**
|
||||
* Converts a canvas or image to an Image element
|
||||
* Consolidated from MaskProcessingUtils.convertToImage()
|
||||
* @param source - Source canvas or image
|
||||
* @returns Promise with Image element
|
||||
*/
|
||||
export async function convertToImage(source: HTMLCanvasElement | HTMLImageElement): Promise<HTMLImageElement> {
|
||||
if (source instanceof HTMLImageElement) {
|
||||
return source; // Already an image
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.src = source.toDataURL();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
image.onload = () => resolve();
|
||||
image.onerror = reject;
|
||||
});
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mask from image source for use in mask editor
|
||||
* Consolidated from mask_utils.create_mask_from_image_src()
|
||||
* @param imageSrc - Image source (URL or data URL)
|
||||
* @returns Promise returning Image object
|
||||
*/
|
||||
export function createMaskFromImageSrc(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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts canvas to Image for use as mask
|
||||
* Consolidated from mask_utils.canvas_to_mask_image()
|
||||
* @param canvas - Canvas to convert
|
||||
* @returns Promise returning Image object
|
||||
*/
|
||||
export function canvasToMaskImage(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();
|
||||
});
|
||||
}
|
||||
|
||||
250
src/utils/MaskProcessingUtils.ts
Normal file
250
src/utils/MaskProcessingUtils.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { createCanvas } from "./CommonUtils.js";
|
||||
import { convertToImage } from "./ImageUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
|
||||
const log = createModuleLogger('MaskProcessingUtils');
|
||||
|
||||
/**
|
||||
* Utility functions for processing masks and image data
|
||||
*/
|
||||
|
||||
export interface MaskProcessingOptions {
|
||||
/** Target width for the processed mask */
|
||||
targetWidth?: number;
|
||||
/** Target height for the processed mask */
|
||||
targetHeight?: number;
|
||||
/** Whether to invert the alpha channel (default: true) */
|
||||
invertAlpha?: boolean;
|
||||
/** Mask color RGB values (default: {r: 255, g: 255, b: 255}) */
|
||||
maskColor?: { r: number; g: number; b: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an image to create a mask with inverted alpha channel
|
||||
* @param sourceImage - Source image or canvas element
|
||||
* @param options - Processing options
|
||||
* @returns Promise with processed mask as HTMLCanvasElement
|
||||
*/
|
||||
export const processImageToMask = withErrorHandling(async function(
|
||||
sourceImage: HTMLImageElement | HTMLCanvasElement,
|
||||
options: MaskProcessingOptions = {}
|
||||
): Promise<HTMLCanvasElement> {
|
||||
if (!sourceImage) {
|
||||
throw createValidationError("Source image is required", { sourceImage });
|
||||
}
|
||||
|
||||
const {
|
||||
targetWidth = sourceImage.width,
|
||||
targetHeight = sourceImage.height,
|
||||
invertAlpha = true,
|
||||
maskColor = { r: 255, g: 255, b: 255 }
|
||||
} = options;
|
||||
|
||||
log.debug('Processing image to mask:', {
|
||||
sourceSize: { width: sourceImage.width, height: sourceImage.height },
|
||||
targetSize: { width: targetWidth, height: targetHeight },
|
||||
invertAlpha,
|
||||
maskColor
|
||||
});
|
||||
|
||||
// Create temporary canvas for processing
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true });
|
||||
|
||||
if (!tempCtx) {
|
||||
throw createValidationError("Failed to get 2D context for mask processing");
|
||||
}
|
||||
|
||||
// Draw the source image
|
||||
tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
// Get image data for processing
|
||||
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
|
||||
const data = imageData.data;
|
||||
|
||||
// Process pixels to create mask
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const originalAlpha = data[i + 3];
|
||||
|
||||
// Set RGB to mask color
|
||||
data[i] = maskColor.r; // Red
|
||||
data[i + 1] = maskColor.g; // Green
|
||||
data[i + 2] = maskColor.b; // Blue
|
||||
|
||||
// Handle alpha channel
|
||||
if (invertAlpha) {
|
||||
data[i + 3] = 255 - originalAlpha; // Invert alpha
|
||||
} else {
|
||||
data[i + 3] = originalAlpha; // Keep original alpha
|
||||
}
|
||||
}
|
||||
|
||||
// Put processed data back to canvas
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
|
||||
log.debug('Mask processing completed');
|
||||
return tempCanvas;
|
||||
}, 'processImageToMask');
|
||||
|
||||
/**
|
||||
* Processes image data with custom pixel transformation
|
||||
* @param sourceImage - Source image or canvas element
|
||||
* @param pixelTransform - Custom pixel transformation function
|
||||
* @param options - Processing options
|
||||
* @returns Promise with processed image as HTMLCanvasElement
|
||||
*/
|
||||
export const processImageWithTransform = withErrorHandling(async function(
|
||||
sourceImage: HTMLImageElement | HTMLCanvasElement,
|
||||
pixelTransform: (r: number, g: number, b: number, a: number, index: number) => [number, number, number, number],
|
||||
options: MaskProcessingOptions = {}
|
||||
): Promise<HTMLCanvasElement> {
|
||||
if (!sourceImage) {
|
||||
throw createValidationError("Source image is required", { sourceImage });
|
||||
}
|
||||
if (!pixelTransform || typeof pixelTransform !== 'function') {
|
||||
throw createValidationError("Pixel transform function is required", { pixelTransform });
|
||||
}
|
||||
|
||||
const {
|
||||
targetWidth = sourceImage.width,
|
||||
targetHeight = sourceImage.height
|
||||
} = options;
|
||||
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true });
|
||||
|
||||
if (!tempCtx) {
|
||||
throw createValidationError("Failed to get 2D context for image processing");
|
||||
}
|
||||
|
||||
tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight);
|
||||
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const [r, g, b, a] = pixelTransform(data[i], data[i + 1], data[i + 2], data[i + 3], i / 4);
|
||||
data[i] = r;
|
||||
data[i + 1] = g;
|
||||
data[i + 2] = b;
|
||||
data[i + 3] = a;
|
||||
}
|
||||
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
return tempCanvas;
|
||||
}, 'processImageWithTransform');
|
||||
|
||||
/**
|
||||
* Crops an image to a specific region
|
||||
* @param sourceImage - Source image or canvas
|
||||
* @param cropArea - Crop area {x, y, width, height}
|
||||
* @returns Promise with cropped image as HTMLCanvasElement
|
||||
*/
|
||||
export const cropImage = withErrorHandling(async function(
|
||||
sourceImage: HTMLImageElement | HTMLCanvasElement,
|
||||
cropArea: { x: number; y: number; width: number; height: number }
|
||||
): Promise<HTMLCanvasElement> {
|
||||
if (!sourceImage) {
|
||||
throw createValidationError("Source image is required", { sourceImage });
|
||||
}
|
||||
if (!cropArea || typeof cropArea !== 'object') {
|
||||
throw createValidationError("Crop area is required", { cropArea });
|
||||
}
|
||||
|
||||
const { x, y, width, height } = cropArea;
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw createValidationError("Crop area must have positive width and height", { cropArea });
|
||||
}
|
||||
|
||||
log.debug('Cropping image:', {
|
||||
sourceSize: { width: sourceImage.width, height: sourceImage.height },
|
||||
cropArea
|
||||
});
|
||||
|
||||
const { canvas, ctx } = createCanvas(width, height);
|
||||
|
||||
if (!ctx) {
|
||||
throw createValidationError("Failed to get 2D context for image cropping");
|
||||
}
|
||||
|
||||
ctx.drawImage(
|
||||
sourceImage,
|
||||
x, y, width, height, // Source rectangle
|
||||
0, 0, width, height // Destination rectangle
|
||||
);
|
||||
|
||||
return canvas;
|
||||
}, 'cropImage');
|
||||
|
||||
/**
|
||||
* Applies a mask to an image using viewport positioning
|
||||
* @param maskImage - Mask image or canvas
|
||||
* @param targetWidth - Target viewport width
|
||||
* @param targetHeight - Target viewport height
|
||||
* @param viewportOffset - Viewport offset {x, y}
|
||||
* @param maskColor - Mask color (default: white)
|
||||
* @returns Promise with processed mask for viewport
|
||||
*/
|
||||
export const processMaskForViewport = withErrorHandling(async function(
|
||||
maskImage: HTMLImageElement | HTMLCanvasElement,
|
||||
targetWidth: number,
|
||||
targetHeight: number,
|
||||
viewportOffset: { x: number; y: number },
|
||||
maskColor: { r: number; g: number; b: number } = { r: 255, g: 255, b: 255 }
|
||||
): Promise<HTMLCanvasElement> {
|
||||
if (!maskImage) {
|
||||
throw createValidationError("Mask image is required", { maskImage });
|
||||
}
|
||||
if (!viewportOffset || typeof viewportOffset !== 'object') {
|
||||
throw createValidationError("Viewport offset is required", { viewportOffset });
|
||||
}
|
||||
if (targetWidth <= 0 || targetHeight <= 0) {
|
||||
throw createValidationError("Target dimensions must be positive", { targetWidth, targetHeight });
|
||||
}
|
||||
|
||||
log.debug("Processing mask for viewport:", {
|
||||
sourceSize: { width: maskImage.width, height: maskImage.height },
|
||||
targetSize: { width: targetWidth, height: targetHeight },
|
||||
viewportOffset
|
||||
});
|
||||
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true });
|
||||
|
||||
if (!tempCtx) {
|
||||
throw createValidationError("Failed to get 2D context for viewport mask processing");
|
||||
}
|
||||
|
||||
// Calculate source coordinates based on viewport offset
|
||||
const sourceX = -viewportOffset.x;
|
||||
const sourceY = -viewportOffset.y;
|
||||
|
||||
// Draw the mask with viewport cropping
|
||||
tempCtx.drawImage(
|
||||
maskImage, // Source: full mask from "output area"
|
||||
sourceX, // sx: Real X coordinate on large mask
|
||||
sourceY, // sy: Real Y coordinate on large mask
|
||||
targetWidth, // sWidth: Width of cropped fragment
|
||||
targetHeight, // sHeight: Height of cropped fragment
|
||||
0, // dx: Where to paste in target canvas (always 0)
|
||||
0, // dy: Where to paste in target canvas (always 0)
|
||||
targetWidth, // dWidth: Width of pasted image
|
||||
targetHeight // dHeight: Height of pasted image
|
||||
);
|
||||
|
||||
// Apply mask color
|
||||
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.debug("Viewport mask processing completed");
|
||||
|
||||
return tempCanvas;
|
||||
}, 'processMaskForViewport');
|
||||
254
src/utils/NotificationUtils.ts
Normal file
254
src/utils/NotificationUtils.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
|
||||
const log = createModuleLogger('NotificationUtils');
|
||||
|
||||
/**
|
||||
* Utility functions for showing notifications to the user
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shows a temporary notification to the user
|
||||
* @param message - The message to show
|
||||
* @param backgroundColor - Background color (default: #4a6cd4)
|
||||
* @param duration - Duration in milliseconds (default: 3000)
|
||||
*/
|
||||
export function showNotification(
|
||||
message: string,
|
||||
backgroundColor: string = "#4a6cd4",
|
||||
duration: number = 3000,
|
||||
type: "success" | "error" | "info" | "warning" | "alert" = "info"
|
||||
): void {
|
||||
// Remove any existing prefix to avoid double prefixing
|
||||
message = message.replace(/^\[Layer Forge\]\s*/, "");
|
||||
|
||||
// Type-specific config
|
||||
const config = {
|
||||
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
|
||||
error: { icon: "❌", title: "Error", bg: "#ff6f6f" },
|
||||
info: { icon: "ℹ️", title: "Info", bg: "#4a6cd4" },
|
||||
warning: { icon: "⚠️", title: "Warning", bg: "#ffd43b" },
|
||||
alert: { icon: "⚠️", title: "Alert", bg: "#fff7cc" }
|
||||
}[type];
|
||||
|
||||
// --- Get or create the main notification container ---
|
||||
let container = document.getElementById('lf-notification-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'lf-notification-container';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// --- Dark, modern notification style ---
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
min-width: 380px;
|
||||
max-width: 440px;
|
||||
max-height: 80vh;
|
||||
background: rgba(30, 32, 41, 0.9);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 32px rgba(0,0,0,0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
font-family: 'Segoe UI', 'Arial', sans-serif;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(80, 80, 80, 0.5);
|
||||
backdrop-filter: blur(8px);
|
||||
animation: lf-fadein 0.3s ease-out;
|
||||
`;
|
||||
|
||||
// --- Header (non-scrollable) ---
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = `display: flex; align-items: flex-start; padding: 16px 20px; position: relative; flex-shrink: 0;`;
|
||||
|
||||
const leftBar = document.createElement('div');
|
||||
leftBar.style.cssText = `position: absolute; left: 0; top: 0; bottom: 0; width: 6px; background: ${config.bg}; box-shadow: 0 0 12px ${config.bg}; border-radius: 3px 0 0 3px;`;
|
||||
|
||||
const iconContainer = document.createElement('div');
|
||||
iconContainer.style.cssText = `width: 48px; height: 48px; min-width: 48px; min-height: 48px; display: flex; align-items: center; justify-content: center; margin-left: 18px; margin-right: 18px;`;
|
||||
iconContainer.innerHTML = {
|
||||
success: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-succ"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 4 L44 14 L44 34 L24 44 L4 34 L4 14 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/><g filter="url(#f-succ)"><path d="M16 24 L22 30 L34 18" stroke="#fff" stroke-width="3" fill="none"/></g></svg>`,
|
||||
error: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-err"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M14 14 L34 34 M34 14 L14 34" fill="none" stroke="#fff" stroke-width="3"/><g filter="url(#f-err)"><path d="M24,4 L42,12 L42,36 L24,44 L6,36 L6,12 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
|
||||
info: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-info"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 16 M24 22 L24 34" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-info)"><path d="M12,4 L36,4 L44,12 L44,36 L36,44 L12,44 L4,36 L4,12 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
|
||||
warning: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-warn"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 28 M24 34 L24 36" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-warn)"><path d="M24,4 L46,24 L24,44 L2,24 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
|
||||
alert: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-alert"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 28 M24 34 L24 36" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-alert)"><path d="M24,4 L46,24 L24,44 L2,24 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`
|
||||
}[type];
|
||||
|
||||
const headerTextContent = document.createElement('div');
|
||||
headerTextContent.style.cssText = `display: flex; flex-direction: column; justify-content: center; flex: 1; min-width: 0;`;
|
||||
const titleSpan = document.createElement('div');
|
||||
titleSpan.style.cssText = `font-weight: 700; font-size: 16px; margin-bottom: 4px; color: #fff; text-transform: uppercase; letter-spacing: 0.5px;`;
|
||||
titleSpan.textContent = config.title;
|
||||
headerTextContent.appendChild(titleSpan);
|
||||
|
||||
const topRightContainer = document.createElement('div');
|
||||
topRightContainer.style.cssText = `position: absolute; top: 14px; right: 18px; display: flex; align-items: center; gap: 12px;`;
|
||||
const tag = document.createElement('span');
|
||||
tag.style.cssText = `font-size: 11px; font-weight: 600; color: #fff; background: ${config.bg}; border-radius: 4px; padding: 2px 8px; box-shadow: 0 0 8px ${config.bg};`;
|
||||
tag.innerHTML = '🎨 Layer Forge';
|
||||
const getTextColorForBg = (hexColor: string): string => {
|
||||
const r = parseInt(hexColor.slice(1, 3), 16), g = parseInt(hexColor.slice(3, 5), 16), b = parseInt(hexColor.slice(5, 7), 16);
|
||||
return ((0.299 * r + 0.587 * g + 0.114 * b) / 255) > 0.5 ? '#000' : '#fff';
|
||||
};
|
||||
tag.style.color = getTextColorForBg(config.bg);
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.setAttribute("aria-label", "Close notification");
|
||||
closeBtn.style.cssText = `background: none; border: none; color: #ccc; font-size: 22px; font-weight: bold; cursor: pointer; padding: 0; opacity: 0.7; transition: opacity 0.15s; line-height: 1;`;
|
||||
topRightContainer.appendChild(tag);
|
||||
topRightContainer.appendChild(closeBtn);
|
||||
|
||||
header.appendChild(iconContainer);
|
||||
header.appendChild(headerTextContent);
|
||||
header.appendChild(topRightContainer);
|
||||
|
||||
// --- Scrollable Body ---
|
||||
const body = document.createElement('div');
|
||||
body.style.cssText = `padding: 0px 20px 16px 20px; overflow-y: auto; flex: 1;`;
|
||||
const msgSpan = document.createElement('div');
|
||||
msgSpan.style.cssText = `font-size: 14px; color: #ccc; line-height: 1.5; white-space: pre-wrap; word-break: break-word;`;
|
||||
msgSpan.textContent = message;
|
||||
body.appendChild(msgSpan);
|
||||
|
||||
// --- Progress Bar ---
|
||||
const progressBar = document.createElement('div');
|
||||
progressBar.style.cssText = `height: 4px; width: 100%; background: ${config.bg}; box-shadow: 0 0 12px ${config.bg}; transform-origin: left; animation: lf-progress ${duration / 1000}s linear; flex-shrink: 0;`;
|
||||
|
||||
// --- Assemble Notification ---
|
||||
notification.appendChild(leftBar);
|
||||
notification.appendChild(header);
|
||||
notification.appendChild(body);
|
||||
|
||||
if (type === 'error') {
|
||||
const footer = document.createElement('div');
|
||||
footer.style.cssText = `padding: 0 20px 12px 86px; flex-shrink: 0;`;
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.textContent = 'Copy Error';
|
||||
copyButton.style.cssText = `background: rgba(255, 111, 111, 0.2); border: 1px solid #ff6f6f; color: #ffafaf; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; transition: background 0.2s;`;
|
||||
copyButton.onmouseenter = () => copyButton.style.background = 'rgba(255, 111, 111, 0.3)';
|
||||
copyButton.onmouseleave = () => copyButton.style.background = 'rgba(255, 111, 111, 0.2)';
|
||||
copyButton.onclick = () => {
|
||||
navigator.clipboard.writeText(message)
|
||||
.then(() => showSuccessNotification("Error message copied!", 2000))
|
||||
.catch(err => console.error('Failed to copy error message: ', err));
|
||||
};
|
||||
footer.appendChild(copyButton);
|
||||
notification.appendChild(footer);
|
||||
}
|
||||
notification.appendChild(progressBar);
|
||||
|
||||
// Add to DOM
|
||||
container.appendChild(notification);
|
||||
|
||||
// --- Keyframes and Timer Logic ---
|
||||
const styleSheet = document.getElementById('lf-notification-styles') as HTMLStyleElement;
|
||||
if (!styleSheet) {
|
||||
const newStyleSheet = document.createElement("style");
|
||||
newStyleSheet.id = 'lf-notification-styles';
|
||||
newStyleSheet.innerText = `
|
||||
@keyframes lf-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
|
||||
@keyframes lf-progress-rewind { to { transform: scaleX(1); } }
|
||||
@keyframes lf-fadein { from { opacity: 0; transform: scale(0.95) translateX(20px); } to { opacity: 1; transform: scale(1) translateX(0); } }
|
||||
@keyframes lf-fadeout { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } }
|
||||
.notification-scrollbar::-webkit-scrollbar { width: 8px; }
|
||||
.notification-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); border-radius: 4px; }
|
||||
.notification-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.25); border-radius: 4px; }
|
||||
.notification-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.4); }
|
||||
`;
|
||||
document.head.appendChild(newStyleSheet);
|
||||
}
|
||||
body.classList.add('notification-scrollbar');
|
||||
|
||||
let dismissTimeout: number | null = null;
|
||||
const closeNotification = () => {
|
||||
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
|
||||
notification.addEventListener('animationend', () => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
if (container && container.children.length === 0) {
|
||||
container.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
closeBtn.onclick = closeNotification;
|
||||
|
||||
const startDismissTimer = () => {
|
||||
dismissTimeout = window.setTimeout(closeNotification, duration);
|
||||
progressBar.style.animation = `lf-progress ${duration / 1000}s linear`;
|
||||
};
|
||||
|
||||
const pauseAndRewindTimer = () => {
|
||||
if (dismissTimeout !== null) clearTimeout(dismissTimeout);
|
||||
dismissTimeout = null;
|
||||
const computedStyle = window.getComputedStyle(progressBar);
|
||||
progressBar.style.transform = computedStyle.transform;
|
||||
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
|
||||
};
|
||||
|
||||
notification.addEventListener('mouseenter', pauseAndRewindTimer);
|
||||
notification.addEventListener('mouseleave', startDismissTimer);
|
||||
|
||||
startDismissTimer();
|
||||
log.debug(`Notification shown: [Layer Forge] ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a success notification
|
||||
*/
|
||||
export function showSuccessNotification(message: string, duration: number = 3000): void {
|
||||
showNotification(message, undefined, duration, "success");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an error notification
|
||||
*/
|
||||
export function showErrorNotification(message: string, duration: number = 5000): void {
|
||||
showNotification(message, undefined, duration, "error");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an info notification
|
||||
*/
|
||||
export function showInfoNotification(message: string, duration: number = 3000): void {
|
||||
showNotification(message, undefined, duration, "info");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a warning notification
|
||||
*/
|
||||
export function showWarningNotification(message: string, duration: number = 3000): void {
|
||||
showNotification(message, undefined, duration, "warning");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an alert notification
|
||||
*/
|
||||
export function showAlertNotification(message: string, duration: number = 3000): void {
|
||||
showNotification(message, undefined, duration, "alert");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a sequence of all notification types for debugging purposes.
|
||||
*/
|
||||
export function showAllNotificationTypes(message?: string): void {
|
||||
const types: ("success" | "error" | "info" | "warning" | "alert")[] = ["success", "error", "info", "warning", "alert"];
|
||||
types.forEach((type, index) => {
|
||||
const notificationMessage = message || `This is a '${type}' notification.`;
|
||||
setTimeout(() => {
|
||||
showNotification(notificationMessage, undefined, 3000, type);
|
||||
}, index * 400); // Stagger the notifications
|
||||
});
|
||||
}
|
||||
254
src/utils/PreviewUtils.ts
Normal file
254
src/utils/PreviewUtils.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
import type { ComfyNode } from '../types';
|
||||
|
||||
const log = createModuleLogger('PreviewUtils');
|
||||
|
||||
/**
|
||||
* Utility functions for creating and managing preview images
|
||||
*/
|
||||
|
||||
export interface PreviewOptions {
|
||||
/** Whether to include mask in the preview (default: true) */
|
||||
includeMask?: boolean;
|
||||
/** Whether to update node.imgs array (default: true) */
|
||||
updateNodeImages?: boolean;
|
||||
/** Custom blob source instead of canvas */
|
||||
customBlob?: Blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a preview image from canvas and updates node
|
||||
* @param canvas - Canvas object with canvasLayers
|
||||
* @param node - ComfyUI node to update
|
||||
* @param options - Preview options
|
||||
* @returns Promise with created Image element
|
||||
*/
|
||||
export const createPreviewFromCanvas = withErrorHandling(async function(
|
||||
canvas: any,
|
||||
node: ComfyNode,
|
||||
options: PreviewOptions = {}
|
||||
): Promise<HTMLImageElement> {
|
||||
if (!canvas) {
|
||||
throw createValidationError("Canvas is required", { canvas });
|
||||
}
|
||||
if (!node) {
|
||||
throw createValidationError("Node is required", { node });
|
||||
}
|
||||
|
||||
const {
|
||||
includeMask = true,
|
||||
updateNodeImages = true,
|
||||
customBlob
|
||||
} = options;
|
||||
|
||||
log.debug('Creating preview from canvas:', {
|
||||
includeMask,
|
||||
updateNodeImages,
|
||||
hasCustomBlob: !!customBlob,
|
||||
nodeId: node.id
|
||||
});
|
||||
|
||||
let blob: Blob | null = customBlob || null;
|
||||
|
||||
// Get blob from canvas if not provided
|
||||
if (!blob) {
|
||||
if (!canvas.canvasLayers) {
|
||||
throw createValidationError("Canvas does not have canvasLayers", { canvas });
|
||||
}
|
||||
|
||||
if (includeMask && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function') {
|
||||
blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
} else if (typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') {
|
||||
blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
} else {
|
||||
throw createValidationError("Canvas does not support required blob generation methods", {
|
||||
canvas,
|
||||
availableMethods: Object.getOwnPropertyNames(canvas.canvasLayers)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!blob) {
|
||||
throw createValidationError("Failed to generate canvas blob for preview", { canvas, options });
|
||||
}
|
||||
|
||||
// Create preview image
|
||||
const previewImage = new Image();
|
||||
previewImage.src = URL.createObjectURL(blob);
|
||||
|
||||
// Wait for image to load
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
previewImage.onload = () => {
|
||||
log.debug("Preview image loaded successfully", {
|
||||
width: previewImage.width,
|
||||
height: previewImage.height,
|
||||
nodeId: node.id
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
previewImage.onerror = (error) => {
|
||||
log.error("Failed to load preview image", error);
|
||||
reject(createValidationError("Failed to load preview image", { error, blob: blob?.size }));
|
||||
};
|
||||
});
|
||||
|
||||
// Update node images if requested
|
||||
if (updateNodeImages) {
|
||||
node.imgs = [previewImage];
|
||||
log.debug("Node images updated with new preview");
|
||||
}
|
||||
|
||||
return previewImage;
|
||||
}, 'createPreviewFromCanvas');
|
||||
|
||||
/**
|
||||
* Creates a preview image from a blob
|
||||
* @param blob - Image blob
|
||||
* @param node - ComfyUI node to update (optional)
|
||||
* @param updateNodeImages - Whether to update node.imgs (default: false)
|
||||
* @returns Promise with created Image element
|
||||
*/
|
||||
export const createPreviewFromBlob = withErrorHandling(async function(
|
||||
blob: Blob,
|
||||
node?: ComfyNode,
|
||||
updateNodeImages: boolean = false
|
||||
): Promise<HTMLImageElement> {
|
||||
if (!blob) {
|
||||
throw createValidationError("Blob is required", { blob });
|
||||
}
|
||||
if (blob.size === 0) {
|
||||
throw createValidationError("Blob cannot be empty", { blobSize: blob.size });
|
||||
}
|
||||
|
||||
log.debug('Creating preview from blob:', {
|
||||
blobSize: blob.size,
|
||||
updateNodeImages,
|
||||
hasNode: !!node
|
||||
});
|
||||
|
||||
const previewImage = new Image();
|
||||
previewImage.src = URL.createObjectURL(blob);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
previewImage.onload = () => {
|
||||
log.debug("Preview image from blob loaded successfully", {
|
||||
width: previewImage.width,
|
||||
height: previewImage.height
|
||||
});
|
||||
resolve();
|
||||
};
|
||||
previewImage.onerror = (error) => {
|
||||
log.error("Failed to load preview image from blob", error);
|
||||
reject(createValidationError("Failed to load preview image from blob", { error, blobSize: blob.size }));
|
||||
};
|
||||
});
|
||||
|
||||
if (updateNodeImages && node) {
|
||||
node.imgs = [previewImage];
|
||||
log.debug("Node images updated with blob preview");
|
||||
}
|
||||
|
||||
return previewImage;
|
||||
}, 'createPreviewFromBlob');
|
||||
|
||||
/**
|
||||
* Updates node preview after canvas changes
|
||||
* @param canvas - Canvas object
|
||||
* @param node - ComfyUI node
|
||||
* @param includeMask - Whether to include mask in preview
|
||||
* @returns Promise with updated preview image
|
||||
*/
|
||||
export const updateNodePreview = withErrorHandling(async function(
|
||||
canvas: any,
|
||||
node: ComfyNode,
|
||||
includeMask: boolean = true
|
||||
): Promise<HTMLImageElement> {
|
||||
if (!canvas) {
|
||||
throw createValidationError("Canvas is required", { canvas });
|
||||
}
|
||||
if (!node) {
|
||||
throw createValidationError("Node is required", { node });
|
||||
}
|
||||
|
||||
log.info('Updating node preview:', {
|
||||
nodeId: node.id,
|
||||
includeMask
|
||||
});
|
||||
|
||||
// Trigger canvas render and save state
|
||||
if (typeof canvas.render === 'function') {
|
||||
canvas.render();
|
||||
}
|
||||
|
||||
if (typeof canvas.saveState === 'function') {
|
||||
canvas.saveState();
|
||||
}
|
||||
|
||||
// Create new preview
|
||||
const previewImage = await createPreviewFromCanvas(canvas, node, {
|
||||
includeMask,
|
||||
updateNodeImages: true
|
||||
});
|
||||
|
||||
log.info('Node preview updated successfully');
|
||||
return previewImage;
|
||||
}, 'updateNodePreview');
|
||||
|
||||
/**
|
||||
* Clears node preview images
|
||||
* @param node - ComfyUI node
|
||||
*/
|
||||
export function clearNodePreview(node: ComfyNode): void {
|
||||
log.debug('Clearing node preview:', { nodeId: node.id });
|
||||
node.imgs = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if node has preview images
|
||||
* @param node - ComfyUI node
|
||||
* @returns True if node has preview images
|
||||
*/
|
||||
export function hasNodePreview(node: ComfyNode): boolean {
|
||||
return !!(node.imgs && node.imgs.length > 0 && node.imgs[0].src);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current preview image from node
|
||||
* @param node - ComfyUI node
|
||||
* @returns Current preview image or null
|
||||
*/
|
||||
export function getCurrentPreview(node: ComfyNode): HTMLImageElement | null {
|
||||
if (hasNodePreview(node) && node.imgs) {
|
||||
return node.imgs[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a preview with custom processing
|
||||
* @param canvas - Canvas object
|
||||
* @param node - ComfyUI node
|
||||
* @param processor - Custom processing function that takes canvas and returns blob
|
||||
* @returns Promise with processed preview image
|
||||
*/
|
||||
export const createCustomPreview = withErrorHandling(async function(
|
||||
canvas: any,
|
||||
node: ComfyNode,
|
||||
processor: (canvas: any) => Promise<Blob>
|
||||
): Promise<HTMLImageElement> {
|
||||
if (!canvas) {
|
||||
throw createValidationError("Canvas is required", { canvas });
|
||||
}
|
||||
if (!node) {
|
||||
throw createValidationError("Node is required", { node });
|
||||
}
|
||||
if (!processor || typeof processor !== 'function') {
|
||||
throw createValidationError("Processor function is required", { processor });
|
||||
}
|
||||
|
||||
log.debug('Creating custom preview:', { nodeId: node.id });
|
||||
|
||||
const blob = await processor(canvas);
|
||||
return createPreviewFromBlob(blob, node, true);
|
||||
}, 'createCustomPreview');
|
||||
@@ -1,7 +1,17 @@
|
||||
// @ts-ignore
|
||||
import { $el } from "../../../scripts/ui.js";
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
|
||||
|
||||
const log = createModuleLogger('ResourceManager');
|
||||
|
||||
export const addStylesheet = withErrorHandling(function(url: string): void {
|
||||
if (!url) {
|
||||
throw createValidationError("URL is required", { url });
|
||||
}
|
||||
|
||||
log.debug('Adding stylesheet:', { url });
|
||||
|
||||
export function addStylesheet(url: string): void {
|
||||
if (url.endsWith(".js")) {
|
||||
url = url.substr(0, url.length - 2) + "css";
|
||||
}
|
||||
@@ -11,9 +21,15 @@ export function addStylesheet(url: string): void {
|
||||
type: "text/css",
|
||||
href: url.startsWith("http") ? url : getUrl(url),
|
||||
});
|
||||
}
|
||||
|
||||
log.debug('Stylesheet added successfully:', { finalUrl: url });
|
||||
}, 'addStylesheet');
|
||||
|
||||
export function getUrl(path: string, baseUrl?: string | URL): string {
|
||||
if (!path) {
|
||||
throw createValidationError("Path is required", { path });
|
||||
}
|
||||
|
||||
if (baseUrl) {
|
||||
return new URL(path, baseUrl).toString();
|
||||
} else {
|
||||
@@ -22,11 +38,24 @@ export function getUrl(path: string, baseUrl?: string | URL): string {
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadTemplate(path: string, baseUrl?: string | URL): Promise<string> {
|
||||
export const loadTemplate = withErrorHandling(async function(path: string, baseUrl?: string | URL): Promise<string> {
|
||||
if (!path) {
|
||||
throw createValidationError("Path is required", { path });
|
||||
}
|
||||
|
||||
const url = getUrl(path, baseUrl);
|
||||
log.debug('Loading template:', { path, url });
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load template: ${url}`);
|
||||
throw createNetworkError(`Failed to load template: ${url}`, {
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
});
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
log.debug('Template loaded successfully:', { path, contentLength: content.length });
|
||||
return content;
|
||||
}, 'loadTemplate');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
|
||||
import type { WebSocketMessage, AckCallbacks } from "../types.js";
|
||||
|
||||
const log = createModuleLogger('WebSocketManager');
|
||||
@@ -26,7 +27,7 @@ class WebSocketManager {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
connect = withErrorHandling(() => {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
log.debug("WebSocket is already open.");
|
||||
return;
|
||||
@@ -37,58 +38,56 @@ class WebSocketManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.url) {
|
||||
throw createValidationError("WebSocket URL is required", { url: this.url });
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
log.info(`Connecting to WebSocket at ${this.url}...`);
|
||||
|
||||
try {
|
||||
this.socket = new WebSocket(this.url);
|
||||
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.socket.onopen = () => {
|
||||
this.isConnecting = false;
|
||||
log.error("Failed to create WebSocket connection:", error);
|
||||
this.handleReconnect();
|
||||
}
|
||||
}
|
||||
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;
|
||||
throw createNetworkError("WebSocket connection error", { error, url: this.url });
|
||||
};
|
||||
}, 'WebSocketManager.connect');
|
||||
|
||||
handleReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
@@ -100,13 +99,17 @@ class WebSocketManager {
|
||||
}
|
||||
}
|
||||
|
||||
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."));
|
||||
}
|
||||
sendMessage = withErrorHandling(async (data: WebSocketMessage, requiresAck = false): Promise<WebSocketMessage | void> => {
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw createValidationError("Message data is required", { data });
|
||||
}
|
||||
|
||||
const nodeId = data.nodeId;
|
||||
if (requiresAck && !nodeId) {
|
||||
throw createValidationError("A nodeId is required for messages that need acknowledgment", { data, requiresAck });
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const message = JSON.stringify(data);
|
||||
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
@@ -117,7 +120,7 @@ class WebSocketManager {
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.ackCallbacks.delete(nodeId);
|
||||
reject(new Error(`ACK timeout for nodeId ${nodeId}`));
|
||||
reject(createNetworkError(`ACK timeout for nodeId ${nodeId}`, { nodeId, timeout: 10000 }));
|
||||
log.warn(`ACK timeout for nodeId ${nodeId}.`);
|
||||
}, 10000); // 10-second timeout
|
||||
|
||||
@@ -142,13 +145,16 @@ class WebSocketManager {
|
||||
}
|
||||
|
||||
if (requiresAck) {
|
||||
reject(new Error("Cannot send message with ACK required while disconnected."));
|
||||
reject(createNetworkError("Cannot send message with ACK required while disconnected", {
|
||||
socketState: this.socket?.readyState,
|
||||
isConnecting: this.isConnecting
|
||||
}));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 'WebSocketManager.sendMessage');
|
||||
|
||||
flushMessageQueue() {
|
||||
log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
import type { Canvas } from '../Canvas.js';
|
||||
// @ts-ignore
|
||||
import {ComfyApp} from "../../../scripts/app.js";
|
||||
@@ -146,51 +147,28 @@ export function press_maskeditor_cancel(app: ComfyApp): void {
|
||||
* @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;
|
||||
export const start_mask_editor_with_predefined_mask = withErrorHandling(function(canvasInstance: Canvas, maskImage: HTMLImageElement | HTMLCanvasElement, sendCleanImage = true): void {
|
||||
if (!canvasInstance) {
|
||||
throw createValidationError('Canvas instance is required', { canvasInstance });
|
||||
}
|
||||
if (!maskImage) {
|
||||
throw createValidationError('Mask image is required', { maskImage });
|
||||
}
|
||||
|
||||
canvasInstance.startMaskEditor(maskImage, sendCleanImage);
|
||||
}
|
||||
}, 'start_mask_editor_with_predefined_mask');
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
export const start_mask_editor_auto = withErrorHandling(function(canvasInstance: Canvas): void {
|
||||
if (!canvasInstance) {
|
||||
log.error('Canvas instance is required');
|
||||
return;
|
||||
throw createValidationError('Canvas instance is required', { canvasInstance });
|
||||
}
|
||||
canvasInstance.startMaskEditor(null, true);
|
||||
}
|
||||
}, 'start_mask_editor_auto');
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
}
|
||||
// Duplikowane funkcje zostały przeniesione do ImageUtils.ts:
|
||||
// - create_mask_from_image_src -> createMaskFromImageSrc
|
||||
// - canvas_to_mask_image -> canvasToMaskImage
|
||||
|
||||
Reference in New Issue
Block a user