mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 05:02:11 -03:00
Compare commits
183 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11dd554204 | ||
|
|
9f21ff13ae | ||
|
|
1a1d8748cb | ||
|
|
38973b4698 | ||
|
|
1bd261bee0 | ||
|
|
df6979a59b | ||
|
|
2427f0bc5f | ||
|
|
3356c631bb | ||
|
|
3d34bfafd5 | ||
|
|
3c3e6934d7 | ||
|
|
84e1e4820c | ||
|
|
012368c52b | ||
|
|
82c42f99fe | ||
|
|
5da0855a52 | ||
|
|
ed9fdf5d60 | ||
|
|
d84b9385ad | ||
|
|
c4318d4923 | ||
|
|
5b54ab28cb | ||
|
|
503ec126a5 | ||
|
|
3d6e3901d0 | ||
|
|
4df89a793e | ||
|
|
e42e08e35d | ||
|
|
7ed6f7ee93 | ||
|
|
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 | ||
|
|
f3027587d6 | ||
|
|
20d52b632a | ||
|
|
57bd1e1499 | ||
|
|
674879b497 | ||
|
|
98d4769ba1 | ||
|
|
5419acad27 | ||
|
|
db65c0c72e | ||
|
|
3c85b99167 | ||
|
|
4e55bb25bc | ||
|
|
5adc77471f | ||
|
|
3e4cdf10bc | ||
|
|
bd8007d8aa | ||
|
|
af5e81c56b | ||
|
|
aa31a347d1 | ||
|
|
dfa7309132 | ||
|
|
2ab406ebfd | ||
|
|
d40f68b8c6 | ||
|
|
e5060fd8c3 | ||
|
|
f8eb91c4ad | ||
|
|
c4af745b2a | ||
|
|
c9c0babf3c | ||
|
|
152a3f7dff | ||
|
|
9f9a733731 | ||
|
|
3419061b6c | ||
|
|
9e4da30b59 | ||
|
|
2f730c87fa | ||
|
|
aca1f4e422 | ||
|
|
195e25437a | ||
|
|
d1004d5864 | ||
|
|
d2ccfc4e20 | ||
|
|
2c313f43e8 | ||
|
|
2636521026 | ||
|
|
e0a4549321 | ||
|
|
29ab916759 | ||
|
|
ac21aa9579 | ||
|
|
cae24310db | ||
|
|
7d8fd30bbf | ||
|
|
244d48728c | ||
|
|
ef01be3323 | ||
|
|
b3d1206f3f | ||
|
|
a73a3dcf96 | ||
|
|
53aa35491e | ||
|
|
b3b901a8d6 | ||
|
|
826f448af9 | ||
|
|
42e13f1551 | ||
|
|
562b0db042 | ||
|
|
038dad759a | ||
|
|
6f4602eb31 | ||
|
|
cac7652b7d | ||
|
|
d5573f426c | ||
|
|
979fcd59bc | ||
|
|
4ec470a3ed | ||
|
|
8a456db6a0 | ||
|
|
55a60d710c | ||
|
|
e40c85b0ee | ||
|
|
145d64ea39 | ||
|
|
281350f75a | ||
|
|
dc3197e914 | ||
|
|
5a71eb46db | ||
|
|
35d3c77ba8 | ||
|
|
813df556fb | ||
|
|
6372aea90c | ||
|
|
9ab8680a85 | ||
|
|
90a0c6476f | ||
|
|
3544576605 | ||
|
|
3b16c00b66 | ||
|
|
d0ade5ebc7 | ||
|
|
9dcf38b36d | ||
|
|
7a7c8f2295 | ||
|
|
fc8ebedb1e | ||
|
|
98037324cd | ||
|
|
372a7a4718 | ||
|
|
8a18e4ec30 | ||
|
|
ade3cd7818 | ||
|
|
4a9dc3219b |
138
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
138
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Report an error or unexpected behavior
|
||||
title: "[BUG] "
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Thank you for reporting a bug!**
|
||||
Please follow these steps to capture all necessary information:
|
||||
|
||||
### ✅ Before You Report:
|
||||
1. Make sure you have the **latest versions**:
|
||||
- [ComfyUI Github](https://github.com/comfyanonymous/ComfyUI/releases)
|
||||
- [LayerForge Github](https://github.com/Azornes/Comfyui-LayerForge/releases) or via [ComfyUI Node Manager](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
|
||||
2. Gather the required logs:
|
||||
|
||||
### 🔍 Enable Debug Logs (for **full** logs):
|
||||
|
||||
#### 1. Edit `config.js` (Frontend Logs):
|
||||
Path:
|
||||
```
|
||||
ComfyUI/custom_nodes/Comfyui-LayerForge/js/config.js
|
||||
```
|
||||
Find:
|
||||
```js
|
||||
export const LOG_LEVEL = 'NONE';
|
||||
```
|
||||
Change to:
|
||||
```js
|
||||
export const LOG_LEVEL = 'DEBUG';
|
||||
```
|
||||
|
||||
#### 2. Edit `config.py` (Backend Logs):
|
||||
Path:
|
||||
```
|
||||
ComfyUI/custom_nodes/Comfyui-LayerForge/python/config.py
|
||||
```
|
||||
Find:
|
||||
```python
|
||||
LOG_LEVEL = 'NONE'
|
||||
```
|
||||
Change to:
|
||||
```python
|
||||
LOG_LEVEL = 'DEBUG'
|
||||
```
|
||||
|
||||
➡️ **Restart ComfyUI** after applying these changes to activate full logging.
|
||||
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment (OS, ComfyUI version, LayerForge version)
|
||||
placeholder: e.g. Windows 11, ComfyUI v0.3.43, LayerForge v1.2.4
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser & Version
|
||||
placeholder: e.g. Chrome 115.0.0, Firefox 120.1.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what_happened
|
||||
attributes:
|
||||
label: What Happened?
|
||||
placeholder: Describe the issue you encountered
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
placeholder: |
|
||||
1. …
|
||||
2. …
|
||||
3. …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
placeholder: Describe what you expected to happen
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
placeholder: Describe what happened instead
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: backend_logs
|
||||
attributes:
|
||||
label: ComfyUI (Backend) Logs
|
||||
description: |
|
||||
After enabling DEBUG logs, please:
|
||||
1. Restart ComfyUI.
|
||||
2. Reproduce the issue.
|
||||
3. Copy-paste the newest **TEXT** logs from the terminal/console here.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: console_logs
|
||||
attributes:
|
||||
label: Browser Console Logs
|
||||
description: |
|
||||
After enabling DEBUG logs:
|
||||
1. Open Developer Tools → Console.
|
||||
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
|
||||
Mac: `Cmd+Option+J`
|
||||
- Firefox (Win/Linux): `Ctrl+Shift+K`
|
||||
Mac: `Cmd+Option+K`
|
||||
- Safari (Mac): enable **Develop** menu in Preferences → Advanced, then `Cmd+Option+C`
|
||||
2. Clear console (before reproducing):
|
||||
- Chrome/Edge: “🚫 Clear console” or `Ctrl+L` (Win/Linux) / `Cmd+K` (Mac).
|
||||
- Firefox: `Ctrl+Shift+L` (newer) or `Ctrl+L` (older) (Win/Linux) / `Cmd+K` (Mac).
|
||||
- Safari: 🗑 icon or `Cmd+K`.
|
||||
3. Reproduce the issue.
|
||||
4. Copy-paste the **TEXT** logs here (no screenshots).
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually.
|
||||
Simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files.
|
||||
24
.github/ISSUE_TEMPLATE/docs_request.yml
vendored
Normal file
24
.github/ISSUE_TEMPLATE/docs_request.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: 📝 Documentation Request
|
||||
description: Suggest improvements or additions to documentation
|
||||
title: "[Docs] "
|
||||
labels: [documentation]
|
||||
body:
|
||||
- type: input
|
||||
id: doc_area
|
||||
attributes:
|
||||
label: Area of documentation
|
||||
placeholder: e.g. Getting started, Node API, Deployment guide
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: current_issue
|
||||
attributes:
|
||||
label: What's wrong or missing?
|
||||
placeholder: Describe the gap or confusing part
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: suggested_content
|
||||
attributes:
|
||||
label: How should it be improved?
|
||||
placeholder: Provide concrete suggestions or examples
|
||||
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.
|
||||
143
.github/workflows/ComfyUIdownloads.yml
vendored
Normal file
143
.github/workflows/ComfyUIdownloads.yml
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
name: LayerForge Top Downloads Badge
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0,8,16 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: gh login
|
||||
run: echo "${{ secrets.SECRET_TOKEN }}" | gh auth login --with-token
|
||||
|
||||
- name: Query LayerForge API 20 times and find top download
|
||||
run: |
|
||||
max_downloads=0
|
||||
top_node_json="{}"
|
||||
|
||||
for i in {1..20}; do
|
||||
echo "Pobieranie danych z próby $i..."
|
||||
curl -s https://api.comfy.org/nodes/layerforge > tmp_$i.json
|
||||
|
||||
if [ ! -s tmp_$i.json ] || ! jq empty tmp_$i.json 2>/dev/null; then
|
||||
echo "Błąd: Nieprawidłowy JSON dla próby $i"
|
||||
continue
|
||||
fi
|
||||
|
||||
if jq -e 'type == "array"' tmp_$i.json >/dev/null; then
|
||||
# Przeszukanie wszystkich węzłów w tablicy
|
||||
node_count=$(jq 'length' tmp_$i.json)
|
||||
echo "Znaleziono $node_count węzłów w próbie $i"
|
||||
|
||||
for j in $(seq 0 $((node_count - 1))); do
|
||||
downloads=$(jq -r ".[$j].downloads // 0" tmp_$i.json)
|
||||
name=$(jq -r ".[$j].name // \"\"" tmp_$i.json)
|
||||
|
||||
if [ "$downloads" -gt "$max_downloads" ]; then
|
||||
max_downloads=$downloads
|
||||
top_node_json=$(jq ".[$j]" tmp_$i.json)
|
||||
echo "Nowe maksimum znalezione: $downloads (węzeł: $name)"
|
||||
fi
|
||||
done
|
||||
else
|
||||
downloads=$(jq -r '.downloads // 0' tmp_$i.json)
|
||||
name=$(jq -r '.name // ""' tmp_$i.json)
|
||||
|
||||
if [ "$downloads" -gt "$max_downloads" ]; then
|
||||
max_downloads=$downloads
|
||||
top_node_json=$(cat tmp_$i.json)
|
||||
echo "Nowe maksimum znalezione: $downloads (węzeł: $name)"
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f tmp_$i.json
|
||||
done
|
||||
|
||||
if [ "$max_downloads" -gt 0 ]; then
|
||||
echo "$top_node_json" > top_layerforge.json
|
||||
echo "Najwyższa liczba pobrań: $max_downloads"
|
||||
echo "Szczegóły węzła:"
|
||||
jq . top_layerforge.json
|
||||
else
|
||||
echo "Błąd: Nie znaleziono żadnych prawidłowych danych"
|
||||
# Utworzenie domyślnego JSON-a
|
||||
echo '{"name": "No data", "downloads": 0}' > top_layerforge.json
|
||||
fi
|
||||
|
||||
- name: create or update gist with top download
|
||||
id: set_id
|
||||
run: |
|
||||
if gh secret list | grep -q "LAYERFORGE_GIST_ID"
|
||||
then
|
||||
echo "GIST_ID found"
|
||||
echo "GIST=${{ secrets.LAYERFORGE_GIST_ID }}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Sprawdzenie czy gist istnieje
|
||||
if gh gist view ${{ secrets.LAYERFORGE_GIST_ID }} &>/dev/null; then
|
||||
echo "Gist istnieje, będzie zaktualizowany"
|
||||
else
|
||||
echo "Gist nie istnieje, tworzenie nowego"
|
||||
gist_id=$(gh gist create top_layerforge.json | awk -F / '{print $NF}')
|
||||
echo $gist_id | gh secret set LAYERFORGE_GIST_ID
|
||||
echo "GIST=$gist_id" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
else
|
||||
echo "Tworzenie nowego gist"
|
||||
gist_id=$(gh gist create top_layerforge.json | awk -F / '{print $NF}')
|
||||
echo $gist_id | gh secret set LAYERFORGE_GIST_ID
|
||||
echo "GIST=$gist_id" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: create badge if needed
|
||||
run: |
|
||||
COUNT=$(jq '.downloads' top_layerforge.json)
|
||||
NAME=$(jq -r '.name' top_layerforge.json)
|
||||
if [ ! -f LAYERFORGE.md ]; then
|
||||
shields="https://img.shields.io/badge/dynamic/json?color=informational&label=TopLayerForge&query=downloads&url="
|
||||
url="https://gist.githubusercontent.com/${{ github.actor }}/${{ steps.set_id.outputs.GIST }}/raw/top_layerforge.json"
|
||||
repo="https://comfy.org"
|
||||
echo ''> LAYERFORGE.md
|
||||
echo '
|
||||
**Markdown**
|
||||
|
||||
```markdown' >> LAYERFORGE.md
|
||||
echo "[]($repo)" >> LAYERFORGE.md
|
||||
echo '
|
||||
```
|
||||
|
||||
**HTML**
|
||||
```html' >> LAYERFORGE.md
|
||||
echo "<a href='$repo'><img alt='Top LayerForge Node' src='$shields$url'></a>" >> LAYERFORGE.md
|
||||
echo '```' >> LAYERFORGE.md
|
||||
|
||||
git add LAYERFORGE.md
|
||||
git config --global user.name "GitHub Action"
|
||||
git config --global user.email "action@github.com"
|
||||
git commit -m "Create LayerForge badge"
|
||||
fi
|
||||
|
||||
- name: Update Gist
|
||||
run: |
|
||||
# Upewnienie się, że JSON jest poprawny
|
||||
if jq empty top_layerforge.json 2>/dev/null; then
|
||||
content=$(jq -c . top_layerforge.json)
|
||||
echo "{\"description\": \"Top LayerForge Node\", \"files\": {\"top_layerforge.json\": {\"content\": $(jq -Rs . <<< "$content")}}}" > patch.json
|
||||
|
||||
curl -s -X PATCH \
|
||||
--user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @patch.json https://api.github.com/gists/${{ steps.set_id.outputs.GIST }}
|
||||
else
|
||||
echo "Błąd: Nieprawidłowy JSON w top_layerforge.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Push
|
||||
uses: ad-m/github-push-action@master
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
90
.github/workflows/clone.yml
vendored
Normal file
90
.github/workflows/clone.yml
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
name: GitHub Clone Count Update Everyday
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 */24 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: gh login
|
||||
run: echo "${{ secrets.SECRET_TOKEN }}" | gh auth login --with-token
|
||||
|
||||
- name: parse latest clone count
|
||||
run: |
|
||||
curl --user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github.v3+json" \
|
||||
https://api.github.com/repos/${{ github.repository }}/traffic/clones \
|
||||
> clone.json
|
||||
|
||||
- name: create gist and download previous count
|
||||
id: set_id
|
||||
run: |
|
||||
if gh secret list | grep -q "GIST_ID"
|
||||
then
|
||||
echo "GIST_ID found"
|
||||
echo "GIST=${{ secrets.GIST_ID }}" >> $GITHUB_OUTPUT
|
||||
curl https://gist.githubusercontent.com/${{ github.actor }}/${{ secrets.GIST_ID }}/raw/clone.json > clone_before.json
|
||||
if cat clone_before.json | grep '404: Not Found'; then
|
||||
echo "GIST_ID not valid anymore. Creating another gist..."
|
||||
gist_id=$(gh gist create clone.json | awk -F / '{print $NF}')
|
||||
echo $gist_id | gh secret set GIST_ID
|
||||
echo "GIST=$gist_id" >> $GITHUB_OUTPUT
|
||||
cp clone.json clone_before.json
|
||||
git rm --ignore-unmatch CLONE.md
|
||||
fi
|
||||
else
|
||||
echo "GIST_ID not found. Creating a gist..."
|
||||
gist_id=$(gh gist create clone.json | awk -F / '{print $NF}')
|
||||
echo $gist_id | gh secret set GIST_ID
|
||||
echo "GIST=$gist_id" >> $GITHUB_OUTPUT
|
||||
cp clone.json clone_before.json
|
||||
fi
|
||||
|
||||
- name: update clone.json
|
||||
run: |
|
||||
curl https://raw.githubusercontent.com/MShawon/github-clone-count-badge/master/main.py > main.py
|
||||
python3 main.py
|
||||
|
||||
- name: Update gist with latest count
|
||||
run: |
|
||||
content=$(sed -e 's/\\/\\\\/g' -e 's/\t/\\t/g' -e 's/\"/\\"/g' -e 's/\r//g' "clone.json" | sed -E ':a;N;$!ba;s/\r{0,1}\n/\\n/g')
|
||||
echo '{"description": "${{ github.repository }} clone statistics", "files": {"clone.json": {"content": "'"$content"'"}}}' > post_clone.json
|
||||
curl -s -X PATCH \
|
||||
--user "${{ github.actor }}:${{ secrets.SECRET_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @post_clone.json https://api.github.com/gists/${{ steps.set_id.outputs.GIST }} > /dev/null 2>&1
|
||||
|
||||
if [ ! -f CLONE.md ]; then
|
||||
shields="https://img.shields.io/badge/dynamic/json?color=success&label=Clone&query=count&url="
|
||||
url="https://gist.githubusercontent.com/${{ github.actor }}/${{ steps.set_id.outputs.GIST }}/raw/clone.json"
|
||||
repo="https://github.com/MShawon/github-clone-count-badge"
|
||||
echo ''> CLONE.md
|
||||
echo '
|
||||
**Markdown**
|
||||
|
||||
```markdown' >> CLONE.md
|
||||
echo "[]($repo)" >> CLONE.md
|
||||
echo '
|
||||
```
|
||||
|
||||
**HTML**
|
||||
```html' >> CLONE.md
|
||||
echo "<a href='$repo'><img alt='GitHub Clones' src='$shields$url&logo=github'></a>" >> CLONE.md
|
||||
echo '```' >> CLONE.md
|
||||
|
||||
git add CLONE.md
|
||||
git config --global user.name "GitHub Action"
|
||||
git config --global user.email "action@github.com"
|
||||
git commit -m "create clone count badge"
|
||||
fi
|
||||
|
||||
- name: Push
|
||||
uses: ad-m/github-push-action@master
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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 }}
|
||||
55
.github/workflows/release.yml
vendored
55
.github/workflows/release.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Auto Release with Version Patch
|
||||
name: Auto Release with Version Check
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,6 +12,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Pobierz pełną historię Git (potrzebne do git log)
|
||||
|
||||
- name: Extract base version from pyproject.toml
|
||||
id: version
|
||||
@@ -19,30 +21,51 @@ jobs:
|
||||
base=$(grep '^version *= *"' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
|
||||
echo "base_version=$base" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Determine unique version tag
|
||||
- name: Check if tag for this version already exists
|
||||
run: |
|
||||
TAG="v${{ steps.version.outputs.base_version }}"
|
||||
git fetch --tags
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag $TAG already exists. Skipping release."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Set version tag
|
||||
id: unique_tag
|
||||
run: |
|
||||
BASE="v${{ steps.version.outputs.base_version }}"
|
||||
TAG=$BASE
|
||||
COUNT=0
|
||||
echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Fetch remote tags
|
||||
# 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: |
|
||||
VERSION_TAG="v${{ steps.version.outputs.base_version }}"
|
||||
git fetch --tags
|
||||
|
||||
while git rev-parse "$TAG" >/dev/null 2>&1; do
|
||||
COUNT=$((COUNT + 1))
|
||||
TAG="$BASE.$COUNT"
|
||||
done
|
||||
if git rev-parse "$VERSION_TAG" >/dev/null 2>&1; then
|
||||
RANGE="$VERSION_TAG..HEAD"
|
||||
else
|
||||
RANGE="HEAD"
|
||||
fi
|
||||
|
||||
echo "final_tag=$TAG" >> $GITHUB_OUTPUT
|
||||
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 significant changes since last release."
|
||||
fi
|
||||
|
||||
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 }}`
|
||||
🔁 Auto-postfix to avoid duplicate tag: `${{ steps.unique_tag.outputs.final_tag }}`
|
||||
name: Release ${{ steps.unique_tag.outputs.final_tag }}
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
13
CLONE.md
Normal file
13
CLONE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
|
||||
**Markdown**
|
||||
|
||||
```markdown
|
||||
[](https://github.com/MShawon/github-clone-count-badge)
|
||||
|
||||
```
|
||||
|
||||
**HTML**
|
||||
```html
|
||||
<a href='https://github.com/MShawon/github-clone-count-badge'><img alt='GitHub Clones' src='https://img.shields.io/badge/dynamic/json?color=success&label=Clone&query=count&url=https://gist.githubusercontent.com/Azornes/5fa586b9e6938f48638fad37a1d146ae/raw/clone.json&logo=github'></a>
|
||||
```
|
||||
96
Doc/ComfyApi
Normal file
96
Doc/ComfyApi
Normal file
@@ -0,0 +1,96 @@
|
||||
# ComfyApi - Function Documentation Summary import { api } from "../../scripts/api.js";
|
||||
|
||||
## Basic Information
|
||||
|
||||
ComfyApi is a class for communication with ComfyUI backend via WebSocket and REST API.
|
||||
|
||||
## Main Functions:
|
||||
|
||||
### Connection and Initialization
|
||||
|
||||
- constructor() - Initializes API, sets host and base path
|
||||
- init() - Starts WebSocket connection for real-time updates
|
||||
- #createSocket() - Creates and manages WebSocket connection
|
||||
|
||||
### URL Management
|
||||
|
||||
- internalURL(route) - Generates URL for internal endpoints
|
||||
- apiURL(route) - Generates URL for public API endpoints
|
||||
- fileURL(route) - Generates URL for static files
|
||||
- fetchApi(route, options) - Performs HTTP requests with automatic user headers
|
||||
|
||||
### Event Handling
|
||||
|
||||
- addEventListener(type, callback) - Listens for API events (status, executing, progress, etc.)
|
||||
- removeEventListener(type, callback) - Removes event listeners
|
||||
- dispatchCustomEvent(type, detail) - Emits custom events
|
||||
|
||||
### Queue and Prompt Management
|
||||
|
||||
- queuePrompt(number, data) - Adds prompt to execution queue
|
||||
- getQueue() - Gets current queue state (Running/Pending)
|
||||
- interrupt() - Interrupts currently executing prompt
|
||||
- clearItems(type) - Clears queue or history
|
||||
- deleteItem(type, id) - Removes item from queue or history
|
||||
|
||||
### History and Statistics
|
||||
|
||||
- getHistory(max_items) - Gets history of executed prompts
|
||||
- getSystemStats() - Gets system statistics (Python, OS, GPU, etc.)
|
||||
- getLogs() - Gets system logs
|
||||
- getRawLogs() - Gets raw logs
|
||||
- subscribeLogs(enabled) - Enables/disables log subscription
|
||||
|
||||
### Model and Resource Management
|
||||
|
||||
- getNodeDefs(options) - Gets definitions of available nodes
|
||||
- getExtensions() - List of installed extensions
|
||||
- getEmbeddings() - List of available embeddings
|
||||
- getModelFolders() - List of model folders
|
||||
- getModels(folder) - List of models in given folder
|
||||
- viewMetadata(folder, model) - Metadata of specific model
|
||||
|
||||
### Workflow Templates
|
||||
|
||||
- getWorkflowTemplates() - Gets workflow templates from custom nodes
|
||||
- getCoreWorkflowTemplates() - Gets core workflow templates
|
||||
|
||||
### User Management
|
||||
|
||||
- getUserConfig() - Gets user configuration
|
||||
- createUser(username) - Creates new user
|
||||
- getSettings() - Gets all user settings
|
||||
- getSetting(id) - Gets specific setting
|
||||
- storeSettings(settings) - Saves settings dictionary
|
||||
- storeSetting(id, value) - Saves single setting
|
||||
|
||||
### User Data
|
||||
|
||||
- getUserData(file) - Gets user data file
|
||||
- storeUserData(file, data, options) - Saves user data
|
||||
- deleteUserData(file) - Deletes user data file
|
||||
- moveUserData(source, dest) - Moves data file
|
||||
- listUserDataFullInfo(dir) - Lists files with full information
|
||||
|
||||
### Other
|
||||
|
||||
- getFolderPaths() - Gets system folder paths
|
||||
- getCustomNodesI18n() - Gets internationalization data for custom nodes
|
||||
|
||||
## Important Properties
|
||||
|
||||
- clientId - Client ID from WebSocket
|
||||
- authToken - Authorization token for ComfyOrg account
|
||||
- apiKey - API key for ComfyOrg account
|
||||
- socket - Active WebSocket connection
|
||||
|
||||
## WebSocket Event Types
|
||||
|
||||
- status - System status
|
||||
- executing - Currently executing node
|
||||
- progress - Execution progress
|
||||
- executed - Node executed
|
||||
- execution_start/success/error/interrupted/cached - Execution events
|
||||
- logs - System logs
|
||||
- b_preview - Image preview (binary)
|
||||
- reconnecting/reconnected - Connection events
|
||||
72
Doc/ComfyApp
Normal file
72
Doc/ComfyApp
Normal file
@@ -0,0 +1,72 @@
|
||||
## __Main ComfyApp Functions__ import { app, ComfyApp } from "../../scripts/app.js";
|
||||
|
||||
### __Application Management__
|
||||
|
||||
- `setup(canvasEl)` - Initializes the application on the page, loads extensions, registers nodes
|
||||
- `resizeCanvas()` - Adjusts canvas size to window
|
||||
- `clean()` - Clears application state (node outputs, image previews, errors)
|
||||
|
||||
### __Workflow Management__
|
||||
|
||||
- `loadGraphData(graphData, clean, restore_view, workflow, options)` - Loads workflow data from JSON
|
||||
- `loadApiJson(apiData, fileName)` - Loads workflow from API format
|
||||
- `graphToPrompt(graph, options)` - Converts graph to prompt for execution
|
||||
- `handleFile(file)` - Handles file loading (PNG, WebP, JSON, MP3, MP4, SVG, etc.)
|
||||
|
||||
### __Execution__
|
||||
|
||||
- `queuePrompt(number, batchCount, queueNodeIds)` - Queues prompt for execution
|
||||
- `registerNodes()` - Registers node definitions from backend
|
||||
- `registerNodeDef(nodeId, nodeDef)` - Registers single node definition
|
||||
- `refreshComboInNodes()` - Refreshes combo lists in nodes
|
||||
|
||||
### __Node Management__
|
||||
|
||||
- `registerExtension(extension)` - Registers ComfyUI extension
|
||||
- `updateVueAppNodeDefs(defs)` - Updates node definitions in Vue app
|
||||
- `revokePreviews(nodeId)` - Frees memory for node previews
|
||||
|
||||
### __Clipboard__
|
||||
|
||||
- `copyToClipspace(node)` - Copies node to clipboard
|
||||
- `pasteFromClipspace(node)` - Pastes data from clipboard to node
|
||||
|
||||
### __Position Conversion__
|
||||
|
||||
- `clientPosToCanvasPos(pos)` - Converts client position to canvas position
|
||||
- `canvasPosToClientPos(pos)` - Converts canvas position to client position
|
||||
|
||||
### __Error Handling__
|
||||
|
||||
- `showErrorOnFileLoad(file)` - Displays file loading error
|
||||
- `#showMissingNodesError(missingNodeTypes)` - Shows missing nodes error
|
||||
- `#showMissingModelsError(missingModels, paths)` - Shows missing models error
|
||||
|
||||
### __Internal Handlers__
|
||||
|
||||
- `#addDropHandler()` - Handles drag and drop of files
|
||||
- `#addProcessKeyHandler()` - Handles keyboard input
|
||||
- `#addDrawNodeHandler()` - Modifies node drawing behavior
|
||||
- `#addApiUpdateHandlers()` - Handles API updates
|
||||
- `#addConfigureHandler()` - Graph configuration flag
|
||||
- `#addAfterConfigureHandler()` - Post-configuration handling
|
||||
|
||||
### __Deprecated Properties__
|
||||
|
||||
Many properties are marked as deprecated and redirect to appropriate stores:
|
||||
|
||||
- `lastNodeErrors` → `useExecutionStore().lastNodeErrors`
|
||||
- `lastExecutionError` → `useExecutionStore().lastExecutionError`
|
||||
- `runningNodeId` → `useExecutionStore().executingNodeId`
|
||||
- `shiftDown` → `useWorkspaceStore().shiftDown`
|
||||
- `widgets` → `useWidgetStore().widgets`
|
||||
- `extensions` → `useExtensionStore().extensions`
|
||||
|
||||
### __Utility Functions__
|
||||
|
||||
- `sanitizeNodeName(string)` - Cleans node name from dangerous characters
|
||||
- `getPreviewFormatParam()` - Returns preview format parameter
|
||||
- `getRandParam()` - Returns random parameter for refresh
|
||||
- `isApiJson(data)` - Checks if data is in API JSON format
|
||||
|
||||
This application uses Vue and TypeScript composition pattern, where many functionalities are separated into different services and stores (e.g., `useExecutionStore`, `useWorkflowService`, `useExtensionService`, etc.).
|
||||
75
Doc/LitegraphService
Normal file
75
Doc/LitegraphService
Normal file
@@ -0,0 +1,75 @@
|
||||
LitegraphService Documentation
|
||||
|
||||
Main functions of useLitegraphService()
|
||||
|
||||
Node Registration and Creation Functions:
|
||||
|
||||
registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1)
|
||||
|
||||
- Registers node definition in LiteGraph system
|
||||
- Creates ComfyNode class with inputs, outputs and widgets
|
||||
- Adds context menu, background drawing and keyboard handling
|
||||
- Invokes extensions before registration
|
||||
|
||||
addNodeOnGraph(nodeDef, options)
|
||||
|
||||
- Adds new node to graph at specified position
|
||||
- By default places node at canvas center
|
||||
|
||||
Navigation and View Functions:
|
||||
|
||||
getCanvasCenter(): Vector2
|
||||
|
||||
- Returns canvas center coordinates accounting for DPI
|
||||
|
||||
goToNode(nodeId: NodeId)
|
||||
|
||||
- Animates transition to specified node on canvas
|
||||
|
||||
resetView()
|
||||
|
||||
- Resets canvas view to default settings (scale 1, offset [0,0])
|
||||
|
||||
fitView()
|
||||
|
||||
- Fits canvas view to show all nodes
|
||||
|
||||
Node Handling Functions (internal):
|
||||
|
||||
addNodeContextMenuHandler(node)
|
||||
|
||||
- Adds context menu with options:
|
||||
|
||||
- Open/Copy/Save image (for image nodes)
|
||||
- Bypass node
|
||||
- Copy/Paste to Clipspace
|
||||
- Open in MaskEditor (for image nodes)
|
||||
|
||||
addDrawBackgroundHandler(node)
|
||||
|
||||
- Adds node background drawing logic
|
||||
- Handles image, animation and video previews
|
||||
- Manages thumbnail display
|
||||
|
||||
addNodeKeyHandler(node)
|
||||
|
||||
- Adds keyboard handling:
|
||||
|
||||
- Left/Right arrows: navigate between images
|
||||
- Escape: close image preview
|
||||
|
||||
ComfyNode Class (created by registerNodeDef):
|
||||
|
||||
Main methods:
|
||||
|
||||
- #addInputs() - adds inputs and widgets to node
|
||||
- #addOutputs() - adds outputs to node
|
||||
- configure() - configures node from serialized data
|
||||
- #setupStrokeStyles() - sets border styles (errors, execution, etc.)
|
||||
|
||||
Properties:
|
||||
|
||||
- comfyClass - ComfyUI class name
|
||||
- nodeData - node definition
|
||||
- Automatic yellow coloring for API nodes
|
||||
|
||||
76
Doc/MaskEditor
Normal file
76
Doc/MaskEditor
Normal file
@@ -0,0 +1,76 @@
|
||||
MASKEDITOR.TS FUNCTION DOCUMENTATION
|
||||
|
||||
MaskEditorDialog - Main mask editor class
|
||||
|
||||
- getInstance() - Singleton pattern, returns editor instance
|
||||
- show() - Opens the mask editor
|
||||
- save() - Saves mask to server
|
||||
- destroy() - Closes and cleans up editor
|
||||
- isOpened() - Checks if editor is open
|
||||
|
||||
CanvasHistory - Change history management
|
||||
|
||||
- saveState() - Saves current canvas state
|
||||
- undo() - Undo last operation
|
||||
- redo() - Redo undone operation
|
||||
- clearStates() - Clears history
|
||||
|
||||
BrushTool - Brush tool
|
||||
|
||||
- setBrushSize(size) - Sets brush size
|
||||
- setBrushOpacity(opacity) - Sets brush opacity
|
||||
- setBrushHardness(hardness) - Sets brush hardness
|
||||
- setBrushType(type) - Sets brush shape (circle/square)
|
||||
- startDrawing() - Starts drawing
|
||||
- handleDrawing() - Handles drawing during movement
|
||||
- drawEnd() - Ends drawing
|
||||
|
||||
PaintBucketTool - Fill tool
|
||||
|
||||
- floodFill(point) - Fills area with color from point
|
||||
- setTolerance(tolerance) - Sets color tolerance
|
||||
- setFillOpacity(opacity) - Sets fill opacity
|
||||
- invertMask() - Inverts mask
|
||||
|
||||
ColorSelectTool - Color selection tool
|
||||
|
||||
- fillColorSelection(point) - Selects similar colors
|
||||
- setTolerance(tolerance) - Sets selection tolerance
|
||||
- setLivePreview(enabled) - Enables/disables live preview
|
||||
- setComparisonMethod(method) - Sets color comparison method
|
||||
- setApplyWholeImage(enabled) - Applies to whole image
|
||||
- setSelectOpacity(opacity) - Sets selection opacity
|
||||
|
||||
UIManager - Interface management
|
||||
|
||||
- updateBrushPreview() - Updates brush preview
|
||||
- setBrushVisibility(visible) - Shows/hides brush
|
||||
- screenToCanvas(coords) - Converts screen coordinates to canvas
|
||||
- getMaskColor() - Returns mask color
|
||||
- setSaveButtonEnabled(enabled) - Enables/disables save button
|
||||
|
||||
ToolManager - Tool management
|
||||
|
||||
- setTool(tool) - Sets active tool
|
||||
- getCurrentTool() - Returns active tool
|
||||
- handlePointerDown/Move/Up() - Handles mouse/touch events
|
||||
|
||||
PanAndZoomManager - View management
|
||||
|
||||
- zoom(event) - Zooms in/out canvas
|
||||
- handlePanStart/Move() - Handles canvas panning
|
||||
- initializeCanvasPanZoom() - Initializes canvas view
|
||||
- smoothResetView() - Smoothly resets view
|
||||
|
||||
MessageBroker - Communication system
|
||||
|
||||
- publish(topic, data) - Publishes message
|
||||
- subscribe(topic, callback) - Subscribes to topic
|
||||
- pull(topic, data) - Pulls data from topic
|
||||
- createPullTopic/PushTopic() - Creates communication topics
|
||||
|
||||
KeyboardManager - Keyboard handling
|
||||
|
||||
- addListeners() - Adds keyboard listeners
|
||||
- removeListeners() - Removes listeners
|
||||
- isKeyDown(key) - Checks if key is pressed
|
||||
13
LAYERFORGE.md
Normal file
13
LAYERFORGE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
|
||||
**Markdown**
|
||||
|
||||
```markdown
|
||||
[](https://comfy.org)
|
||||
|
||||
```
|
||||
|
||||
**HTML**
|
||||
```html
|
||||
<a href='https://comfy.org'><img alt='Top LayerForge Node' src='https://img.shields.io/badge/dynamic/json?color=informational&label=TopLayerForge&query=downloads&url=https://gist.githubusercontent.com/Azornes/912463d4edd123956066a7aaaa3ef835/raw/top_layerforge.json'></a>
|
||||
```
|
||||
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
|
||||
|
||||
108
README.md
108
README.md
@@ -1,26 +1,24 @@
|
||||
<h1 align="center">LayerForge – Advanced Canvas Editor for ComfyUI 🎨</h1>
|
||||
|
||||
|
||||
<p align="center"><i>LayerForge is an advanced canvas node for ComfyUI, providing a Photoshop-like layer-based editing experience directly within your workflow. It extends the concept of a simple canvas with multi-layer support, masking, blend modes, precise transformations, and seamless integration with other nodes.</i></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://registry.comfy.org/publishers/azornes/nodes/layerforge">
|
||||
<img alt="Downloads" src="https://img.shields.io/badge/dynamic/json?color=2F80ED&label=Downloads&query=$.downloads&url=https://api.comfy.org/nodes/layerforge&style=for-the-badge">
|
||||
</a>
|
||||
|
||||
<a href="https://registry.comfy.org/publishers/azornes/nodes/layerforge" style="display:inline-flex; align-items:center; gap:6px;">
|
||||
<img alt="ComfyUI" src="https://img.shields.io/badge/ComfyUI-1a1a1a?style=for-the-badge&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAASFBMVEVHcEwYLtsYLtkXLtkXLdkYLtkWLdcFIdoAD95uerfI1XLR3mq3xIP8/yj0/zvw/0FSYMP5/zKMmKQtPNOuuozj8FOhrZW7x4FMWFFbAAAABnRSTlMAUrPX87KxijklAAAA00lEQVR4AX3SBw6DMAxA0UzbrIzO+9+02GkEpoWP9hPZZs06Hw75aI3k4W/+wkQtnGZNhF1I34BzalQcxkmasY0b9raklNcvLYU1GNiiOeVWauOa/XS526gRyzpV/7HeUOG9Jp6vcsvUrCPeKg/3KBKBQhoTD1dQggPWzPVfFOIgo85/kR4y6oB/8SlIEh7wvmTuKd3wgLVW1sTfRBoR7oWVqy/U2NcrWDYMINE7NUuJuoV+2fhaWmnbjzcOWnRv7XbiLh/Y9dNUqk2y0QcNwTu7wgf+/BhsPUhf4QAAAABJRU5ErkJggg==" />
|
||||
<img alt="Downloads" src="https://img.shields.io/badge/dynamic/json?color=%230D2A4A&label=&query=downloads&url=https://gist.githubusercontent.com/Azornes/912463d4edd123956066a7aaaa3ef835/raw/top_layerforge.json&style=for-the-badge" />
|
||||
</a>
|
||||
<a href='https://github.com/Azornes/Comfyui-LayerForge'>
|
||||
<img alt='GitHub Clones' src='https://img.shields.io/badge/dynamic/json?color=2F80ED&label=Clone&query=count&url=https://gist.githubusercontent.com/Azornes/5fa586b9e6938f48638fad37a1d146ae/raw/clone.json&logo=github&style=for-the-badge'>
|
||||
</a>
|
||||
<a href="https://visitorbadge.io/status?path=https%3A%2F%2Fgithub.com%2FAzornes%2FComfyui-LayerForge">
|
||||
<img src="https://api.visitorbadge.io/api/combined?path=https%3A%2F%2Fgithub.com%2FAzornes%2FComfyui-LayerForge&countColor=%2337d67a&style=for-the-badge&labelStyle=none" />
|
||||
</a>
|
||||
<img alt="Python 3.10+" src="https://img.shields.io/badge/-Python_3.10+-4B8BBE?logo=python&logoColor=FFFFFF&style=for-the-badge&logoWidth=20">
|
||||
<img alt="JavaScript" src="https://img.shields.io/badge/-JavaScript-000000?logo=javascript&logoColor=F7DF1E&style=for-the-badge&logoWidth=20">
|
||||
<a href="https://docs.comfy.org/" target="_blank" rel="noopener noreferrer">
|
||||
<img alt="ComfyUI" src="https://img.shields.io/badge/ComfyUI-1a1a1a?style=for-the-badge&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAASFBMVEVHcEwYLtsYLtkXLtkXLdkYLtkWLdcFIdoAD95uerfI1XLR3mq3xIP8/yj0/zvw/0FSYMP5/zKMmKQtPNOuuozj8FOhrZW7x4FMWFFbAAAABnRSTlMAUrPX87KxijklAAAA00lEQVR4AX3SBw6DMAxA0UzbrIzO+9+02GkEpoWP9hPZZs06Hw75aI3k4W/+wkQtnGZNhF1I34BzalQcxkmasY0b9raklNcvLYU1GNiiOeVWauOa/XS526gRyzpV/7HeUOG9Jp6vcsvUrCPeKg/3KBKBQhoTD1dQggPWzPVfFOIgo85/kR4y6oB/8SlIEh7wvmTuKd3wgLVW1sTfRBoR7oWVqy/U2NcrWDYMINE7NUuJuoV+2fhaWmnbjzcOWnRv7XbiLh/Y9dNUqk2y0QcNwTu7wgf+/BhsPUhf4QAAAABJRU5ErkJggg==" />
|
||||
</a>
|
||||
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
|
||||
### Why LayerForge?
|
||||
|
||||
- **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without
|
||||
@@ -30,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.
|
||||
@@ -70,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
|
||||
@@ -93,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
|
||||
@@ -110,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° |
|
||||
@@ -140,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
|
||||
@@ -149,12 +218,13 @@ optional feature and requires a model.
|
||||
> - **Download from**:
|
||||
>
|
||||
- [Hugging Face](https://huggingface.co/ZhengPeng7/BiRefNet/tree/main) (Recommended)
|
||||
> - [Google Drive](https://drive.google.com/drive/folders/1BCLInCLH89fmTpYoP8Sgs_Eqww28f_wq?usp=sharing)
|
||||
- [Google Drive](https://drive.google.com/drive/folders/1BCLInCLH89fmTpYoP8Sgs_Eqww28f_wq?usp=sharing)
|
||||
> - **Installation Path**: Place the model file in `ComfyUI/models/BiRefNet/`.
|
||||
|
||||
---
|
||||
|
||||
## 🐞 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.
|
||||
@@ -178,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.
|
||||
|
||||
@@ -4,16 +4,16 @@ import os
|
||||
# Add the custom node's directory to the Python path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from .canvas_node import CanvasNode
|
||||
from .canvas_node import LayerForgeNode
|
||||
|
||||
CanvasNode.setup_routes()
|
||||
LayerForgeNode.setup_routes()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"CanvasNode": CanvasNode
|
||||
"LayerForgeNode": LayerForgeNode
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"CanvasNode": "Layer Forge (Editor, outpaintintg, Canvas Node)"
|
||||
"LayerForgeNode": "Layer Forge (Editor, outpaintintg, Canvas Node)"
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
214
canvas_node.py
214
canvas_node.py
@@ -10,7 +10,12 @@ import threading
|
||||
import os
|
||||
from tqdm import tqdm
|
||||
from torchvision import transforms
|
||||
from transformers import AutoModelForImageSegmentation, PretrainedConfig
|
||||
try:
|
||||
from transformers import AutoModelForImageSegmentation, PretrainedConfig
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
TRANSFORMERS_AVAILABLE = True
|
||||
except ImportError:
|
||||
TRANSFORMERS_AVAILABLE = False
|
||||
import torch.nn.functional as F
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -23,8 +28,9 @@ import os
|
||||
|
||||
try:
|
||||
from python.logger import logger, LogLevel, debug, info, warn, error, exception
|
||||
from python.config import LOG_LEVEL
|
||||
|
||||
logger.set_module_level('canvas_node', LogLevel.NONE)
|
||||
logger.set_module_level('canvas_node', LogLevel[LOG_LEVEL])
|
||||
|
||||
logger.configure({
|
||||
'log_to_file': True,
|
||||
@@ -84,7 +90,7 @@ class BiRefNet(torch.nn.Module):
|
||||
return [output]
|
||||
|
||||
|
||||
class CanvasNode:
|
||||
class LayerForgeNode:
|
||||
_canvas_data_storage = {}
|
||||
_storage_lock = threading.Lock()
|
||||
|
||||
@@ -168,8 +174,10 @@ class CanvasNode:
|
||||
return {
|
||||
"required": {
|
||||
"fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}),
|
||||
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}),
|
||||
"node_id": ("STRING", {"default": "0", "hidden": True}),
|
||||
"show_preview": ("BOOLEAN", {"default": False, "label_on": "Show Preview", "label_off": "Hide Preview"}),
|
||||
"auto_refresh_after_generation": ("BOOLEAN", {"default": False, "label_on": "True", "label_off": "False"}),
|
||||
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
|
||||
"node_id": ("STRING", {"default": "0"}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": ("PROMPT",),
|
||||
@@ -231,7 +239,7 @@ class CanvasNode:
|
||||
|
||||
_processing_lock = threading.Lock()
|
||||
|
||||
def process_canvas_image(self, fit_on_add, trigger, node_id, prompt=None, unique_id=None):
|
||||
def process_canvas_image(self, fit_on_add, show_preview, auto_refresh_after_generation, trigger, node_id, prompt=None, unique_id=None):
|
||||
|
||||
try:
|
||||
|
||||
@@ -327,6 +335,24 @@ class CanvasNode:
|
||||
latest_image_path = max(image_files, key=os.path.getctime)
|
||||
return latest_image_path
|
||||
|
||||
@classmethod
|
||||
def get_latest_images(cls, since_timestamp=0):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
files = []
|
||||
for f_name in os.listdir(output_dir):
|
||||
file_path = os.path.join(output_dir, f_name)
|
||||
if os.path.isfile(file_path) and file_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
|
||||
try:
|
||||
mtime = os.path.getmtime(file_path)
|
||||
if mtime > since_timestamp:
|
||||
files.append((mtime, file_path))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
files.sort(key=lambda x: x[0])
|
||||
|
||||
return [f[1] for f in files]
|
||||
|
||||
@classmethod
|
||||
def get_flow_status(cls, flow_id=None):
|
||||
|
||||
@@ -366,7 +392,7 @@ class CanvasNode:
|
||||
def setup_routes(cls):
|
||||
@PromptServer.instance.routes.get("/layerforge/canvas_ws")
|
||||
async def handle_canvas_websocket(request):
|
||||
ws = web.WebSocketResponse()
|
||||
ws = web.WebSocketResponse(max_msg_size=33554432)
|
||||
await ws.prepare(request)
|
||||
|
||||
async for msg in ws:
|
||||
@@ -448,6 +474,30 @@ class CanvasNode:
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
@PromptServer.instance.routes.get("/layerforge/get-latest-images/{since}")
|
||||
async def get_latest_images_route(request):
|
||||
try:
|
||||
since_timestamp = float(request.match_info.get('since', 0))
|
||||
# JS Timestamps are in milliseconds, Python's are in seconds
|
||||
latest_image_paths = cls.get_latest_images(since_timestamp / 1000.0)
|
||||
|
||||
images_data = []
|
||||
for image_path in latest_image_paths:
|
||||
with open(image_path, "rb") as f:
|
||||
encoded_string = base64.b64encode(f.read()).decode('utf-8')
|
||||
images_data.append(f"data:image/png;base64,{encoded_string}")
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'images': images_data
|
||||
})
|
||||
except Exception as e:
|
||||
log_error(f"Error in get_latest_images_route: {str(e)}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.get("/ycnode/get_latest_image")
|
||||
async def get_latest_image_route(request):
|
||||
try:
|
||||
@@ -470,6 +520,70 @@ class CanvasNode:
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.post("/ycnode/load_image_from_path")
|
||||
async def load_image_from_path_route(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get('file_path')
|
||||
|
||||
if not file_path:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'file_path is required'
|
||||
}, status=400)
|
||||
|
||||
log_info(f"Attempting to load image from path: {file_path}")
|
||||
|
||||
# Check if file exists and is accessible
|
||||
if not os.path.exists(file_path):
|
||||
log_warn(f"File not found: {file_path}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f'File not found: {file_path}'
|
||||
}, status=404)
|
||||
|
||||
# Check if it's an image file
|
||||
valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.avif')
|
||||
if not file_path.lower().endswith(valid_extensions):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f'Invalid image file extension. Supported: {valid_extensions}'
|
||||
}, status=400)
|
||||
|
||||
# Try to load and convert the image
|
||||
try:
|
||||
with Image.open(file_path) as img:
|
||||
# Convert to RGB if necessary
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Convert to base64
|
||||
buffered = io.BytesIO()
|
||||
img.save(buffered, format="PNG")
|
||||
img_str = base64.b64encode(buffered.getvalue()).decode('utf-8')
|
||||
|
||||
log_info(f"Successfully loaded image from path: {file_path}")
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'image_data': f"data:image/png;base64,{img_str}",
|
||||
'width': img.width,
|
||||
'height': img.height
|
||||
})
|
||||
|
||||
except Exception as img_error:
|
||||
log_error(f"Error processing image file {file_path}: {str(img_error)}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f'Error processing image file: {str(img_error)}'
|
||||
}, status=500)
|
||||
|
||||
except Exception as e:
|
||||
log_error(f"Error in load_image_from_path_route: {str(e)}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
def store_image(self, image_data):
|
||||
|
||||
if isinstance(image_data, str) and image_data.startswith('data:image'):
|
||||
@@ -499,44 +613,47 @@ class BiRefNetMatting:
|
||||
"models")
|
||||
|
||||
def load_model(self, model_path):
|
||||
from json.decoder import JSONDecodeError
|
||||
try:
|
||||
if model_path not in self.model_cache:
|
||||
|
||||
full_model_path = os.path.join(self.base_path, "BiRefNet")
|
||||
|
||||
log_info(f"Loading BiRefNet model from {full_model_path}...")
|
||||
|
||||
try:
|
||||
|
||||
self.model = AutoModelForImageSegmentation.from_pretrained(
|
||||
"ZhengPeng7/BiRefNet",
|
||||
trust_remote_code=True,
|
||||
cache_dir=full_model_path
|
||||
)
|
||||
|
||||
self.model.eval()
|
||||
if torch.cuda.is_available():
|
||||
self.model = self.model.cuda()
|
||||
|
||||
self.model_cache[model_path] = self.model
|
||||
log_info("Model loaded successfully from Hugging Face")
|
||||
log_debug(f"Model type: {type(self.model)}")
|
||||
log_debug(f"Model device: {next(self.model.parameters()).device}")
|
||||
|
||||
except JSONDecodeError as e:
|
||||
log_error(f"JSONDecodeError: Failed to load model from {full_model_path}. The model's config.json may be corrupted.")
|
||||
raise RuntimeError(
|
||||
"The matting model's configuration file (config.json) appears to be corrupted. "
|
||||
f"Please manually delete the directory '{full_model_path}' and try again. "
|
||||
"This will force a fresh download of the model."
|
||||
) from e
|
||||
except Exception as e:
|
||||
log_error(f"Failed to load model: {str(e)}")
|
||||
raise
|
||||
|
||||
log_error(f"Failed to load model from Hugging Face: {str(e)}")
|
||||
# Re-raise with a more informative message
|
||||
raise RuntimeError(
|
||||
"Failed to download or load the matting model. "
|
||||
"This could be due to a network issue, file permissions, or a corrupted model cache. "
|
||||
f"Please check your internet connection and the model cache path: {full_model_path}. "
|
||||
f"Original error: {str(e)}"
|
||||
) from e
|
||||
else:
|
||||
self.model = self.model_cache[model_path]
|
||||
log_debug("Using cached model")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Catch the re-raised exception or any other error
|
||||
log_error(f"Error loading model: {str(e)}")
|
||||
log_exception("Model loading failed")
|
||||
return False
|
||||
raise # Re-raise the exception to be caught by the execute method
|
||||
|
||||
def preprocess_image(self, image):
|
||||
|
||||
@@ -566,11 +683,9 @@ class BiRefNetMatting:
|
||||
|
||||
def execute(self, image, model_path, threshold=0.5, refinement=1):
|
||||
try:
|
||||
|
||||
PromptServer.instance.send_sync("matting_status", {"status": "processing"})
|
||||
|
||||
if not self.load_model(model_path):
|
||||
raise RuntimeError("Failed to load model")
|
||||
self.load_model(model_path)
|
||||
|
||||
if isinstance(image, torch.Tensor):
|
||||
original_size = image.shape[-2:] if image.dim() == 4 else image.shape[-2:]
|
||||
@@ -647,25 +762,31 @@ _matting_lock = None
|
||||
async def matting(request):
|
||||
global _matting_lock
|
||||
|
||||
if not TRANSFORMERS_AVAILABLE:
|
||||
log_error("Matting request failed: 'transformers' library is not installed.")
|
||||
return web.json_response({
|
||||
"error": "Dependency Not Found",
|
||||
"details": "The 'transformers' library is required for the matting feature. Please install it by running: pip install transformers"
|
||||
}, status=400)
|
||||
|
||||
if _matting_lock is not None:
|
||||
log_warn("Matting already in progress, rejecting request")
|
||||
return web.json_response({
|
||||
"error": "Another matting operation is in progress",
|
||||
"details": "Please wait for the current operation to complete"
|
||||
}, status=429) # 429 Too Many Requests
|
||||
}, status=429)
|
||||
|
||||
_matting_lock = True
|
||||
|
||||
try:
|
||||
log_info("Received matting request")
|
||||
data = await request.json()
|
||||
|
||||
matting = BiRefNetMatting()
|
||||
matting_instance = BiRefNetMatting()
|
||||
|
||||
image_tensor, original_alpha = convert_base64_to_tensor(data["image"])
|
||||
log_debug(f"Input image shape: {image_tensor.shape}")
|
||||
|
||||
matted_image, alpha_mask = matting.execute(
|
||||
matted_image, alpha_mask = matting_instance.execute(
|
||||
image_tensor,
|
||||
"BiRefNet/model.safetensors",
|
||||
threshold=data.get("threshold", 0.5),
|
||||
@@ -680,14 +801,32 @@ async def matting(request):
|
||||
"alpha_mask": result_mask
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(f"Error in matting endpoint: {str(e)}")
|
||||
except RequestsConnectionError as e:
|
||||
log_error(f"Connection error during matting model download: {e}")
|
||||
return web.json_response({
|
||||
"error": str(e),
|
||||
"error": "Network Connection Error",
|
||||
"details": "Failed to download the matting model from Hugging Face. Please check your internet connection."
|
||||
}, status=400)
|
||||
except RuntimeError as e:
|
||||
log_error(f"Runtime error during matting: {e}")
|
||||
return web.json_response({
|
||||
"error": "Matting Model Error",
|
||||
"details": str(e)
|
||||
}, status=500)
|
||||
except Exception as e:
|
||||
log_exception(f"Error in matting endpoint: {e}")
|
||||
# Check for offline error message from Hugging Face
|
||||
if "Offline mode is enabled" in str(e) or "Can't load 'ZhengPeng7/BiRefNet' offline" in str(e):
|
||||
return web.json_response({
|
||||
"error": "Network Connection Error",
|
||||
"details": "Failed to download the matting model from Hugging Face. Please check your internet connection and ensure you are not in offline mode."
|
||||
}, status=400)
|
||||
|
||||
return web.json_response({
|
||||
"error": "An unexpected error occurred",
|
||||
"details": traceback.format_exc()
|
||||
}, status=500)
|
||||
finally:
|
||||
|
||||
_matting_lock = None
|
||||
log_debug("Matting lock released")
|
||||
|
||||
@@ -773,12 +912,3 @@ def convert_tensor_to_base64(tensor, alpha_mask=None, original_alpha=None):
|
||||
log_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
|
||||
raise
|
||||
|
||||
CanvasNode.setup_routes()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"CanvasNode": CanvasNode
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"CanvasNode": "LayerForge"
|
||||
}
|
||||
|
||||
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 |
843
example_workflows/LayerForge_flux_fill_inpaint_example.json
Normal file
843
example_workflows/LayerForge_flux_fill_inpaint_example.json
Normal file
@@ -0,0 +1,843 @@
|
||||
{
|
||||
"id": "d26732fd-91ea-4503-8d0d-383544823cec",
|
||||
"revision": 0,
|
||||
"last_node_id": 52,
|
||||
"last_link_id": 114,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
307,
|
||||
282
|
||||
],
|
||||
"size": [
|
||||
425.2799987792969,
|
||||
180.61000061035156
|
||||
],
|
||||
"flags": {
|
||||
"collapsed": true
|
||||
},
|
||||
"order": 8,
|
||||
"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": 10,
|
||||
"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": 9,
|
||||
"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": 14,
|
||||
"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": 12,
|
||||
"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": 11,
|
||||
"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": 113
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 114
|
||||
}
|
||||
],
|
||||
"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": 13,
|
||||
"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": [
|
||||
1006953529460557,
|
||||
"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": 15,
|
||||
"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": [
|
||||
-905.195556640625,
|
||||
924.5140991210938
|
||||
],
|
||||
"size": [
|
||||
311.0955810546875,
|
||||
108.43277740478516
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"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": 51,
|
||||
"type": "Note",
|
||||
"pos": [
|
||||
-916.8970947265625,
|
||||
476.72564697265625
|
||||
],
|
||||
"size": [
|
||||
350.92510986328125,
|
||||
250.50831604003906
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"How to Use Polygonal Selection\n- Start Drawing: Hold Shift + S and left-click to place the first point of your polygonal selection.\n\n- Add Points: Continue left-clicking to place additional points. Each click adds a new vertex to your polygon.\n\n- Close Selection: Click back on the first point (or close to it) to complete and close the polygonal selection.\n\n- Run Inpainting: Once your selection is complete, run your inpainting workflow as usual. The generated content will seamlessly integrate with the existing image."
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
},
|
||||
{
|
||||
"id": 52,
|
||||
"type": "Note",
|
||||
"pos": [
|
||||
-911.10205078125,
|
||||
769.1378173828125
|
||||
],
|
||||
"size": [
|
||||
350.28143310546875,
|
||||
99.23915100097656
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Add a description at the bottom to tell the model what to generate."
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653"
|
||||
},
|
||||
{
|
||||
"id": 50,
|
||||
"type": "LayerForgeNode",
|
||||
"pos": [
|
||||
-553.9073486328125,
|
||||
478.2644348144531
|
||||
],
|
||||
"size": [
|
||||
1879.927490234375,
|
||||
1259.4072265625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
113
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
114
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "layerforge",
|
||||
"ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
|
||||
"Node name for S&R": "LayerForgeNode",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
18,
|
||||
"50",
|
||||
""
|
||||
]
|
||||
}
|
||||
],
|
||||
"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"
|
||||
],
|
||||
[
|
||||
110,
|
||||
38,
|
||||
2,
|
||||
49,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
111,
|
||||
49,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
112,
|
||||
3,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
113,
|
||||
50,
|
||||
0,
|
||||
38,
|
||||
3,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
114,
|
||||
50,
|
||||
1,
|
||||
38,
|
||||
4,
|
||||
"MASK"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.6588450000000008,
|
||||
"offset": [
|
||||
1117.7398801488407,
|
||||
-110.40634975151642
|
||||
]
|
||||
},
|
||||
"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: 1.7 MiB |
423
example_workflows/LayerForge_test_simple_workflow.json
Normal file
423
example_workflows/LayerForge_test_simple_workflow.json
Normal file
@@ -0,0 +1,423 @@
|
||||
{
|
||||
"id": "c7ba7096-c52c-4978-8843-e87ce219b6a8",
|
||||
"revision": 0,
|
||||
"last_node_id": 710,
|
||||
"last_link_id": 1505,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 708,
|
||||
"type": "LayerForgeNode",
|
||||
"pos": [
|
||||
-3077.55615234375,
|
||||
-3358.0537109375
|
||||
],
|
||||
"size": [
|
||||
1150,
|
||||
1000
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1500
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
1501
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "layerforge",
|
||||
"ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
|
||||
"widget_ue_connectable": {},
|
||||
"Node name for S&R": "LayerForgeNode"
|
||||
},
|
||||
"widgets_values": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
11,
|
||||
"708",
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 709,
|
||||
"type": "Reroute",
|
||||
"pos": [
|
||||
-1920.4510498046875,
|
||||
-3559.688232421875
|
||||
],
|
||||
"size": [
|
||||
75,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "*",
|
||||
"link": 1500
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1502,
|
||||
1503
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"showOutputText": false,
|
||||
"horizontal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 710,
|
||||
"type": "Reroute",
|
||||
"pos": [
|
||||
-1917.6273193359375,
|
||||
-3524.312744140625
|
||||
],
|
||||
"size": [
|
||||
75,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "*",
|
||||
"link": 1501
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
1504,
|
||||
1505
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"showOutputText": false,
|
||||
"horizontal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 369,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-1914.3177490234375,
|
||||
-2807.92724609375
|
||||
],
|
||||
"size": [
|
||||
710,
|
||||
450
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1499
|
||||
}
|
||||
],
|
||||
"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": [
|
||||
-1913.4202880859375,
|
||||
-3428.773193359375
|
||||
],
|
||||
"size": [
|
||||
700,
|
||||
510
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1503
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "PreviewImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 442,
|
||||
"type": "JoinImageWithAlpha",
|
||||
"pos": [
|
||||
-1190.1787109375,
|
||||
-3237.75732421875
|
||||
],
|
||||
"size": [
|
||||
176.86483764648438,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1502
|
||||
},
|
||||
{
|
||||
"name": "alpha",
|
||||
"type": "MASK",
|
||||
"link": 1505
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1236,
|
||||
1465
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.34",
|
||||
"Node name for S&R": "JoinImageWithAlpha",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 603,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
-1188.5968017578125,
|
||||
-3143.6875
|
||||
],
|
||||
"size": [
|
||||
640,
|
||||
510
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"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": [
|
||||
-536.2315673828125,
|
||||
-3135.49755859375
|
||||
],
|
||||
"size": [
|
||||
279.97137451171875,
|
||||
282
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"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": 706,
|
||||
"type": "MaskToImage",
|
||||
"pos": [
|
||||
-1911.38525390625,
|
||||
-2875.74658203125
|
||||
],
|
||||
"size": [
|
||||
184.62362670898438,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 1504
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
1499
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"cnr_id": "comfy-core",
|
||||
"ver": "0.3.44",
|
||||
"Node name for S&R": "MaskToImage",
|
||||
"widget_ue_connectable": {}
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1236,
|
||||
442,
|
||||
0,
|
||||
603,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1465,
|
||||
442,
|
||||
0,
|
||||
680,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1499,
|
||||
706,
|
||||
0,
|
||||
369,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1500,
|
||||
708,
|
||||
0,
|
||||
709,
|
||||
0,
|
||||
"*"
|
||||
],
|
||||
[
|
||||
1501,
|
||||
708,
|
||||
1,
|
||||
710,
|
||||
0,
|
||||
"*"
|
||||
],
|
||||
[
|
||||
1502,
|
||||
709,
|
||||
0,
|
||||
442,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1503,
|
||||
709,
|
||||
0,
|
||||
606,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
1504,
|
||||
710,
|
||||
0,
|
||||
706,
|
||||
0,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
1505,
|
||||
710,
|
||||
0,
|
||||
442,
|
||||
1,
|
||||
"MASK"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.7972024500000005,
|
||||
"offset": [
|
||||
3208.3419155969927,
|
||||
3617.011371212156
|
||||
]
|
||||
},
|
||||
"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_test_simple_workflow.png
Normal file
BIN
example_workflows/LayerForge_test_simple_workflow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 854 KiB |
249
js/BatchPreviewManager.js
Normal file
249
js/BatchPreviewManager.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('BatchPreviewManager');
|
||||
export class BatchPreviewManager {
|
||||
constructor(canvas, initialPosition = { x: 0, y: 0 }, generationArea = null) {
|
||||
this.canvas = canvas;
|
||||
this.active = false;
|
||||
this.layers = [];
|
||||
this.currentIndex = 0;
|
||||
this.element = null;
|
||||
this.counterElement = null;
|
||||
this.uiInitialized = false;
|
||||
this.maskWasVisible = false;
|
||||
this.worldX = initialPosition.x;
|
||||
this.worldY = initialPosition.y;
|
||||
this.isDragging = false;
|
||||
this.generationArea = generationArea;
|
||||
}
|
||||
updateScreenPosition(viewport) {
|
||||
if (!this.active || !this.element)
|
||||
return;
|
||||
const screenX = (this.worldX - viewport.x) * viewport.zoom;
|
||||
const screenY = (this.worldY - viewport.y) * viewport.zoom;
|
||||
const scale = 1;
|
||||
this.element.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`;
|
||||
}
|
||||
_createUI() {
|
||||
if (this.uiInitialized)
|
||||
return;
|
||||
this.element = document.createElement('div');
|
||||
this.element.id = 'layerforge-batch-preview';
|
||||
this.element.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #333;
|
||||
color: white;
|
||||
padding: 8px 15px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
font-family: sans-serif;
|
||||
z-index: 1001;
|
||||
border: 1px solid #555;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
`;
|
||||
this.element.addEventListener('mousedown', (e) => {
|
||||
if (e.target.tagName === 'BUTTON')
|
||||
return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.isDragging = true;
|
||||
const handleMouseMove = (moveEvent) => {
|
||||
if (this.isDragging) {
|
||||
const deltaX = moveEvent.movementX / this.canvas.viewport.zoom;
|
||||
const deltaY = moveEvent.movementY / this.canvas.viewport.zoom;
|
||||
this.worldX += deltaX;
|
||||
this.worldY += deltaY;
|
||||
// The render loop will handle updating the screen position, but we need to trigger it.
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
this.isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
const prevButton = this._createButton('◀', 'Previous'); // Left arrow
|
||||
const nextButton = this._createButton('▶', 'Next'); // Right arrow
|
||||
const confirmButton = this._createButton('✔', 'Confirm'); // Checkmark
|
||||
const cancelButton = this._createButton('✖', 'Cancel All');
|
||||
const closeButton = this._createButton('➲', 'Close');
|
||||
this.counterElement = document.createElement('span');
|
||||
this.counterElement.style.minWidth = '40px';
|
||||
this.counterElement.style.textAlign = 'center';
|
||||
this.counterElement.style.fontWeight = 'bold';
|
||||
prevButton.onclick = () => this.navigate(-1);
|
||||
nextButton.onclick = () => this.navigate(1);
|
||||
confirmButton.onclick = () => this.confirm();
|
||||
cancelButton.onclick = () => this.cancelAndRemoveAll();
|
||||
closeButton.onclick = () => this.hide();
|
||||
this.element.append(prevButton, this.counterElement, nextButton, confirmButton, cancelButton, closeButton);
|
||||
if (this.canvas.canvas.parentElement) {
|
||||
this.canvas.canvas.parentElement.appendChild(this.element);
|
||||
}
|
||||
else {
|
||||
log.error("Could not find parent node to attach batch preview UI.");
|
||||
}
|
||||
this.uiInitialized = true;
|
||||
}
|
||||
_createButton(innerHTML, title) {
|
||||
const button = document.createElement('button');
|
||||
button.innerHTML = innerHTML;
|
||||
button.title = title;
|
||||
button.style.cssText = `
|
||||
background: #555;
|
||||
color: white;
|
||||
border: 1px solid #777;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
button.onmouseover = () => button.style.background = '#666';
|
||||
button.onmouseout = () => button.style.background = '#555';
|
||||
return button;
|
||||
}
|
||||
show(layers) {
|
||||
if (!layers || layers.length <= 1) {
|
||||
return;
|
||||
}
|
||||
this._createUI();
|
||||
// Auto-hide mask logic
|
||||
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
||||
if (this.maskWasVisible) {
|
||||
this.canvas.maskTool.toggleOverlayVisibility();
|
||||
const toggleSwitch = document.getElementById(`toggle-mask-switch-${this.canvas.node.id}`);
|
||||
if (toggleSwitch) {
|
||||
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
toggleSwitch.classList.remove('primary');
|
||||
const iconContainer = toggleSwitch.querySelector('.switch-icon');
|
||||
if (iconContainer) {
|
||||
iconContainer.style.opacity = '0.5';
|
||||
}
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
log.info(`Showing batch preview for ${layers.length} layers.`);
|
||||
this.layers = layers;
|
||||
this.currentIndex = 0;
|
||||
if (this.element) {
|
||||
this.element.style.display = 'flex';
|
||||
}
|
||||
this.active = true;
|
||||
if (this.element) {
|
||||
const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom;
|
||||
const paddingInWorld = 20 / this.canvas.viewport.zoom;
|
||||
this.worldX -= menuWidthInWorld / 2;
|
||||
this.worldY += paddingInWorld;
|
||||
}
|
||||
// Hide all batch layers initially, then show only the first one
|
||||
this.layers.forEach((layer) => {
|
||||
layer.visible = false;
|
||||
});
|
||||
this._update();
|
||||
}
|
||||
hide() {
|
||||
log.info('Hiding batch preview.');
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
}
|
||||
this.active = false;
|
||||
const index = this.canvas.batchPreviewManagers.indexOf(this);
|
||||
if (index > -1) {
|
||||
this.canvas.batchPreviewManagers.splice(index, 1);
|
||||
}
|
||||
this.canvas.render();
|
||||
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
||||
this.canvas.maskTool.toggleOverlayVisibility();
|
||||
const toggleSwitch = document.getElementById(`toggle-mask-switch-${String(this.canvas.node.id)}`);
|
||||
if (toggleSwitch) {
|
||||
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
toggleSwitch.classList.add('primary');
|
||||
const iconContainer = toggleSwitch.querySelector('.switch-icon');
|
||||
if (iconContainer) {
|
||||
iconContainer.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
this.maskWasVisible = false;
|
||||
// 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) {
|
||||
this.currentIndex += direction;
|
||||
if (this.currentIndex < 0) {
|
||||
this.currentIndex = this.layers.length - 1;
|
||||
}
|
||||
else if (this.currentIndex >= this.layers.length) {
|
||||
this.currentIndex = 0;
|
||||
}
|
||||
this._update();
|
||||
}
|
||||
confirm() {
|
||||
const layerToKeep = this.layers[this.currentIndex];
|
||||
log.info(`Confirming selection: Keeping layer ${layerToKeep.id}.`);
|
||||
const layersToDelete = this.layers.filter((l) => l.id !== layerToKeep.id);
|
||||
const layerIdsToDelete = layersToDelete.map((l) => l.id);
|
||||
this.canvas.removeLayersByIds(layerIdsToDelete);
|
||||
log.info(`Deleted ${layersToDelete.length} other layers.`);
|
||||
this.hide();
|
||||
}
|
||||
cancelAndRemoveAll() {
|
||||
log.info('Cancel clicked. Removing all new layers.');
|
||||
const layerIdsToDelete = this.layers.map((l) => l.id);
|
||||
this.canvas.removeLayersByIds(layerIdsToDelete);
|
||||
log.info(`Deleted all ${layerIdsToDelete.length} new layers.`);
|
||||
this.hide();
|
||||
}
|
||||
_update() {
|
||||
if (this.counterElement) {
|
||||
this.counterElement.textContent = `${this.currentIndex + 1} / ${this.layers.length}`;
|
||||
}
|
||||
this._focusOnLayer(this.layers[this.currentIndex]);
|
||||
}
|
||||
_focusOnLayer(layer) {
|
||||
if (!layer)
|
||||
return;
|
||||
log.debug(`Focusing on layer ${layer.id} 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();
|
||||
}
|
||||
}
|
||||
828
js/Canvas.js
828
js/Canvas.js
@@ -1,118 +1,481 @@
|
||||
import {removeImage} from "./db.js";
|
||||
import {MaskTool} from "./MaskTool.js";
|
||||
import {CanvasState} from "./CanvasState.js";
|
||||
import {CanvasInteractions} from "./CanvasInteractions.js";
|
||||
import {CanvasLayers} from "./CanvasLayers.js";
|
||||
import {CanvasRenderer} from "./CanvasRenderer.js";
|
||||
import {CanvasIO} from "./CanvasIO.js";
|
||||
import {ImageReferenceManager} from "./ImageReferenceManager.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
// @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";
|
||||
import { CanvasLayersPanel } from "./CanvasLayersPanel.js";
|
||||
import { CanvasRenderer } from "./CanvasRenderer.js";
|
||||
import { CanvasIO } from "./CanvasIO.js";
|
||||
import { ImageReferenceManager } from "./ImageReferenceManager.js";
|
||||
import { BatchPreviewManager } from "./BatchPreviewManager.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { debounce, createCanvas } from "./utils/CommonUtils.js";
|
||||
import { MaskEditorIntegration } from "./MaskEditorIntegration.js";
|
||||
import { CanvasSelection } from "./CanvasSelection.js";
|
||||
const useChainCallback = (original, next) => {
|
||||
if (original === undefined || original === null) {
|
||||
return next;
|
||||
}
|
||||
return function (...args) {
|
||||
const originalReturn = original.apply(this, args);
|
||||
const nextReturn = next.apply(this, args);
|
||||
return nextReturn === undefined ? originalReturn : nextReturn;
|
||||
};
|
||||
};
|
||||
const log = createModuleLogger('Canvas');
|
||||
|
||||
/**
|
||||
* Canvas - Fasada dla systemu rysowania
|
||||
*
|
||||
* Klasa Canvas pełni rolę fasady, oferując uproszczony interfejs wysokiego poziomu
|
||||
* dla złożonego systemu rysowania. Zamiast eksponować wszystkie metody modułów,
|
||||
* udostępnia tylko kluczowe operacje i umożliwia bezpośredni dostęp do modułów
|
||||
* gdy potrzebna jest bardziej szczegółowa kontrola.
|
||||
*/
|
||||
export class Canvas {
|
||||
constructor(node, widget, callbacks = {}) {
|
||||
this.node = node;
|
||||
this.widget = widget;
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
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.selectedLayer = null;
|
||||
this.selectedLayers = [];
|
||||
this.onSelectionChange = null;
|
||||
this.onStateChange = callbacks.onStateChange || null;
|
||||
this.lastMousePosition = {x: 0, y: 0};
|
||||
|
||||
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.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
|
||||
this.initCanvas();
|
||||
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.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);
|
||||
this.canvasLayers = new CanvasLayers(this);
|
||||
this.canvasLayersPanel = new CanvasLayersPanel(this);
|
||||
this.canvasRenderer = new CanvasRenderer(this);
|
||||
this.canvasIO = new CanvasIO(this);
|
||||
this.imageReferenceManager = new ImageReferenceManager(this);
|
||||
this.batchPreviewManagers = [];
|
||||
this.pendingBatchContext = null;
|
||||
this.interaction = this.canvasInteractions.interaction;
|
||||
|
||||
this.setupEventListeners();
|
||||
this.initNodeData();
|
||||
|
||||
this.layers = this.layers.map(layer => ({
|
||||
this.previewVisible = false;
|
||||
this.isMouseOver = false;
|
||||
this._initializeModules();
|
||||
this._setupCanvas();
|
||||
log.debug('Canvas widget element:', this.node);
|
||||
log.info('Canvas initialized', {
|
||||
nodeId: this.node.id,
|
||||
dimensions: { width: this.width, height: this.height },
|
||||
viewport: this.viewport
|
||||
});
|
||||
this.previewVisible = false;
|
||||
}
|
||||
async waitForWidget(name, node, interval = 100, timeout = 20000) {
|
||||
const startTime = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
const check = () => {
|
||||
const widget = node.widgets.find((w) => w.name === name);
|
||||
if (widget) {
|
||||
resolve(widget);
|
||||
}
|
||||
else if (Date.now() - startTime > timeout) {
|
||||
reject(new Error(`Widget "${name}" not found within timeout.`));
|
||||
}
|
||||
else {
|
||||
setTimeout(check, interval);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Kontroluje widoczność podglądu canvas
|
||||
* @param {boolean} visible - Czy podgląd ma być widoczny
|
||||
*/
|
||||
async setPreviewVisibility(visible) {
|
||||
this.previewVisible = visible;
|
||||
log.info("Canvas preview visibility set to:", visible);
|
||||
const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node);
|
||||
if (imagePreviewWidget) {
|
||||
log.debug("Found $$canvas-image-preview widget, controlling visibility");
|
||||
if (visible) {
|
||||
if (imagePreviewWidget.options) {
|
||||
imagePreviewWidget.options.hidden = false;
|
||||
}
|
||||
if ('visible' in imagePreviewWidget) {
|
||||
imagePreviewWidget.visible = true;
|
||||
}
|
||||
if ('hidden' in imagePreviewWidget) {
|
||||
imagePreviewWidget.hidden = false;
|
||||
}
|
||||
imagePreviewWidget.computeSize = function () {
|
||||
return [0, 250]; // Szerokość 0 (auto), wysokość 250
|
||||
};
|
||||
}
|
||||
else {
|
||||
if (imagePreviewWidget.options) {
|
||||
imagePreviewWidget.options.hidden = true;
|
||||
}
|
||||
if ('visible' in imagePreviewWidget) {
|
||||
imagePreviewWidget.visible = false;
|
||||
}
|
||||
if ('hidden' in imagePreviewWidget) {
|
||||
imagePreviewWidget.hidden = true;
|
||||
}
|
||||
imagePreviewWidget.computeSize = function () {
|
||||
return [0, 0]; // Szerokość 0, wysokość 0
|
||||
};
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
else {
|
||||
log.warn("$$canvas-image-preview widget not found in Canvas.js");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Inicjalizuje moduły systemu canvas
|
||||
* @private
|
||||
*/
|
||||
_initializeModules() {
|
||||
log.debug('Initializing Canvas modules...');
|
||||
// Stwórz opóźnioną wersję funkcji zapisu stanu
|
||||
this.requestSaveState = debounce(() => this.saveState(), 500);
|
||||
this._setupAutoRefreshHandlers();
|
||||
log.debug('Canvas modules initialized successfully');
|
||||
}
|
||||
/**
|
||||
* Konfiguruje podstawowe właściwości canvas
|
||||
* @private
|
||||
*/
|
||||
_setupCanvas() {
|
||||
this.initCanvas();
|
||||
this.canvasInteractions.setupEventListeners();
|
||||
this.canvasIO.initNodeData();
|
||||
this.layers = this.layers.map((layer) => ({
|
||||
...layer,
|
||||
opacity: 1
|
||||
}));
|
||||
|
||||
this.imageCache = new Map();
|
||||
}
|
||||
|
||||
async loadStateFromDB() {
|
||||
return this.canvasState.loadStateFromDB();
|
||||
}
|
||||
|
||||
async saveStateToDB(immediate = false) {
|
||||
return this.canvasState.saveStateToDB(immediate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje stan canvas z bazy danych
|
||||
*/
|
||||
async loadInitialState() {
|
||||
log.info("Loading initial state for node:", this.node.id);
|
||||
const loaded = await this.loadStateFromDB();
|
||||
const loaded = await this.canvasState.loadStateFromDB();
|
||||
if (!loaded) {
|
||||
log.info("No saved state found, initializing from node data.");
|
||||
await this.initNodeData();
|
||||
await this.canvasIO.initNodeData();
|
||||
}
|
||||
this.saveState();
|
||||
this.render();
|
||||
}
|
||||
|
||||
_notifyStateChange() {
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
// Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisuje obecny stan
|
||||
* @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii
|
||||
*/
|
||||
saveState(replaceLast = false) {
|
||||
log.debug('Saving canvas state', { replaceLast, layersCount: this.layers.length });
|
||||
this.canvasState.saveState(replaceLast);
|
||||
this.incrementOperationCount();
|
||||
this._notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cofnij ostatnią operację
|
||||
*/
|
||||
undo() {
|
||||
log.info('Performing undo operation');
|
||||
const historyInfo = this.canvasState.getHistoryInfo();
|
||||
log.debug('History state before undo:', historyInfo);
|
||||
this.canvasState.undo();
|
||||
this.incrementOperationCount();
|
||||
this._notifyStateChange();
|
||||
// Powiadom panel warstw o zmianie stanu warstw
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
this.canvasLayersPanel.onSelectionChanged();
|
||||
}
|
||||
log.debug('Undo completed, layers count:', this.layers.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ponów cofniętą operację
|
||||
*/
|
||||
redo() {
|
||||
log.info('Performing redo operation');
|
||||
const historyInfo = this.canvasState.getHistoryInfo();
|
||||
log.debug('History state before redo:', historyInfo);
|
||||
this.canvasState.redo();
|
||||
this.incrementOperationCount();
|
||||
this._notifyStateChange();
|
||||
}
|
||||
|
||||
updateSelectionAfterHistory() {
|
||||
const newSelectedLayers = [];
|
||||
if (this.selectedLayers) {
|
||||
this.selectedLayers.forEach(sl => {
|
||||
const found = this.layers.find(l => l.id === sl.id);
|
||||
if (found) newSelectedLayers.push(found);
|
||||
});
|
||||
// Powiadom panel warstw o zmianie stanu warstw
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
this.canvasLayersPanel.onSelectionChanged();
|
||||
}
|
||||
this.updateSelection(newSelectedLayers);
|
||||
log.debug('Redo completed, layers count:', this.layers.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderuje canvas
|
||||
*/
|
||||
render() {
|
||||
this.canvasRenderer.render();
|
||||
}
|
||||
/**
|
||||
* Dodaje warstwę z obrazem
|
||||
* @param {Image} image - Obraz do dodania
|
||||
* @param {Object} layerProps - Właściwości warstwy
|
||||
* @param {string} addMode - Tryb dodawania
|
||||
*/
|
||||
async addLayer(image, layerProps = {}, addMode = 'default') {
|
||||
const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
|
||||
// Powiadom panel warstw o dodaniu nowej warstwy
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Usuwa wybrane warstwy
|
||||
*/
|
||||
removeLayersByIds(layerIds) {
|
||||
if (!layerIds || layerIds.length === 0)
|
||||
return;
|
||||
const initialCount = this.layers.length;
|
||||
this.saveState();
|
||||
this.layers = this.layers.filter((l) => !layerIds.includes(l.id));
|
||||
// If the current selection was part of the removal, clear it
|
||||
const newSelection = this.canvasSelection.selectedLayers.filter((l) => !layerIds.includes(l.id));
|
||||
this.canvasSelection.updateSelection(newSelection);
|
||||
this.render();
|
||||
this.saveState();
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
log.info(`Removed ${initialCount - this.layers.length} layers by ID.`);
|
||||
}
|
||||
removeSelectedLayers() {
|
||||
return this.canvasSelection.removeSelectedLayers();
|
||||
}
|
||||
/**
|
||||
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
|
||||
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
|
||||
* @param {Array} newSelection - Nowa lista zaznaczonych warstw
|
||||
*/
|
||||
updateSelection(newSelection) {
|
||||
return this.canvasSelection.updateSelection(newSelection);
|
||||
}
|
||||
/**
|
||||
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
|
||||
*/
|
||||
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ść
|
||||
* @param {number} height - Nowa wysokość
|
||||
* @param {boolean} saveHistory - Czy zapisać w historii
|
||||
*/
|
||||
updateOutputAreaSize(width, height, saveHistory = true) {
|
||||
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
|
||||
*/
|
||||
async getFlattenedCanvasAsBlob() {
|
||||
return this.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
}
|
||||
/**
|
||||
* Eksportuje spłaszczony canvas z maską jako kanałem alpha
|
||||
*/
|
||||
async getFlattenedCanvasWithMaskAsBlob() {
|
||||
return this.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
}
|
||||
/**
|
||||
* Importuje najnowszy obraz
|
||||
*/
|
||||
async importLatestImage() {
|
||||
return this.canvasIO.importLatestImage();
|
||||
}
|
||||
_setupAutoRefreshHandlers() {
|
||||
let lastExecutionStartTime = 0;
|
||||
// Helper function to get auto-refresh value from node widget
|
||||
const getAutoRefreshValue = () => {
|
||||
const widget = this.node.widgets.find((w) => w.name === 'auto_refresh_after_generation');
|
||||
return widget ? widget.value : false;
|
||||
};
|
||||
const handleExecutionStart = () => {
|
||||
if (getAutoRefreshValue()) {
|
||||
lastExecutionStartTime = Date.now();
|
||||
// Store a snapshot of the context for the upcoming batch
|
||||
this.pendingBatchContext = {
|
||||
// For the menu position - position relative to outputAreaBounds, not canvas center
|
||||
spawnPosition: {
|
||||
x: this.outputAreaBounds.x + this.outputAreaBounds.width / 2,
|
||||
y: this.outputAreaBounds.y + this.outputAreaBounds.height
|
||||
},
|
||||
// For the image placement - use actual outputAreaBounds instead of hardcoded (0,0)
|
||||
outputArea: {
|
||||
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);
|
||||
this.render(); // Trigger render to show the pending outline immediately
|
||||
}
|
||||
};
|
||||
const handleExecutionSuccess = async () => {
|
||||
if (getAutoRefreshValue()) {
|
||||
log.info('Auto-refresh triggered, importing latest images.');
|
||||
if (!this.pendingBatchContext) {
|
||||
log.warn("execution_start did not fire, cannot process batch. Awaiting next execution.");
|
||||
return;
|
||||
}
|
||||
// Use the captured output area for image import
|
||||
const newLayers = await this.canvasIO.importLatestImages(lastExecutionStartTime, this.pendingBatchContext.outputArea);
|
||||
if (newLayers && newLayers.length > 1) {
|
||||
const newManager = new BatchPreviewManager(this, this.pendingBatchContext.spawnPosition, this.pendingBatchContext.outputArea);
|
||||
this.batchPreviewManagers.push(newManager);
|
||||
newManager.show(newLayers);
|
||||
}
|
||||
// Consume the context
|
||||
this.pendingBatchContext = null;
|
||||
// Final render to clear the outline if it was the last one
|
||||
this.render();
|
||||
}
|
||||
};
|
||||
api.addEventListener('execution_start', handleExecutionStart);
|
||||
api.addEventListener('execution_success', handleExecutionSuccess);
|
||||
this.node.onRemoved = useChainCallback(this.node.onRemoved, () => {
|
||||
log.info('Node removed, cleaning up auto-refresh listeners.');
|
||||
api.removeEventListener('execution_start', handleExecutionStart);
|
||||
api.removeEventListener('execution_success', handleExecutionSuccess);
|
||||
});
|
||||
log.debug('Auto-refresh handlers setup complete, reading from node widget: auto_refresh_after_generation');
|
||||
}
|
||||
/**
|
||||
* Uruchamia edytor masek
|
||||
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora
|
||||
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
|
||||
*/
|
||||
async startMaskEditor(predefinedMask = null, sendCleanImage = true) {
|
||||
return this.maskEditorIntegration.startMaskEditor(predefinedMask, sendCleanImage);
|
||||
}
|
||||
/**
|
||||
* Inicjalizuje podstawowe właściwości canvas
|
||||
*/
|
||||
initCanvas() {
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
this.canvas.style.border = '1px solid black';
|
||||
this.canvas.style.maxWidth = '100%';
|
||||
this.canvas.style.backgroundColor = '#606060';
|
||||
this.canvas.style.width = '100%';
|
||||
this.canvas.style.height = '100%';
|
||||
this.canvas.tabIndex = 0;
|
||||
this.canvas.style.outline = 'none';
|
||||
}
|
||||
/**
|
||||
* Pobiera współrzędne myszy w układzie świata
|
||||
* @param {MouseEvent} e - Zdarzenie myszy
|
||||
*/
|
||||
getMouseWorldCoordinates(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mouseX_DOM = e.clientX - rect.left;
|
||||
const mouseY_DOM = e.clientY - rect.top;
|
||||
if (!this.offscreenCanvas)
|
||||
throw new Error("Offscreen canvas not initialized");
|
||||
const scaleX = this.offscreenCanvas.width / rect.width;
|
||||
const scaleY = this.offscreenCanvas.height / rect.height;
|
||||
const mouseX_Buffer = mouseX_DOM * scaleX;
|
||||
const mouseY_Buffer = mouseY_DOM * scaleY;
|
||||
const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x;
|
||||
const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y;
|
||||
return { x: worldX, y: worldY };
|
||||
}
|
||||
/**
|
||||
* Pobiera współrzędne myszy w układzie widoku
|
||||
* @param {MouseEvent} e - Zdarzenie myszy
|
||||
*/
|
||||
getMouseViewCoordinates(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mouseX_DOM = e.clientX - rect.left;
|
||||
const mouseY_DOM = e.clientY - rect.top;
|
||||
const scaleX = this.canvas.width / rect.width;
|
||||
const scaleY = this.canvas.height / rect.height;
|
||||
const mouseX_Canvas = mouseX_DOM * scaleX;
|
||||
const mouseY_Canvas = mouseY_DOM * scaleY;
|
||||
return { x: mouseX_Canvas, y: mouseY_Canvas };
|
||||
}
|
||||
/**
|
||||
* Aktualizuje zaznaczenie po operacji historii
|
||||
*/
|
||||
updateSelectionAfterHistory() {
|
||||
return this.canvasSelection.updateSelectionAfterHistory();
|
||||
}
|
||||
/**
|
||||
* Aktualizuje przyciski historii
|
||||
*/
|
||||
updateHistoryButtons() {
|
||||
if (this.onHistoryChange) {
|
||||
const historyInfo = this.canvasState.getHistoryInfo();
|
||||
@@ -122,338 +485,16 @@ export class Canvas {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initCanvas() {
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
this.canvas.style.border = '1px solid black';
|
||||
this.canvas.style.maxWidth = '100%';
|
||||
this.canvas.style.backgroundColor = '#606060';
|
||||
this.canvas.style.width = '100%';
|
||||
this.canvas.style.height = '100%';
|
||||
|
||||
|
||||
this.canvas.tabIndex = 0;
|
||||
this.canvas.style.outline = 'none';
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.canvasInteractions.setupEventListeners();
|
||||
}
|
||||
|
||||
updateSelection(newSelection) {
|
||||
this.selectedLayers = newSelection || [];
|
||||
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
|
||||
if (this.onSelectionChange) {
|
||||
this.onSelectionChange();
|
||||
}
|
||||
}
|
||||
|
||||
async copySelectedLayers() {
|
||||
return this.canvasLayers.copySelectedLayers();
|
||||
}
|
||||
|
||||
pasteLayers() {
|
||||
return this.canvasLayers.pasteLayers();
|
||||
}
|
||||
|
||||
async handlePaste(addMode) {
|
||||
return this.canvasLayers.handlePaste(addMode);
|
||||
}
|
||||
|
||||
|
||||
handleMouseMove(e) {
|
||||
this.canvasInteractions.handleMouseMove(e);
|
||||
}
|
||||
|
||||
|
||||
handleMouseUp(e) {
|
||||
this.canvasInteractions.handleMouseUp(e);
|
||||
}
|
||||
|
||||
|
||||
handleMouseLeave(e) {
|
||||
this.canvasInteractions.handleMouseLeave(e);
|
||||
}
|
||||
|
||||
|
||||
handleWheel(e) {
|
||||
this.canvasInteractions.handleWheel(e);
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
this.canvasInteractions.handleKeyDown(e);
|
||||
}
|
||||
|
||||
handleKeyUp(e) {
|
||||
this.canvasInteractions.handleKeyUp(e);
|
||||
}
|
||||
|
||||
|
||||
isRotationHandle(x, y) {
|
||||
return this.canvasLayers.isRotationHandle(x, y);
|
||||
}
|
||||
|
||||
async addLayerWithImage(image, layerProps = {}, addMode = 'default') {
|
||||
return this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
|
||||
}
|
||||
|
||||
|
||||
async addLayer(image, addMode = 'default') {
|
||||
return this.addLayerWithImage(image, {}, addMode);
|
||||
}
|
||||
|
||||
async removeLayer(index) {
|
||||
if (index >= 0 && index < this.layers.length) {
|
||||
const layer = this.layers[index];
|
||||
if (layer.imageId) {
|
||||
const isImageUsedElsewhere = this.layers.some((l, i) => i !== index && l.imageId === layer.imageId);
|
||||
if (!isImageUsedElsewhere) {
|
||||
await removeImage(layer.imageId);
|
||||
this.imageCache.delete(layer.imageId);
|
||||
}
|
||||
}
|
||||
this.layers.splice(index, 1);
|
||||
this.selectedLayer = this.layers[this.layers.length - 1] || null;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
removeSelectedLayers() {
|
||||
if (this.selectedLayers.length > 0) {
|
||||
this.saveState();
|
||||
this.layers = this.layers.filter(l => !this.selectedLayers.includes(l));
|
||||
this.updateSelection([]);
|
||||
this.render();
|
||||
this.saveState();
|
||||
}
|
||||
}
|
||||
|
||||
getMouseWorldCoordinates(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
|
||||
const mouseX_DOM = e.clientX - rect.left;
|
||||
const mouseY_DOM = e.clientY - rect.top;
|
||||
|
||||
const scaleX = this.offscreenCanvas.width / rect.width;
|
||||
const scaleY = this.offscreenCanvas.height / rect.height;
|
||||
|
||||
const mouseX_Buffer = mouseX_DOM * scaleX;
|
||||
const mouseY_Buffer = mouseY_DOM * scaleY;
|
||||
|
||||
const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x;
|
||||
const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y;
|
||||
|
||||
return {x: worldX, y: worldY};
|
||||
}
|
||||
|
||||
getMouseViewCoordinates(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mouseX_DOM = e.clientX - rect.left;
|
||||
const mouseY_DOM = e.clientY - rect.top;
|
||||
|
||||
const scaleX = this.canvas.width / rect.width;
|
||||
const scaleY = this.canvas.height / rect.height;
|
||||
|
||||
const mouseX_Canvas = mouseX_DOM * scaleX;
|
||||
const mouseY_Canvas = mouseY_DOM * scaleY;
|
||||
|
||||
return { x: mouseX_Canvas, y: mouseY_Canvas };
|
||||
}
|
||||
|
||||
|
||||
moveLayer(fromIndex, toIndex) {
|
||||
return this.canvasLayers.moveLayer(fromIndex, toIndex);
|
||||
}
|
||||
|
||||
resizeLayer(scale) {
|
||||
this.selectedLayers.forEach(layer => {
|
||||
layer.width *= scale;
|
||||
layer.height *= scale;
|
||||
});
|
||||
this.render();
|
||||
this.saveState();
|
||||
}
|
||||
|
||||
rotateLayer(angle) {
|
||||
this.selectedLayers.forEach(layer => {
|
||||
layer.rotation += angle;
|
||||
});
|
||||
this.render();
|
||||
this.saveState();
|
||||
}
|
||||
|
||||
updateOutputAreaSize(width, height, saveHistory = true) {
|
||||
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
|
||||
}
|
||||
|
||||
render() {
|
||||
this.canvasRenderer.render();
|
||||
}
|
||||
|
||||
|
||||
getHandles(layer) {
|
||||
return this.canvasLayers.getHandles(layer);
|
||||
}
|
||||
|
||||
getHandleAtPosition(worldX, worldY) {
|
||||
return this.canvasLayers.getHandleAtPosition(worldX, worldY);
|
||||
}
|
||||
|
||||
|
||||
async getFlattenedCanvasAsBlob() {
|
||||
return this.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
}
|
||||
|
||||
async getFlattenedSelectionAsBlob() {
|
||||
return this.canvasLayers.getFlattenedSelectionAsBlob();
|
||||
}
|
||||
|
||||
moveLayerUp() {
|
||||
return this.canvasLayers.moveLayerUp();
|
||||
}
|
||||
|
||||
moveLayerDown() {
|
||||
return this.canvasLayers.moveLayerDown();
|
||||
}
|
||||
|
||||
|
||||
getLayerAtPosition(worldX, worldY) {
|
||||
return this.canvasLayers.getLayerAtPosition(worldX, worldY);
|
||||
}
|
||||
|
||||
getResizeHandle(x, y) {
|
||||
return this.canvasLayers.getResizeHandle(x, y);
|
||||
}
|
||||
|
||||
async mirrorHorizontal() {
|
||||
return this.canvasLayers.mirrorHorizontal();
|
||||
}
|
||||
|
||||
async mirrorVertical() {
|
||||
return this.canvasLayers.mirrorVertical();
|
||||
}
|
||||
|
||||
async getLayerImageData(layer) {
|
||||
return this.canvasLayers.getLayerImageData(layer);
|
||||
}
|
||||
|
||||
addMattedLayer(image, mask) {
|
||||
return this.canvasLayers.addMattedLayer(image, mask);
|
||||
}
|
||||
|
||||
async addInputToCanvas(inputImage, inputMask) {
|
||||
return this.canvasIO.addInputToCanvas(inputImage, inputMask);
|
||||
}
|
||||
|
||||
async convertTensorToImage(tensor) {
|
||||
return this.canvasIO.convertTensorToImage(tensor);
|
||||
}
|
||||
|
||||
async convertTensorToMask(tensor) {
|
||||
return this.canvasIO.convertTensorToMask(tensor);
|
||||
}
|
||||
|
||||
async initNodeData() {
|
||||
return this.canvasIO.initNodeData();
|
||||
}
|
||||
|
||||
scheduleDataCheck() {
|
||||
return this.canvasIO.scheduleDataCheck();
|
||||
}
|
||||
|
||||
async processImageData(imageData) {
|
||||
return this.canvasIO.processImageData(imageData);
|
||||
}
|
||||
|
||||
addScaledLayer(image, scale) {
|
||||
return this.canvasIO.addScaledLayer(image, scale);
|
||||
}
|
||||
|
||||
convertTensorToImageData(tensor) {
|
||||
return this.canvasIO.convertTensorToImageData(tensor);
|
||||
}
|
||||
|
||||
async createImageFromData(imageData) {
|
||||
return this.canvasIO.createImageFromData(imageData);
|
||||
}
|
||||
|
||||
async retryDataLoad(maxRetries = 3, delay = 1000) {
|
||||
return this.canvasIO.retryDataLoad(maxRetries, delay);
|
||||
}
|
||||
|
||||
async processMaskData(maskData) {
|
||||
return this.canvasIO.processMaskData(maskData);
|
||||
}
|
||||
|
||||
async loadImageFromCache(base64Data) {
|
||||
return this.canvasIO.loadImageFromCache(base64Data);
|
||||
}
|
||||
|
||||
async importImage(cacheData) {
|
||||
return this.canvasIO.importImage(cacheData);
|
||||
}
|
||||
|
||||
async importLatestImage() {
|
||||
return this.canvasIO.importLatestImage();
|
||||
}
|
||||
|
||||
showBlendModeMenu(x, y) {
|
||||
return this.canvasLayers.showBlendModeMenu(x, y);
|
||||
}
|
||||
|
||||
handleBlendModeSelection(mode) {
|
||||
return this.canvasLayers.handleBlendModeSelection(mode);
|
||||
}
|
||||
|
||||
showOpacitySlider(mode) {
|
||||
return this.canvasLayers.showOpacitySlider(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwiększa licznik operacji (wywoływane przy każdej operacji na canvas)
|
||||
* Zwiększa licznik operacji (dla garbage collection)
|
||||
*/
|
||||
incrementOperationCount() {
|
||||
if (this.imageReferenceManager) {
|
||||
this.imageReferenceManager.incrementOperationCount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ręczne uruchomienie garbage collection
|
||||
*/
|
||||
async runGarbageCollection() {
|
||||
if (this.imageReferenceManager) {
|
||||
await this.imageReferenceManager.manualGarbageCollection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca statystyki garbage collection
|
||||
*/
|
||||
getGarbageCollectionStats() {
|
||||
if (this.imageReferenceManager) {
|
||||
const stats = this.imageReferenceManager.getStats();
|
||||
return {
|
||||
...stats,
|
||||
operationCount: this.imageReferenceManager.operationCount,
|
||||
operationThreshold: this.imageReferenceManager.operationThreshold
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustawia próg operacji dla automatycznego GC
|
||||
*/
|
||||
setGarbageCollectionThreshold(threshold) {
|
||||
if (this.imageReferenceManager) {
|
||||
this.imageReferenceManager.setOperationThreshold(threshold);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści zasoby canvas (wywoływane przy usuwaniu)
|
||||
* Czyści zasoby canvas
|
||||
*/
|
||||
destroy() {
|
||||
if (this.imageReferenceManager) {
|
||||
@@ -461,4 +502,13 @@ export class Canvas {
|
||||
}
|
||||
log.info("Canvas destroyed");
|
||||
}
|
||||
/**
|
||||
* Powiadamia o zmianie stanu
|
||||
* @private
|
||||
*/
|
||||
_notifyStateChange() {
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
635
js/CanvasIO.js
635
js/CanvasIO.js
@@ -1,91 +1,70 @@
|
||||
import {createCanvas} from "./utils/CommonUtils.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import {webSocketManager} from "./utils/WebSocketManager.js";
|
||||
|
||||
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 {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this._saveInProgress = null;
|
||||
}
|
||||
|
||||
async saveToServer(fileName, outputMode = 'disk') {
|
||||
if (outputMode === 'disk') {
|
||||
if (!window.canvasSaveStates) {
|
||||
window.canvasSaveStates = new Map();
|
||||
}
|
||||
|
||||
const nodeId = this.canvas.node.id;
|
||||
const saveKey = `${nodeId}_${fileName}`;
|
||||
if (this._saveInProgress || window.canvasSaveStates.get(saveKey)) {
|
||||
log.warn(`Save already in progress for node ${nodeId}, waiting...`);
|
||||
return this._saveInProgress || window.canvasSaveStates.get(saveKey);
|
||||
}
|
||||
|
||||
log.info(`Starting saveToServer (disk) with fileName: ${fileName} for node: ${nodeId}`);
|
||||
this._saveInProgress = this._performSave(fileName, outputMode);
|
||||
window.canvasSaveStates.set(saveKey, this._saveInProgress);
|
||||
|
||||
try {
|
||||
return await this._saveInProgress;
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
this._saveInProgress = null;
|
||||
window.canvasSaveStates.delete(saveKey);
|
||||
log.debug(`Save completed for node ${nodeId}, lock released`);
|
||||
}
|
||||
} else {
|
||||
|
||||
}
|
||||
else {
|
||||
log.info(`Starting saveToServer (RAM) for node: ${this.canvas.node.id}`);
|
||||
return this._performSave(fileName, outputMode);
|
||||
}
|
||||
}
|
||||
|
||||
async _performSave(fileName, outputMode) {
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
await this.canvas.saveStateToDB(true);
|
||||
await this.canvas.canvasState.saveStateToDB();
|
||||
const nodeId = this.canvas.node.id;
|
||||
const delay = (nodeId % 10) * 50;
|
||||
if (delay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(this.canvas.width, this.canvas.height);
|
||||
const {canvas: maskCanvas, ctx: maskCtx} = createCanvas(this.canvas.width, this.canvas.height);
|
||||
|
||||
const visibilityCanvas = document.createElement('canvas');
|
||||
visibilityCanvas.width = this.canvas.width;
|
||||
visibilityCanvas.height = this.canvas.height;
|
||||
const visibilityCtx = visibilityCanvas.getContext('2d', {alpha: true});
|
||||
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 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");
|
||||
maskCtx.fillStyle = '#ffffff';
|
||||
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
log.debug(`Canvas contexts created, starting layer rendering`);
|
||||
const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
|
||||
log.debug(`Processing ${sortedLayers.length} layers in order`);
|
||||
sortedLayers.forEach((layer, index) => {
|
||||
log.debug(`Processing layer ${index}: zIndex=${layer.zIndex}, size=${layer.width}x${layer.height}, pos=(${layer.x},${layer.y})`);
|
||||
log.debug(`Layer ${index}: blendMode=${layer.blendMode || 'normal'}, opacity=${layer.opacity !== undefined ? layer.opacity : 1}`);
|
||||
|
||||
tempCtx.save();
|
||||
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
||||
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
||||
tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
tempCtx.restore();
|
||||
|
||||
log.debug(`Layer ${index} rendered successfully`);
|
||||
visibilityCtx.save();
|
||||
visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
||||
visibilityCtx.rotate(layer.rotation * Math.PI / 180);
|
||||
visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
visibilityCtx.restore();
|
||||
});
|
||||
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
|
||||
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
|
||||
log.debug(`Finished rendering layers`);
|
||||
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
||||
@@ -94,117 +73,96 @@ export class CanvasIO {
|
||||
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
|
||||
maskData.data[i + 3] = 255;
|
||||
}
|
||||
|
||||
maskCtx.putImageData(maskData, 0, 0);
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||
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');
|
||||
|
||||
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');
|
||||
const maskData = maskCanvas.toDataURL('image/png');
|
||||
log.info("Returning image and mask data as base64 for RAM mode.");
|
||||
resolve({image: imageData, mask: maskData});
|
||||
resolve({ image: imageData, mask: maskData });
|
||||
return;
|
||||
}
|
||||
|
||||
const fileNameWithoutMask = fileName.replace('.png', '_without_mask.png');
|
||||
log.info(`Saving image without mask as: ${fileNameWithoutMask}`);
|
||||
|
||||
tempCanvas.toBlob(async (blobWithoutMask) => {
|
||||
if (!blobWithoutMask)
|
||||
return;
|
||||
log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`);
|
||||
const formDataWithoutMask = new FormData();
|
||||
formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask);
|
||||
formDataWithoutMask.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const response = await fetch("/upload/image", {
|
||||
method: "POST",
|
||||
body: formDataWithoutMask,
|
||||
});
|
||||
log.debug(`Image without mask upload response: ${response.status}`);
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Error uploading image without mask:`, error);
|
||||
}
|
||||
}, "image/png");
|
||||
log.info(`Saving main image as: ${fileName}`);
|
||||
tempCanvas.toBlob(async (blob) => {
|
||||
if (!blob)
|
||||
return;
|
||||
log.debug(`Created blob for main image, size: ${blob.size} bytes`);
|
||||
const formData = new FormData();
|
||||
formData.append("image", blob, fileName);
|
||||
formData.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const resp = await fetch("/upload/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
log.debug(`Main image upload response: ${resp.status}`);
|
||||
|
||||
if (resp.status === 200) {
|
||||
const maskFileName = fileName.replace('.png', '_mask.png');
|
||||
log.info(`Saving mask as: ${maskFileName}`);
|
||||
|
||||
maskCanvas.toBlob(async (maskBlob) => {
|
||||
if (!maskBlob)
|
||||
return;
|
||||
log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`);
|
||||
const maskFormData = new FormData();
|
||||
maskFormData.append("image", maskBlob, maskFileName);
|
||||
maskFormData.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const maskResp = await fetch("/upload/image", {
|
||||
method: "POST",
|
||||
body: maskFormData,
|
||||
});
|
||||
log.debug(`Mask upload response: ${maskResp.status}`);
|
||||
|
||||
if (maskResp.status === 200) {
|
||||
const data = await resp.json();
|
||||
if (this.canvas.widget) {
|
||||
@@ -212,124 +170,61 @@ export class CanvasIO {
|
||||
}
|
||||
log.info(`All files saved successfully, widget value set to: ${fileName}`);
|
||||
resolve(true);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.error(`Error saving mask: ${maskResp.status}`);
|
||||
resolve(false);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Error saving mask:`, error);
|
||||
resolve(false);
|
||||
}
|
||||
}, "image/png");
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.error(`Main image upload failed: ${resp.status} - ${resp.statusText}`);
|
||||
resolve(false);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Error uploading main image:`, error);
|
||||
resolve(false);
|
||||
}
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
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});
|
||||
maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
|
||||
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach((layer) => {
|
||||
|
||||
tempCtx.save();
|
||||
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
||||
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
||||
tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
tempCtx.restore();
|
||||
|
||||
visibilityCtx.save();
|
||||
visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
||||
visibilityCtx.rotate(layer.rotation * Math.PI / 180);
|
||||
visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
visibilityCtx.restore();
|
||||
});
|
||||
|
||||
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const 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');
|
||||
|
||||
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.`);
|
||||
|
||||
const {image, mask} = await this._renderOutputData();
|
||||
|
||||
const { image, mask } = await this._renderOutputData();
|
||||
try {
|
||||
log.info(`Sending data for node ${nodeId}...`);
|
||||
await webSocketManager.sendMessage({
|
||||
@@ -338,205 +233,168 @@ export class CanvasIO {
|
||||
image: image,
|
||||
mask: mask,
|
||||
}, true); // `true` requires an acknowledgment
|
||||
|
||||
log.info(`Data for node ${nodeId} has been sent and acknowledged by the server.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Failed to send data for node ${nodeId}:`, error);
|
||||
|
||||
|
||||
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
|
||||
throw new Error(`Failed to get confirmation from server for node ${nodeId}. ` +
|
||||
`Make sure that the nodeId: (${nodeId}) matches the "node_id" value in the node options. If they don't match, you may need to manually set the node_id to ${nodeId}.` +
|
||||
`If the issue persists, try using a different browser. Some issues have been observed specifically with portable versions of Chrome, ` +
|
||||
`which may have limitations related to memory or WebSocket handling. Consider testing in a standard Chrome installation, Firefox, or another browser.`);
|
||||
}
|
||||
}
|
||||
|
||||
async addInputToCanvas(inputImage, inputMask) {
|
||||
try {
|
||||
log.debug("Adding input to canvas:", {inputImage});
|
||||
|
||||
const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(inputImage.width, inputImage.height);
|
||||
|
||||
const imgData = new ImageData(
|
||||
inputImage.data,
|
||||
inputImage.width,
|
||||
inputImage.height
|
||||
);
|
||||
log.debug("Adding input to canvas:", { inputImage });
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
|
||||
if (!tempCtx)
|
||||
throw new Error("Could not create temp context");
|
||||
const imgData = new ImageData(new Uint8ClampedArray(inputImage.data), inputImage.width, inputImage.height);
|
||||
tempCtx.putImageData(imgData, 0, 0);
|
||||
|
||||
const image = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = reject;
|
||||
image.src = tempCanvas.toDataURL();
|
||||
});
|
||||
|
||||
const scale = Math.min(
|
||||
this.canvas.width / inputImage.width * 0.8,
|
||||
this.canvas.height / inputImage.height * 0.8
|
||||
);
|
||||
|
||||
const layer = await this.canvas.addLayerWithImage(image, {
|
||||
x: (this.canvas.width - inputImage.width * scale) / 2,
|
||||
y: (this.canvas.height - inputImage.height * scale) / 2,
|
||||
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: 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,
|
||||
});
|
||||
|
||||
if (inputMask) {
|
||||
if (inputMask && layer) {
|
||||
layer.mask = inputMask.data;
|
||||
}
|
||||
|
||||
log.info("Layer added successfully");
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error in addInputToCanvas:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async convertTensorToImage(tensor) {
|
||||
try {
|
||||
log.debug("Converting tensor to image:", tensor);
|
||||
|
||||
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
|
||||
throw new Error("Invalid tensor data");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = tensor.width;
|
||||
canvas.height = tensor.height;
|
||||
|
||||
const imageData = new ImageData(
|
||||
new Uint8ClampedArray(tensor.data),
|
||||
tensor.width,
|
||||
tensor.height
|
||||
);
|
||||
|
||||
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true });
|
||||
if (!ctx)
|
||||
throw new Error("Could not create canvas context");
|
||||
const imageData = new ImageData(new Uint8ClampedArray(tensor.data), tensor.width, tensor.height);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error converting tensor to image:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async convertTensorToMask(tensor) {
|
||||
if (!tensor || !tensor.data) {
|
||||
throw new Error("Invalid mask tensor");
|
||||
}
|
||||
|
||||
try {
|
||||
return new Float32Array(tensor.data);
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Mask conversion failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async initNodeData() {
|
||||
try {
|
||||
log.info("Starting node data initialization...");
|
||||
|
||||
if (!this.canvas.node || !this.canvas.node.inputs) {
|
||||
log.debug("Node or inputs not ready");
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
|
||||
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||
const imageLinkId = this.canvas.node.inputs[0].link;
|
||||
const imageData = app.nodeOutputs[imageLinkId];
|
||||
|
||||
const imageData = window.app.nodeOutputs[imageLinkId];
|
||||
if (imageData) {
|
||||
log.debug("Found image data:", imageData);
|
||||
await this.processImageData(imageData);
|
||||
this.canvas.dataInitialized = true;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.debug("Image data not available yet");
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
||||
const maskLinkId = this.canvas.node.inputs[1].link;
|
||||
const maskData = app.nodeOutputs[maskLinkId];
|
||||
|
||||
const maskData = window.app.nodeOutputs[maskLinkId];
|
||||
if (maskData) {
|
||||
log.debug("Found mask data:", maskData);
|
||||
await this.processMaskData(maskData);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error in initNodeData:", error);
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
}
|
||||
|
||||
scheduleDataCheck() {
|
||||
if (this.canvas.pendingDataCheck) {
|
||||
clearTimeout(this.canvas.pendingDataCheck);
|
||||
}
|
||||
|
||||
this.canvas.pendingDataCheck = setTimeout(() => {
|
||||
this.canvas.pendingDataCheck = window.setTimeout(() => {
|
||||
this.canvas.pendingDataCheck = null;
|
||||
if (!this.canvas.dataInitialized) {
|
||||
this.initNodeData();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async processImageData(imageData) {
|
||||
try {
|
||||
if (!imageData) return;
|
||||
|
||||
if (!imageData)
|
||||
return;
|
||||
log.debug("Processing image data:", {
|
||||
type: typeof imageData,
|
||||
isArray: Array.isArray(imageData),
|
||||
shape: imageData.shape,
|
||||
hasData: !!imageData.data
|
||||
});
|
||||
|
||||
if (Array.isArray(imageData)) {
|
||||
imageData = imageData[0];
|
||||
}
|
||||
|
||||
if (!imageData.shape || !imageData.data) {
|
||||
throw new Error("Invalid image data format");
|
||||
}
|
||||
|
||||
const originalWidth = imageData.shape[2];
|
||||
const originalHeight = imageData.shape[1];
|
||||
|
||||
const scale = Math.min(
|
||||
this.canvas.width / originalWidth * 0.8,
|
||||
this.canvas.height / originalHeight * 0.8
|
||||
);
|
||||
|
||||
const scale = Math.min(this.canvas.width / originalWidth * 0.8, this.canvas.height / originalHeight * 0.8);
|
||||
const convertedData = this.convertTensorToImageData(imageData);
|
||||
if (convertedData) {
|
||||
const image = await this.createImageFromData(convertedData);
|
||||
|
||||
this.addScaledLayer(image, scale);
|
||||
log.info("Image layer added successfully with scale:", scale);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error processing image data:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
addScaledLayer(image, scale) {
|
||||
try {
|
||||
const scaledWidth = image.width * scale;
|
||||
const scaledHeight = image.height * scale;
|
||||
|
||||
const layer = {
|
||||
id: '', // This will be set in addLayerWithImage
|
||||
imageId: '', // This will be set in addLayerWithImage
|
||||
name: 'Layer',
|
||||
image: image,
|
||||
x: (this.canvas.width - scaledWidth) / 2,
|
||||
y: (this.canvas.height - scaledHeight) / 2,
|
||||
@@ -545,31 +403,31 @@ export class CanvasIO {
|
||||
rotation: 0,
|
||||
zIndex: this.canvas.layers.length,
|
||||
originalWidth: image.width,
|
||||
originalHeight: image.height
|
||||
originalHeight: image.height,
|
||||
blendMode: 'normal',
|
||||
opacity: 1,
|
||||
visible: true
|
||||
};
|
||||
|
||||
this.canvas.layers.push(layer);
|
||||
this.canvas.selectedLayer = layer;
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.canvas.render();
|
||||
|
||||
log.debug("Scaled layer added:", {
|
||||
originalSize: `${image.width}x${image.height}`,
|
||||
scaledSize: `${scaledWidth}x${scaledHeight}`,
|
||||
scale: scale
|
||||
});
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error adding scaled layer:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
convertTensorToImageData(tensor) {
|
||||
try {
|
||||
const shape = tensor.shape;
|
||||
const height = shape[1];
|
||||
const width = shape[2];
|
||||
const channels = shape[3];
|
||||
|
||||
log.debug("Converting tensor:", {
|
||||
shape: shape,
|
||||
dataRange: {
|
||||
@@ -577,164 +435,67 @@ export class CanvasIO {
|
||||
max: tensor.max_val
|
||||
}
|
||||
});
|
||||
|
||||
const imageData = new ImageData(width, height);
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
const flatData = tensor.data;
|
||||
const pixelCount = width * height;
|
||||
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
const tensorIndex = i * channels;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const value = flatData[tensorIndex + c];
|
||||
|
||||
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
|
||||
data[pixelIndex + c] = Math.round(normalizedValue * 255);
|
||||
}
|
||||
|
||||
data[pixelIndex + 3] = 255;
|
||||
}
|
||||
|
||||
imageData.data.set(data);
|
||||
return imageData;
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error converting tensor:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
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);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
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) return;
|
||||
|
||||
if (!maskData)
|
||||
return;
|
||||
log.debug("Processing mask data:", maskData);
|
||||
|
||||
if (Array.isArray(maskData)) {
|
||||
maskData = maskData[0];
|
||||
}
|
||||
|
||||
if (!maskData.shape || !maskData.data) {
|
||||
throw new Error("Invalid mask data format");
|
||||
}
|
||||
|
||||
if (this.canvas.selectedLayer) {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
const maskTensor = await this.convertTensorToMask(maskData);
|
||||
this.canvas.selectedLayer.mask = maskTensor;
|
||||
this.canvas.canvasSelection.selectedLayers[0].mask = maskTensor;
|
||||
this.canvas.render();
|
||||
log.info("Mask applied to selected layer");
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
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');
|
||||
|
||||
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');
|
||||
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 = {
|
||||
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,
|
||||
rotation: 0,
|
||||
zIndex: this.canvas.layers.length
|
||||
};
|
||||
|
||||
this.canvas.layers.push(layer);
|
||||
this.canvas.selectedLayer = 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...");
|
||||
const response = await fetch('/ycnode/get_latest_image');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.image_data) {
|
||||
log.info("Latest image received, adding to canvas.");
|
||||
const img = new Image();
|
||||
@@ -743,22 +504,88 @@ export class CanvasIO {
|
||||
img.onerror = reject;
|
||||
img.src = result.image_data;
|
||||
});
|
||||
|
||||
await this.canvas.addLayerWithImage(img, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
});
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit');
|
||||
log.info("Latest image imported and placed on canvas successfully.");
|
||||
return true;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
throw new Error(result.error || "Failed to fetch the latest image.");
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
async importLatestImages(sinceTimestamp, targetArea = null) {
|
||||
try {
|
||||
log.info(`Fetching latest images since ${sinceTimestamp}...`);
|
||||
const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`);
|
||||
const result = await response.json();
|
||||
if (result.success && result.images && result.images.length > 0) {
|
||||
log.info(`Received ${result.images.length} new images, adding to canvas.`);
|
||||
const newLayers = [];
|
||||
for (const imageData of result.images) {
|
||||
const img = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = imageData;
|
||||
});
|
||||
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.");
|
||||
return newLayers.filter(l => l !== null);
|
||||
}
|
||||
else if (result.success) {
|
||||
log.info("No new images found since last generation.");
|
||||
return [];
|
||||
}
|
||||
else {
|
||||
throw new Error(result.error || "Failed to fetch latest images.");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error importing latest images:", error);
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2042
js/CanvasLayers.js
2042
js/CanvasLayers.js
File diff suppressed because it is too large
Load Diff
481
js/CanvasLayersPanel.js
Normal file
481
js/CanvasLayersPanel.js
Normal file
@@ -0,0 +1,481 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
|
||||
const log = createModuleLogger('CanvasLayersPanel');
|
||||
export class CanvasLayersPanel {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.container = null;
|
||||
this.layersContainer = null;
|
||||
this.draggedElements = [];
|
||||
this.dragInsertionLine = null;
|
||||
this.isMultiSelecting = false;
|
||||
this.lastSelectedIndex = -1;
|
||||
this.handleLayerClick = this.handleLayerClick.bind(this);
|
||||
this.handleDragStart = this.handleDragStart.bind(this);
|
||||
this.handleDragOver = this.handleDragOver.bind(this);
|
||||
this.handleDragEnd = this.handleDragEnd.bind(this);
|
||||
this.handleDrop = this.handleDrop.bind(this);
|
||||
// Preload icons
|
||||
this.initializeIcons();
|
||||
// Load CSS for layers panel
|
||||
addStylesheet(getUrl('./css/layers_panel.css'));
|
||||
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.className = 'icon-container';
|
||||
iconContainer.style.width = `${size}px`;
|
||||
iconContainer.style.height = `${size}px`;
|
||||
const icon = iconLoader.getIcon(toolName);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode();
|
||||
img.style.width = `${size}px`;
|
||||
img.style.height = `${size}px`;
|
||||
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.classList.add('fallback-text');
|
||||
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
||||
iconContainer.style.fontSize = `${size * 0.6}px`;
|
||||
}
|
||||
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.className = 'icon-container visibility-hidden';
|
||||
iconContainer.style.width = '16px';
|
||||
iconContainer.style.height = '16px';
|
||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode();
|
||||
img.style.width = '16px';
|
||||
img.style.height = '16px';
|
||||
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.classList.add('fallback-text');
|
||||
iconContainer.textContent = 'H';
|
||||
iconContainer.style.fontSize = '10px';
|
||||
}
|
||||
return iconContainer;
|
||||
}
|
||||
}
|
||||
createPanelStructure() {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'layers-panel';
|
||||
this.container.tabIndex = 0; // Umożliwia fokus na panelu
|
||||
this.container.innerHTML = `
|
||||
<div class="layers-panel-header">
|
||||
<span class="layers-panel-title">Layers</span>
|
||||
<div class="layers-panel-controls">
|
||||
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layers-container" id="layers-container">
|
||||
<!-- Lista warstw będzie renderowana tutaj -->
|
||||
</div>
|
||||
`;
|
||||
this.layersContainer = this.container.querySelector('#layers-container');
|
||||
// Setup event listeners dla przycisków
|
||||
this.setupControlButtons();
|
||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||
this.container.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.deleteSelectedLayers();
|
||||
}
|
||||
});
|
||||
log.debug('Panel structure created');
|
||||
return this.container;
|
||||
}
|
||||
setupControlButtons() {
|
||||
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();
|
||||
});
|
||||
// Initial button state update
|
||||
this.updateButtonStates();
|
||||
}
|
||||
renderLayers() {
|
||||
if (!this.layersContainer) {
|
||||
log.warn('Layers container not initialized');
|
||||
return;
|
||||
}
|
||||
// Wyczyść istniejącą zawartość
|
||||
this.layersContainer.innerHTML = '';
|
||||
// Usuń linię wstawiania jeśli istnieje
|
||||
this.removeDragInsertionLine();
|
||||
// Sortuj warstwy według zIndex (od najwyższej do najniższej)
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||
sortedLayers.forEach((layer, index) => {
|
||||
const layerElement = this.createLayerElement(layer, index);
|
||||
if (this.layersContainer)
|
||||
this.layersContainer.appendChild(layerElement);
|
||||
});
|
||||
log.debug(`Rendered ${sortedLayers.length} layers`);
|
||||
}
|
||||
createLayerElement(layer, index) {
|
||||
const layerRow = document.createElement('div');
|
||||
layerRow.className = 'layer-row';
|
||||
layerRow.draggable = true;
|
||||
layerRow.dataset.layerIndex = String(index);
|
||||
const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
|
||||
if (isSelected) {
|
||||
layerRow.classList.add('selected');
|
||||
}
|
||||
// Ustawienie domyślnych właściwości jeśli nie istnieją
|
||||
if (!layer.name) {
|
||||
layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer);
|
||||
}
|
||||
else {
|
||||
// Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu)
|
||||
layer.name = this.ensureUniqueName(layer.name, layer);
|
||||
}
|
||||
layerRow.innerHTML = `
|
||||
<div class="layer-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);
|
||||
}
|
||||
this.setupLayerEventListeners(layerRow, layer, index);
|
||||
return layerRow;
|
||||
}
|
||||
generateThumbnail(layer, thumbnailContainer) {
|
||||
if (!layer.image) {
|
||||
thumbnailContainer.style.background = '#4a4a4a';
|
||||
return;
|
||||
}
|
||||
const { canvas, ctx } = createCanvas(48, 48, '2d', { willReadFrequently: true });
|
||||
if (!ctx)
|
||||
return;
|
||||
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
|
||||
const scaledWidth = layer.image.width * scale;
|
||||
const scaledHeight = layer.image.height * scale;
|
||||
// Wycentruj obraz
|
||||
const x = (48 - scaledWidth) / 2;
|
||||
const y = (48 - scaledHeight) / 2;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight);
|
||||
thumbnailContainer.appendChild(canvas);
|
||||
}
|
||||
setupLayerEventListeners(layerRow, layer, index) {
|
||||
layerRow.addEventListener('mousedown', (e) => {
|
||||
const nameElement = layerRow.querySelector('.layer-name');
|
||||
if (nameElement && nameElement.classList.contains('editing')) {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
this.updateButtonStates();
|
||||
}
|
||||
});
|
||||
layerRow.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const nameElement = layerRow.querySelector('.layer-name');
|
||||
if (nameElement) {
|
||||
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));
|
||||
layerRow.addEventListener('drop', (e) => this.handleDrop(e, index));
|
||||
}
|
||||
handleLayerClick(e, layer, index) {
|
||||
const isCtrlPressed = e.ctrlKey || e.metaKey;
|
||||
const isShiftPressed = e.shiftKey;
|
||||
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
|
||||
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
|
||||
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||
this.updateSelectionAppearance();
|
||||
this.updateButtonStates();
|
||||
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||
}
|
||||
startEditingLayerName(nameElement, layer) {
|
||||
const currentName = layer.name;
|
||||
nameElement.classList.add('editing');
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = currentName;
|
||||
input.style.width = '100%';
|
||||
nameElement.innerHTML = '';
|
||||
nameElement.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
const finishEditing = () => {
|
||||
let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`;
|
||||
newName = this.ensureUniqueName(newName, layer);
|
||||
layer.name = newName;
|
||||
nameElement.classList.remove('editing');
|
||||
nameElement.textContent = newName;
|
||||
this.canvas.saveState();
|
||||
log.info(`Layer renamed to: ${newName}`);
|
||||
};
|
||||
input.addEventListener('blur', finishEditing);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
finishEditing();
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
nameElement.classList.remove('editing');
|
||||
nameElement.textContent = currentName;
|
||||
}
|
||||
});
|
||||
}
|
||||
ensureUniqueName(proposedName, currentLayer) {
|
||||
const existingNames = this.canvas.layers
|
||||
.filter((layer) => layer !== currentLayer)
|
||||
.map((layer) => layer.name);
|
||||
if (!existingNames.includes(proposedName)) {
|
||||
return proposedName;
|
||||
}
|
||||
// Sprawdź czy nazwa już ma numerację w nawiasach
|
||||
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
|
||||
let baseName, startNumber;
|
||||
if (match) {
|
||||
baseName = match[1].trim();
|
||||
startNumber = parseInt(match[2]) + 1;
|
||||
}
|
||||
else {
|
||||
baseName = proposedName;
|
||||
startNumber = 1;
|
||||
}
|
||||
// Znajdź pierwszą dostępną numerację
|
||||
let counter = startNumber;
|
||||
let uniqueName;
|
||||
do {
|
||||
uniqueName = `${baseName} (${counter})`;
|
||||
counter++;
|
||||
} while (existingNames.includes(uniqueName));
|
||||
return uniqueName;
|
||||
}
|
||||
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');
|
||||
return;
|
||||
}
|
||||
log.info(`Deleting ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
|
||||
this.canvas.removeSelectedLayers();
|
||||
this.renderLayers();
|
||||
}
|
||||
handleDragStart(e, layer, index) {
|
||||
if (!this.layersContainer || !e.dataTransfer)
|
||||
return;
|
||||
const editingElement = this.layersContainer.querySelector('.layer-name.editing');
|
||||
if (editingElement) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją
|
||||
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.renderLayers();
|
||||
}
|
||||
this.draggedElements = [...this.canvas.canvasSelection.selectedLayers];
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', '');
|
||||
this.layersContainer.querySelectorAll('.layer-row').forEach((row, idx) => {
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||
if (this.draggedElements.includes(sortedLayers[idx])) {
|
||||
row.classList.add('dragging');
|
||||
}
|
||||
});
|
||||
log.debug(`Started dragging ${this.draggedElements.length} layers`);
|
||||
}
|
||||
handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer)
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const layerRow = e.currentTarget;
|
||||
const rect = layerRow.getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
const isUpperHalf = e.clientY < midpoint;
|
||||
this.showDragInsertionLine(layerRow, isUpperHalf);
|
||||
}
|
||||
showDragInsertionLine(targetRow, isUpperHalf) {
|
||||
this.removeDragInsertionLine();
|
||||
const line = document.createElement('div');
|
||||
line.className = 'drag-insertion-line';
|
||||
if (isUpperHalf) {
|
||||
line.style.top = '-1px';
|
||||
}
|
||||
else {
|
||||
line.style.bottom = '-1px';
|
||||
}
|
||||
targetRow.style.position = 'relative';
|
||||
targetRow.appendChild(line);
|
||||
this.dragInsertionLine = line;
|
||||
}
|
||||
removeDragInsertionLine() {
|
||||
if (this.dragInsertionLine) {
|
||||
this.dragInsertionLine.remove();
|
||||
this.dragInsertionLine = null;
|
||||
}
|
||||
}
|
||||
handleDrop(e, targetIndex) {
|
||||
e.preventDefault();
|
||||
this.removeDragInsertionLine();
|
||||
if (this.draggedElements.length === 0 || !(e.currentTarget instanceof HTMLElement))
|
||||
return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
const isUpperHalf = e.clientY < midpoint;
|
||||
// Oblicz docelowy indeks
|
||||
let insertIndex = targetIndex;
|
||||
if (!isUpperHalf) {
|
||||
insertIndex = targetIndex + 1;
|
||||
}
|
||||
// Użyj nowej, centralnej funkcji do przesuwania warstw
|
||||
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
|
||||
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
|
||||
}
|
||||
handleDragEnd(e) {
|
||||
this.removeDragInsertionLine();
|
||||
if (!this.layersContainer)
|
||||
return;
|
||||
this.layersContainer.querySelectorAll('.layer-row').forEach((row) => {
|
||||
row.classList.remove('dragging');
|
||||
});
|
||||
this.draggedElements = [];
|
||||
}
|
||||
onLayersChanged() {
|
||||
this.renderLayers();
|
||||
}
|
||||
updateSelectionAppearance() {
|
||||
if (!this.layersContainer)
|
||||
return;
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||
const layerRows = this.layersContainer.querySelectorAll('.layer-row');
|
||||
layerRows.forEach((row, index) => {
|
||||
const layer = sortedLayers[index];
|
||||
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
row.classList.add('selected');
|
||||
}
|
||||
else {
|
||||
row.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Aktualizuje stan przycisków w zależności od zaznaczenia warstw
|
||||
*/
|
||||
updateButtonStates() {
|
||||
if (!this.container)
|
||||
return;
|
||||
const deleteBtn = this.container.querySelector('#delete-layer-btn');
|
||||
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = !hasSelectedLayers;
|
||||
deleteBtn.title = hasSelectedLayers
|
||||
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
|
||||
: 'No layers selected';
|
||||
}
|
||||
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
/**
|
||||
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
||||
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
||||
*/
|
||||
onSelectionChanged() {
|
||||
this.updateSelectionAppearance();
|
||||
this.updateButtonStates();
|
||||
}
|
||||
destroy() {
|
||||
if (this.container && this.container.parentNode) {
|
||||
this.container.parentNode.removeChild(this.container);
|
||||
}
|
||||
this.container = null;
|
||||
this.layersContainer = null;
|
||||
this.draggedElements = [];
|
||||
this.removeDragInsertionLine();
|
||||
log.info('CanvasLayersPanel destroyed');
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('CanvasRenderer');
|
||||
|
||||
export class CanvasRenderer {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
@@ -10,7 +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;
|
||||
@@ -23,16 +71,15 @@ export class CanvasRenderer {
|
||||
this.actualRender();
|
||||
this.isDirty = false;
|
||||
}
|
||||
|
||||
if (this.isDirty) {
|
||||
this.renderAnimationFrame = null;
|
||||
this.render();
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this.renderAnimationFrame = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
actualRender() {
|
||||
if (this.canvas.offscreenCanvas.width !== this.canvas.canvas.clientWidth ||
|
||||
this.canvas.offscreenCanvas.height !== this.canvas.canvas.clientHeight) {
|
||||
@@ -41,151 +88,121 @@ export class CanvasRenderer {
|
||||
this.canvas.offscreenCanvas.width = newWidth;
|
||||
this.canvas.offscreenCanvas.height = newHeight;
|
||||
}
|
||||
|
||||
const ctx = this.canvas.offscreenCtx;
|
||||
|
||||
ctx.fillStyle = '#606060';
|
||||
ctx.fillRect(0, 0, this.canvas.offscreenCanvas.width, this.canvas.offscreenCanvas.height);
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
|
||||
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
|
||||
|
||||
this.drawGrid(ctx);
|
||||
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach(layer => {
|
||||
if (!layer.image) return;
|
||||
ctx.save();
|
||||
const currentTransform = ctx.getTransform();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.setTransform(currentTransform);
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(layer.rotation * Math.PI / 180);
|
||||
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.selectedLayers.includes(layer)) {
|
||||
this.drawSelectionFrame(ctx, layer);
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
this.drawCanvasOutline(ctx);
|
||||
// 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) {
|
||||
|
||||
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||
ctx.save();
|
||||
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 0.5;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
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) {
|
||||
this.canvas.canvas.width = this.canvas.offscreenCanvas.width;
|
||||
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
||||
}
|
||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
||||
// Update Batch Preview UI positions
|
||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||
this.canvas.batchPreviewManagers.forEach((manager) => {
|
||||
manager.updateScreenPosition(this.canvas.viewport);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderInteractionElements(ctx) {
|
||||
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.selectedLayer) {
|
||||
this.canvas.selectedLayers.forEach(layer => {
|
||||
if (!layer.image) return;
|
||||
|
||||
if (this.canvas.canvasSelection.selectedLayer) {
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||
if (!layer.image || !layer.visible)
|
||||
return;
|
||||
const layerIndex = this.canvas.layers.indexOf(layer);
|
||||
const currentWidth = Math.round(layer.width);
|
||||
const currentHeight = Math.round(layer.height);
|
||||
@@ -199,15 +216,13 @@ export class CanvasRenderer {
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
|
||||
const localCorners = [
|
||||
{x: -halfW, y: -halfH},
|
||||
{x: halfW, y: -halfH},
|
||||
{x: halfW, y: halfH},
|
||||
{x: -halfW, y: halfH}
|
||||
{ x: -halfW, y: -halfH },
|
||||
{ x: halfW, y: -halfH },
|
||||
{ x: halfW, y: halfH },
|
||||
{ x: -halfW, y: halfH }
|
||||
];
|
||||
const worldCorners = localCorners.map(p => ({
|
||||
x: centerX + p.x * cos - p.y * sin,
|
||||
@@ -222,103 +237,350 @@ 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drawGrid(ctx) {
|
||||
const gridSize = 64;
|
||||
const lineWidth = 0.5 / this.canvas.viewport.zoom;
|
||||
|
||||
const viewLeft = this.canvas.viewport.x;
|
||||
const viewTop = this.canvas.viewport.y;
|
||||
const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom;
|
||||
const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.viewport.zoom;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#707070';
|
||||
ctx.lineWidth = lineWidth;
|
||||
|
||||
for (let x = Math.floor(viewLeft / gridSize) * gridSize; x < viewRight; x += gridSize) {
|
||||
ctx.moveTo(x, viewTop);
|
||||
ctx.lineTo(x, viewBottom);
|
||||
}
|
||||
|
||||
for (let y = Math.floor(viewTop / gridSize) * gridSize; y < viewBottom; y += gridSize) {
|
||||
ctx.moveTo(viewLeft, y);
|
||||
ctx.lineTo(viewRight, y);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -layer.height / 2);
|
||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
||||
ctx.stroke();
|
||||
const handles = this.canvas.getHandles(layer);
|
||||
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
|
||||
// --- CROP MODE ---
|
||||
ctx.lineWidth = lineWidth;
|
||||
// 1. Draw dashed blue line for the full transform frame (the "original size" container)
|
||||
ctx.strokeStyle = '#007bff';
|
||||
ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
|
||||
ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
ctx.setLineDash([]);
|
||||
// 2. Draw solid blue line for the crop bounds
|
||||
const layerScaleX = layer.width / layer.originalWidth;
|
||||
const layerScaleY = layer.height / layer.originalHeight;
|
||||
const s = layer.cropBounds;
|
||||
const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
|
||||
const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
|
||||
const cropRectW = s.width * layerScaleX;
|
||||
const cropRectH = s.height * layerScaleY;
|
||||
ctx.strokeStyle = '#007bff'; // Solid blue
|
||||
this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
|
||||
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
|
||||
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY + cropRectH, cropRectX, cropRectY + cropRectH, layer); // Bottom
|
||||
this.drawAdaptiveLine(ctx, cropRectX, cropRectY + cropRectH, cropRectX, cropRectY, layer); // Left
|
||||
}
|
||||
else {
|
||||
// --- TRANSFORM MODE ---
|
||||
ctx.strokeStyle = '#00ff00'; // Green
|
||||
ctx.lineWidth = lineWidth;
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
// Draw adaptive solid green line for transform frame
|
||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
||||
// Draw line to rotation handle
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
const startY = layer.flipV ? halfH : -halfH;
|
||||
const endY = startY + (layer.flipV ? 1 : -1) * (20 / this.canvas.viewport.zoom);
|
||||
ctx.moveTo(0, startY);
|
||||
ctx.lineTo(0, endY);
|
||||
ctx.stroke();
|
||||
}
|
||||
// --- DRAW HANDLES (Unified Logic) ---
|
||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
for (const key in handles) {
|
||||
// Skip rotation handle in crop mode
|
||||
if (layer.cropMode && key === 'rot')
|
||||
continue;
|
||||
const point = handles[key];
|
||||
ctx.beginPath();
|
||||
const localX = point.x - (layer.x + layer.width / 2);
|
||||
const localY = point.y - (layer.y + layer.height / 2);
|
||||
|
||||
// The handle position is already in world space.
|
||||
// We need to convert it to the layer's local, un-rotated space.
|
||||
const dx = point.x - centerX;
|
||||
const dy = point.y - centerY;
|
||||
// "Un-rotate" the position to get it in the layer's local, un-rotated space
|
||||
const rad = -layer.rotation * Math.PI / 180;
|
||||
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
||||
|
||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const localX = dx * cos - dy * sin;
|
||||
const localY = dx * sin + dy * cos;
|
||||
// The context is already flipped. We need to flip the coordinates
|
||||
// to match the visual transformation, so the arc is drawn in the correct place.
|
||||
const finalX = localX * (layer.flipH ? -1 : 1);
|
||||
const finalY = localY * (layer.flipV ? -1 : 1);
|
||||
ctx.beginPath();
|
||||
ctx.arc(finalX, finalY, handleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
drawOutputAreaExtensionPreview(ctx) {
|
||||
if (!this.canvas.outputAreaExtensionPreview) {
|
||||
return;
|
||||
}
|
||||
// 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
148
js/CanvasSelection.js
Normal file
148
js/CanvasSelection.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { generateUUID } from "./utils/CommonUtils.js";
|
||||
const log = createModuleLogger('CanvasSelection');
|
||||
export class CanvasSelection {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.selectedLayers = [];
|
||||
this.selectedLayer = null;
|
||||
this.onSelectionChange = null;
|
||||
}
|
||||
/**
|
||||
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
|
||||
*/
|
||||
duplicateSelectedLayers() {
|
||||
if (this.selectedLayers.length === 0)
|
||||
return [];
|
||||
const newLayers = [];
|
||||
const sortedLayers = [...this.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach(layer => {
|
||||
const newLayer = {
|
||||
...layer,
|
||||
id: generateUUID(),
|
||||
zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu
|
||||
};
|
||||
this.canvas.layers.push(newLayer);
|
||||
newLayers.push(newLayer);
|
||||
});
|
||||
// Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego)
|
||||
this.updateSelection(newLayers);
|
||||
// Powiadom panel o zmianie struktury, aby się przerysował
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
log.info(`Duplicated ${newLayers.length} layers (in-memory).`);
|
||||
return newLayers;
|
||||
}
|
||||
/**
|
||||
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
|
||||
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
|
||||
* @param {Array} newSelection - Nowa lista zaznaczonych warstw
|
||||
*/
|
||||
updateSelection(newSelection) {
|
||||
const previousSelection = this.selectedLayers.length;
|
||||
// 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 ||
|
||||
this.selectedLayers.some((layer, i) => this.selectedLayers[i] !== (newSelection || [])[i]);
|
||||
if (!hasChanged && previousSelection > 0) {
|
||||
// return; // Zablokowane na razie, może powodować problemy
|
||||
}
|
||||
log.debug('Selection updated', {
|
||||
previousCount: previousSelection,
|
||||
newCount: this.selectedLayers.length,
|
||||
selectedLayerIds: this.selectedLayers.map((l) => l.id || 'unknown')
|
||||
});
|
||||
// 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji
|
||||
this.canvas.render();
|
||||
// 2. Powiadom inne części aplikacji (jeśli są)
|
||||
if (this.onSelectionChange) {
|
||||
this.onSelectionChange();
|
||||
}
|
||||
// 3. Powiadom panel warstw, aby zaktualizował swój wygląd
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
|
||||
*/
|
||||
updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) {
|
||||
let newSelection = [...this.selectedLayers];
|
||||
let selectionChanged = false;
|
||||
if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) {
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||
const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
|
||||
const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
|
||||
newSelection = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (sortedLayers[i]) {
|
||||
newSelection.push(sortedLayers[i]);
|
||||
}
|
||||
}
|
||||
selectionChanged = true;
|
||||
}
|
||||
else if (isCtrlPressed) {
|
||||
const layerIndex = newSelection.indexOf(layer);
|
||||
if (layerIndex === -1) {
|
||||
newSelection.push(layer);
|
||||
}
|
||||
else {
|
||||
newSelection.splice(layerIndex, 1);
|
||||
}
|
||||
this.canvas.canvasLayersPanel.lastSelectedIndex = index;
|
||||
selectionChanged = true;
|
||||
}
|
||||
else {
|
||||
// Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia,
|
||||
// wyczyść zaznaczenie i zaznacz tylko ją.
|
||||
if (!this.selectedLayers.includes(layer)) {
|
||||
newSelection = [layer];
|
||||
selectionChanged = true;
|
||||
}
|
||||
// Jeśli kliknięta warstwa JEST już zaznaczona (potencjalnie z innymi),
|
||||
// NIE rób nic, aby umożliwić przeciąganie całej grupy.
|
||||
this.canvas.canvasLayersPanel.lastSelectedIndex = index;
|
||||
}
|
||||
// Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło
|
||||
if (selectionChanged) {
|
||||
this.updateSelection(newSelection);
|
||||
}
|
||||
}
|
||||
removeSelectedLayers() {
|
||||
if (this.selectedLayers.length > 0) {
|
||||
log.info('Removing selected layers', {
|
||||
layersToRemove: this.selectedLayers.length,
|
||||
totalLayers: this.canvas.layers.length
|
||||
});
|
||||
this.canvas.saveState();
|
||||
this.canvas.layers = this.canvas.layers.filter((l) => !this.selectedLayers.includes(l));
|
||||
this.updateSelection([]);
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length);
|
||||
}
|
||||
else {
|
||||
log.debug('No layers selected for removal');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Aktualizuje zaznaczenie po operacji historii
|
||||
*/
|
||||
updateSelectionAfterHistory() {
|
||||
const newSelectedLayers = [];
|
||||
if (this.selectedLayers) {
|
||||
this.selectedLayers.forEach((sl) => {
|
||||
const found = this.canvas.layers.find((l) => l.id === sl.id);
|
||||
if (found)
|
||||
newSelectedLayers.push(found);
|
||||
});
|
||||
}
|
||||
this.updateSelection(newSelectedLayers);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import {getCanvasState, setCanvasState, saveImage, getImage} from "./db.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import {generateUUID, cloneLayers, getStateSignature, debounce} from "./utils/CommonUtils.js";
|
||||
import {withErrorHandling} from "./ErrorHandler.js";
|
||||
|
||||
import { getCanvasState, setCanvasState, saveImage, getImage } from "./db.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.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) {
|
||||
this.canvas = canvas;
|
||||
@@ -16,366 +14,422 @@ export class CanvasState {
|
||||
this.saveTimeout = null;
|
||||
this.lastSavedStateSignature = null;
|
||||
this._loadInProgress = null;
|
||||
this._debouncedSave = null;
|
||||
try {
|
||||
// @ts-ignore
|
||||
this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' });
|
||||
log.info("State saver worker initialized successfully.");
|
||||
this.stateSaverWorker.onmessage = (e) => {
|
||||
log.info("Message from state saver worker:", e.data);
|
||||
};
|
||||
this.stateSaverWorker.onerror = (e) => {
|
||||
log.error("Error in state saver worker:", e.message, e.filename, e.lineno);
|
||||
this.stateSaverWorker = null;
|
||||
};
|
||||
}
|
||||
catch (e) {
|
||||
log.error("Failed to initialize state saver worker:", e);
|
||||
this.stateSaverWorker = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async loadStateFromDB() {
|
||||
if (this._loadInProgress) {
|
||||
log.warn("Load already in progress, waiting...");
|
||||
return this._loadInProgress;
|
||||
}
|
||||
|
||||
log.info("Attempting to load state from IndexedDB for node:", this.canvas.node.id);
|
||||
if (!this.canvas.node.id) {
|
||||
log.error("Node ID is not available for loading state from DB.");
|
||||
return false;
|
||||
}
|
||||
|
||||
this._loadInProgress = this._performLoad();
|
||||
|
||||
const loadPromise = this._performLoad();
|
||||
this._loadInProgress = loadPromise;
|
||||
try {
|
||||
const result = await this._loadInProgress;
|
||||
return result;
|
||||
} finally {
|
||||
const result = await loadPromise;
|
||||
this._loadInProgress = null;
|
||||
return result;
|
||||
}
|
||||
catch (error) {
|
||||
this._loadInProgress = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
_performLoad = withErrorHandling(async () => {
|
||||
const savedState = await getCanvasState(this.canvas.node.id);
|
||||
if (!savedState) {
|
||||
log.info("No saved state found in IndexedDB for node:", this.canvas.node.id);
|
||||
async _performLoad() {
|
||||
try {
|
||||
if (!this.canvas.node.id) {
|
||||
log.error("Node ID is not available for loading state from DB.");
|
||||
return false;
|
||||
}
|
||||
const savedState = await getCanvasState(String(this.canvas.node.id));
|
||||
if (!savedState) {
|
||||
log.info("No saved state found in IndexedDB for node:", this.canvas.node.id);
|
||||
return false;
|
||||
}
|
||||
log.info("Found saved state in IndexedDB.");
|
||||
this.canvas.width = savedState.width || 512;
|
||||
this.canvas.height = savedState.height || 512;
|
||||
this.canvas.viewport = savedState.viewport || {
|
||||
x: -(this.canvas.width / 4),
|
||||
y: -(this.canvas.height / 4),
|
||||
zoom: 0.8
|
||||
};
|
||||
// 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);
|
||||
this.canvas.layers = loadedLayers.filter((l) => l !== null);
|
||||
log.info(`Loaded ${this.canvas.layers.length} layers.`);
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn("No valid layers loaded, state may be corrupted.");
|
||||
return false;
|
||||
}
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id);
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error during state load:", error);
|
||||
return false;
|
||||
}
|
||||
log.info("Found saved state in IndexedDB.");
|
||||
this.canvas.width = savedState.width || 512;
|
||||
this.canvas.height = savedState.height || 512;
|
||||
this.canvas.viewport = savedState.viewport || {
|
||||
x: -(this.canvas.width / 4),
|
||||
y: -(this.canvas.height / 4),
|
||||
zoom: 0.8
|
||||
};
|
||||
|
||||
this.canvas.updateOutputAreaSize(this.canvas.width, this.canvas.height, false);
|
||||
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
|
||||
const loadedLayers = await this._loadLayers(savedState.layers);
|
||||
this.canvas.layers = loadedLayers.filter(l => l !== null);
|
||||
log.info(`Loaded ${this.canvas.layers.length} layers.`);
|
||||
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn("No valid layers loaded, state may be corrupted.");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id);
|
||||
return true;
|
||||
}, 'CanvasState._performLoad');
|
||||
|
||||
}
|
||||
/**
|
||||
* Ładuje warstwy z zapisanego stanu
|
||||
* @param {Array} layersData - Dane warstw do załadowania
|
||||
* @returns {Promise<Array>} Załadowane warstwy
|
||||
* @param {any[]} layersData - Dane warstw do załadowania
|
||||
* @returns {Promise<(Layer | null)[]>} Załadowane warstwy
|
||||
*/
|
||||
async _loadLayers(layersData) {
|
||||
const imagePromises = layersData.map((layerData, index) =>
|
||||
this._loadSingleLayer(layerData, index)
|
||||
);
|
||||
const imagePromises = layersData.map((layerData, index) => this._loadSingleLayer(layerData, index));
|
||||
return Promise.all(imagePromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje pojedynczą warstwę
|
||||
* @param {Object} layerData - Dane warstwy
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @returns {Promise<Object|null>} Załadowana warstwa lub null
|
||||
* @returns {Promise<Layer | null>} Załadowana warstwa lub null
|
||||
*/
|
||||
async _loadSingleLayer(layerData, index) {
|
||||
return new Promise((resolve) => {
|
||||
if (layerData.imageId) {
|
||||
this._loadLayerFromImageId(layerData, index, resolve);
|
||||
} else if (layerData.imageSrc) {
|
||||
}
|
||||
else if (layerData.imageSrc) {
|
||||
this._convertLegacyLayer(layerData, index, resolve);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.error(`Layer ${index}: No imageId or imageSrc found, skipping layer.`);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje warstwę z imageId
|
||||
* @param {Object} layerData - Dane warstwy
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @param {Function} resolve - Funkcja resolve
|
||||
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
||||
*/
|
||||
_loadLayerFromImageId(layerData, index, resolve) {
|
||||
log.debug(`Layer ${index}: Loading image with id: ${layerData.imageId}`);
|
||||
|
||||
if (this.canvas.imageCache.has(layerData.imageId)) {
|
||||
log.debug(`Layer ${index}: Image found in cache.`);
|
||||
const imageSrc = this.canvas.imageCache.get(layerData.imageId);
|
||||
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
||||
} else {
|
||||
const imageData = this.canvas.imageCache.get(layerData.imageId);
|
||||
if (imageData) {
|
||||
const imageSrc = URL.createObjectURL(new Blob([imageData.data]));
|
||||
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
||||
}
|
||||
else {
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
else {
|
||||
getImage(layerData.imageId)
|
||||
.then(imageSrc => {
|
||||
if (imageSrc) {
|
||||
log.debug(`Layer ${index}: Loading image from data:URL...`);
|
||||
this.canvas.imageCache.set(layerData.imageId, imageSrc);
|
||||
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
||||
} else {
|
||||
log.error(`Layer ${index}: Image not found in IndexedDB.`);
|
||||
resolve(null);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
log.error(`Layer ${index}: Error loading image from IndexedDB:`, err);
|
||||
if (imageSrc) {
|
||||
log.debug(`Layer ${index}: Loading image from data:URL...`);
|
||||
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
||||
}
|
||||
else {
|
||||
log.error(`Layer ${index}: Image not found in IndexedDB.`);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
log.error(`Layer ${index}: Error loading image from IndexedDB:`, err);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje starą warstwę z imageSrc na nowy format
|
||||
* @param {Object} layerData - Dane warstwy
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @param {Function} resolve - Funkcja resolve
|
||||
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
||||
*/
|
||||
_convertLegacyLayer(layerData, index, resolve) {
|
||||
log.info(`Layer ${index}: Found imageSrc, converting to new format with imageId.`);
|
||||
const imageId = generateUUID();
|
||||
|
||||
saveImage(imageId, layerData.imageSrc)
|
||||
.then(() => {
|
||||
log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`);
|
||||
this.canvas.imageCache.set(imageId, layerData.imageSrc);
|
||||
const newLayerData = {...layerData, imageId};
|
||||
delete newLayerData.imageSrc;
|
||||
this._createLayerFromSrc(newLayerData, layerData.imageSrc, index, resolve);
|
||||
})
|
||||
log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`);
|
||||
const newLayerData = { ...layerData, imageId };
|
||||
delete newLayerData.imageSrc;
|
||||
this._createLayerFromSrc(newLayerData, layerData.imageSrc, index, resolve);
|
||||
})
|
||||
.catch(err => {
|
||||
log.error(`Layer ${index}: Error saving image to IndexedDB:`, err);
|
||||
resolve(null);
|
||||
});
|
||||
log.error(`Layer ${index}: Error saving image to IndexedDB:`, err);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy warstwę z src obrazu
|
||||
* @param {Object} layerData - Dane warstwy
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {string} imageSrc - Źródło obrazu
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @param {Function} resolve - Funkcja resolve
|
||||
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
||||
*/
|
||||
_createLayerFromSrc(layerData, imageSrc, index, resolve) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully.`);
|
||||
const newLayer = {...layerData, image: img};
|
||||
delete newLayer.imageId;
|
||||
resolve(newLayer);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.error(`Layer ${index}: Failed to load image from src.`);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = imageSrc;
|
||||
if (typeof imageSrc === 'string') {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully.`);
|
||||
const newLayer = { ...layerData, image: img };
|
||||
resolve(newLayer);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.error(`Layer ${index}: Failed to load image from src.`);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}
|
||||
else {
|
||||
const { canvas, ctx } = createCanvas(imageSrc.width, imageSrc.height);
|
||||
if (ctx) {
|
||||
ctx.drawImage(imageSrc, 0, 0);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
||||
const newLayer = { ...layerData, image: img };
|
||||
resolve(newLayer);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.error(`Layer ${index}: Failed to load image from ImageBitmap.`);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = canvas.toDataURL();
|
||||
}
|
||||
else {
|
||||
log.error(`Layer ${index}: Failed to get 2d context from canvas.`);
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveStateToDB(immediate = false) {
|
||||
log.info("Preparing to save state to IndexedDB for node:", this.canvas.node.id);
|
||||
async saveStateToDB() {
|
||||
if (!this.canvas.node.id) {
|
||||
log.error("Node ID is not available for saving state to DB.");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStateSignature = getStateSignature(this.canvas.layers);
|
||||
if (this.lastSavedStateSignature === currentStateSignature) {
|
||||
log.debug("State unchanged, skipping save to IndexedDB.");
|
||||
// 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 = {
|
||||
layers: layers.filter(layer => layer !== null),
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
if (this.stateSaverWorker) {
|
||||
log.info("Posting state to worker for background saving.");
|
||||
this.stateSaverWorker.postMessage({
|
||||
nodeId: String(this.canvas.node.id),
|
||||
state: state
|
||||
});
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
const saveFunction = withErrorHandling(async () => {
|
||||
const state = {
|
||||
layers: await this._prepareLayers(),
|
||||
viewport: this.canvas.viewport,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
};
|
||||
|
||||
state.layers = state.layers.filter(layer => layer !== null);
|
||||
if (state.layers.length === 0) {
|
||||
log.warn("No valid layers to save, skipping save to IndexedDB.");
|
||||
return;
|
||||
}
|
||||
|
||||
await setCanvasState(this.canvas.node.id, state);
|
||||
log.info("Canvas state saved to IndexedDB.");
|
||||
this.lastSavedStateSignature = currentStateSignature;
|
||||
}, 'CanvasState.saveStateToDB');
|
||||
|
||||
if (immediate) {
|
||||
await saveFunction();
|
||||
} else {
|
||||
this.saveTimeout = setTimeout(saveFunction, 1000);
|
||||
else {
|
||||
log.warn("State saver worker not available. Saving on main thread.");
|
||||
await setCanvasState(String(this.canvas.node.id), state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Przygotowuje warstwy do zapisu
|
||||
* @returns {Promise<Array>} Przygotowane warstwy
|
||||
* @returns {Promise<(Omit<Layer, 'image'> & { imageId: string })[]>} Przygotowane warstwy
|
||||
*/
|
||||
async _prepareLayers() {
|
||||
return Promise.all(this.canvas.layers.map(async (layer, index) => {
|
||||
const newLayer = {...layer};
|
||||
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer, index) => {
|
||||
const newLayer = { ...layer, imageId: layer.imageId || '' };
|
||||
delete newLayer.image;
|
||||
if (layer.image instanceof HTMLImageElement) {
|
||||
log.debug(`Layer ${index}: Using imageId instead of serializing image.`);
|
||||
if (!layer.imageId) {
|
||||
layer.imageId = generateUUID();
|
||||
await saveImage(layer.imageId, layer.image.src);
|
||||
this.canvas.imageCache.set(layer.imageId, layer.image.src);
|
||||
if (layer.imageId) {
|
||||
newLayer.imageId = layer.imageId;
|
||||
}
|
||||
newLayer.imageId = layer.imageId;
|
||||
} else if (!layer.imageId) {
|
||||
else {
|
||||
log.debug(`Layer ${index}: No imageId found, generating new one and saving image.`);
|
||||
newLayer.imageId = generateUUID();
|
||||
const imageBitmap = await createImageBitmap(layer.image);
|
||||
await saveImage(newLayer.imageId, imageBitmap);
|
||||
}
|
||||
}
|
||||
else if (!layer.imageId) {
|
||||
log.error(`Layer ${index}: No image or imageId found, skipping layer.`);
|
||||
return null;
|
||||
}
|
||||
delete newLayer.image;
|
||||
return newLayer;
|
||||
}));
|
||||
return preparedLayers.filter((layer) => layer !== null);
|
||||
}
|
||||
|
||||
saveState(replaceLast = false) {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.saveMaskState(replaceLast);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this.saveLayersState(replaceLast);
|
||||
}
|
||||
}
|
||||
|
||||
saveLayersState(replaceLast = false) {
|
||||
if (replaceLast && this.layersUndoStack.length > 0) {
|
||||
this.layersUndoStack.pop();
|
||||
}
|
||||
|
||||
const currentState = cloneLayers(this.canvas.layers);
|
||||
|
||||
const currentStateSignature = getStateSignature(currentState);
|
||||
if (this.layersUndoStack.length > 0) {
|
||||
const lastState = this.layersUndoStack[this.layersUndoStack.length - 1];
|
||||
if (getStateSignature(currentState) === getStateSignature(lastState)) {
|
||||
if (getStateSignature(lastState) === currentStateSignature) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.layersUndoStack.push(currentState);
|
||||
|
||||
if (this.layersUndoStack.length > this.historyLimit) {
|
||||
this.layersUndoStack.shift();
|
||||
}
|
||||
this.layersRedoStack = [];
|
||||
this.canvas.updateHistoryButtons();
|
||||
this._debouncedSave = this._debouncedSave || debounce(() => this.saveStateToDB(), 500);
|
||||
if (!this._debouncedSave) {
|
||||
this._debouncedSave = debounce(this.saveStateToDB.bind(this), 1000);
|
||||
}
|
||||
this._debouncedSave();
|
||||
}
|
||||
|
||||
saveMaskState(replaceLast = false) {
|
||||
if (!this.canvas.maskTool) return;
|
||||
|
||||
if (!this.canvas.maskTool)
|
||||
return;
|
||||
if (replaceLast && this.maskUndoStack.length > 0) {
|
||||
this.maskUndoStack.pop();
|
||||
}
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const clonedCanvas = document.createElement('canvas');
|
||||
clonedCanvas.width = maskCanvas.width;
|
||||
clonedCanvas.height = maskCanvas.height;
|
||||
const clonedCtx = clonedCanvas.getContext('2d');
|
||||
clonedCtx.drawImage(maskCanvas, 0, 0);
|
||||
|
||||
const { canvas: clonedCanvas, ctx: clonedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', { willReadFrequently: true });
|
||||
if (clonedCtx) {
|
||||
clonedCtx.drawImage(maskCanvas, 0, 0);
|
||||
}
|
||||
this.maskUndoStack.push(clonedCanvas);
|
||||
|
||||
if (this.maskUndoStack.length > this.historyLimit) {
|
||||
this.maskUndoStack.shift();
|
||||
}
|
||||
this.maskRedoStack = [];
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.undoMaskState();
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this.undoLayersState();
|
||||
}
|
||||
}
|
||||
|
||||
redo() {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.redoMaskState();
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this.redoLayersState();
|
||||
}
|
||||
}
|
||||
|
||||
undoLayersState() {
|
||||
if (this.layersUndoStack.length <= 1) return;
|
||||
|
||||
if (this.layersUndoStack.length <= 1)
|
||||
return;
|
||||
const currentState = this.layersUndoStack.pop();
|
||||
this.layersRedoStack.push(currentState);
|
||||
if (currentState) {
|
||||
this.layersRedoStack.push(currentState);
|
||||
}
|
||||
const prevState = this.layersUndoStack[this.layersUndoStack.length - 1];
|
||||
this.canvas.layers = cloneLayers(prevState);
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
redoLayersState() {
|
||||
if (this.layersRedoStack.length === 0) return;
|
||||
|
||||
if (this.layersRedoStack.length === 0)
|
||||
return;
|
||||
const nextState = this.layersRedoStack.pop();
|
||||
this.layersUndoStack.push(nextState);
|
||||
this.canvas.layers = cloneLayers(nextState);
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
this.canvas.updateHistoryButtons();
|
||||
if (nextState) {
|
||||
this.layersUndoStack.push(nextState);
|
||||
this.canvas.layers = cloneLayers(nextState);
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
}
|
||||
|
||||
undoMaskState() {
|
||||
if (!this.canvas.maskTool || this.maskUndoStack.length <= 1) return;
|
||||
|
||||
if (!this.canvas.maskTool || this.maskUndoStack.length <= 1)
|
||||
return;
|
||||
const currentState = this.maskUndoStack.pop();
|
||||
this.maskRedoStack.push(currentState);
|
||||
|
||||
if (currentState) {
|
||||
this.maskRedoStack.push(currentState);
|
||||
}
|
||||
if (this.maskUndoStack.length > 0) {
|
||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d');
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(prevState, 0, 0);
|
||||
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (maskCtx) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(prevState, 0, 0);
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
redoMaskState() {
|
||||
if (!this.canvas.maskTool || this.maskRedoStack.length === 0) return;
|
||||
|
||||
if (!this.canvas.maskTool || this.maskRedoStack.length === 0)
|
||||
return;
|
||||
const nextState = this.maskRedoStack.pop();
|
||||
this.maskUndoStack.push(nextState);
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d');
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(nextState, 0, 0);
|
||||
|
||||
this.canvas.render();
|
||||
if (nextState) {
|
||||
this.maskUndoStack.push(nextState);
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (maskCtx) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(nextState, 0, 0);
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści historię undo/redo
|
||||
*/
|
||||
@@ -383,17 +437,17 @@ export class CanvasState {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.maskUndoStack = [];
|
||||
this.maskRedoStack = [];
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this.layersUndoStack = [];
|
||||
this.layersRedoStack = [];
|
||||
}
|
||||
this.canvas.updateHistoryButtons();
|
||||
log.info("History cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca informacje o historii
|
||||
* @returns {Object} Informacje o historii
|
||||
* @returns {HistoryInfo} Informacje o historii
|
||||
*/
|
||||
getHistoryInfo() {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
@@ -404,7 +458,8 @@ export class CanvasState {
|
||||
canRedo: this.maskRedoStack.length > 0,
|
||||
historyLimit: this.historyLimit
|
||||
};
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
return {
|
||||
undoCount: this.layersUndoStack.length,
|
||||
redoCount: this.layersRedoStack.length,
|
||||
|
||||
1597
js/CanvasView.js
1597
js/CanvasView.js
File diff suppressed because it is too large
Load Diff
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,11 +2,8 @@
|
||||
* ErrorHandler - Centralna obsługa błędów
|
||||
* Eliminuje powtarzalne wzorce obsługi błędów w całym projekcie
|
||||
*/
|
||||
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('ErrorHandler');
|
||||
|
||||
/**
|
||||
* Typy błędów w aplikacji
|
||||
*/
|
||||
@@ -20,7 +17,6 @@ export const ErrorTypes = {
|
||||
USER_INPUT: 'USER_INPUT_ERROR',
|
||||
SYSTEM: 'SYSTEM_ERROR'
|
||||
};
|
||||
|
||||
/**
|
||||
* Klasa błędu aplikacji z dodatkowymi informacjami
|
||||
*/
|
||||
@@ -37,7 +33,6 @@ export class AppError extends Error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler błędów z automatycznym logowaniem i kategoryzacją
|
||||
*/
|
||||
@@ -47,12 +42,11 @@ export class ErrorHandler {
|
||||
this.errorHistory = [];
|
||||
this.maxHistorySize = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsługuje błąd z automatycznym logowaniem
|
||||
* @param {Error|AppError} error - Błąd do obsłużenia
|
||||
* @param {Error | AppError | string} error - Błąd do obsłużenia
|
||||
* @param {string} context - Kontekst wystąpienia błędu
|
||||
* @param {Object} additionalInfo - Dodatkowe informacje
|
||||
* @param {object} additionalInfo - Dodatkowe informacje
|
||||
* @returns {AppError} Znormalizowany błąd
|
||||
*/
|
||||
handle(error, context = 'Unknown', additionalInfo = {}) {
|
||||
@@ -60,52 +54,33 @@ export class ErrorHandler {
|
||||
this.logError(normalizedError, context);
|
||||
this.recordError(normalizedError);
|
||||
this.incrementErrorCount(normalizedError.type);
|
||||
|
||||
return normalizedError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizuje błąd do standardowego formatu
|
||||
* @param {Error|AppError|string} error - Błąd do znormalizowania
|
||||
* @param {Error | AppError | string} error - Błąd do znormalizowania
|
||||
* @param {string} context - Kontekst
|
||||
* @param {Object} additionalInfo - Dodatkowe informacje
|
||||
* @param {object} additionalInfo - Dodatkowe informacje
|
||||
* @returns {AppError} Znormalizowany błąd
|
||||
*/
|
||||
normalizeError(error, context, additionalInfo) {
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const type = this.categorizeError(error, context);
|
||||
return new AppError(
|
||||
error.message,
|
||||
type,
|
||||
{context, ...additionalInfo},
|
||||
error
|
||||
);
|
||||
return new AppError(error.message, type, { context, ...additionalInfo }, error);
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return new AppError(
|
||||
error,
|
||||
ErrorTypes.SYSTEM,
|
||||
{context, ...additionalInfo}
|
||||
);
|
||||
return new AppError(error, ErrorTypes.SYSTEM, { context, ...additionalInfo });
|
||||
}
|
||||
|
||||
return new AppError(
|
||||
'Unknown error occurred',
|
||||
ErrorTypes.SYSTEM,
|
||||
{context, originalError: error, ...additionalInfo}
|
||||
);
|
||||
return new AppError('Unknown error occurred', ErrorTypes.SYSTEM, { context, originalError: error, ...additionalInfo });
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategoryzuje błąd na podstawie wiadomości i kontekstu
|
||||
* @param {Error} error - Błąd do skategoryzowania
|
||||
* @param {string} context - Kontekst
|
||||
* @returns {string} Typ błędu
|
||||
* @returns {ErrorType} Typ błędu
|
||||
*/
|
||||
categorizeError(error, context) {
|
||||
const message = error.message.toLowerCase();
|
||||
@@ -132,10 +107,8 @@ export class ErrorHandler {
|
||||
if (context.toLowerCase().includes('canvas')) {
|
||||
return ErrorTypes.CANVAS;
|
||||
}
|
||||
|
||||
return ErrorTypes.SYSTEM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loguje błąd z odpowiednim poziomem
|
||||
* @param {AppError} error - Błąd do zalogowania
|
||||
@@ -161,7 +134,6 @@ export class ErrorHandler {
|
||||
log.error(logMessage, logDetails);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisuje błąd w historii
|
||||
* @param {AppError} error - Błąd do zapisania
|
||||
@@ -177,36 +149,37 @@ export class ErrorHandler {
|
||||
this.errorHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwiększa licznik błędów dla danego typu
|
||||
* @param {string} errorType - Typ błędu
|
||||
* @param {ErrorType} errorType - Typ błędu
|
||||
*/
|
||||
incrementErrorCount(errorType) {
|
||||
const current = this.errorCounts.get(errorType) || 0;
|
||||
this.errorCounts.set(errorType, current + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca statystyki błędów
|
||||
* @returns {Object} Statystyki błędów
|
||||
* @returns {ErrorStats} Statystyki błędów
|
||||
*/
|
||||
getErrorStats() {
|
||||
const errorCountsObj = {};
|
||||
for (const [key, value] of this.errorCounts.entries()) {
|
||||
errorCountsObj[key] = value;
|
||||
}
|
||||
return {
|
||||
totalErrors: this.errorHistory.length,
|
||||
errorCounts: Object.fromEntries(this.errorCounts),
|
||||
errorCounts: errorCountsObj,
|
||||
recentErrors: this.errorHistory.slice(-10),
|
||||
errorsByType: this.groupErrorsByType()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Grupuje błędy według typu
|
||||
* @returns {Object} Błędy pogrupowane według typu
|
||||
* @returns {{ [key: string]: ErrorHistoryEntry[] }} Błędy pogrupowane według typu
|
||||
*/
|
||||
groupErrorsByType() {
|
||||
const grouped = {};
|
||||
this.errorHistory.forEach(error => {
|
||||
this.errorHistory.forEach((error) => {
|
||||
if (!grouped[error.type]) {
|
||||
grouped[error.type] = [];
|
||||
}
|
||||
@@ -214,7 +187,6 @@ export class ErrorHandler {
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści historię błędów
|
||||
*/
|
||||
@@ -224,9 +196,7 @@ export class ErrorHandler {
|
||||
log.info('Error history cleared');
|
||||
}
|
||||
}
|
||||
|
||||
const errorHandler = new ErrorHandler();
|
||||
|
||||
/**
|
||||
* Wrapper funkcji z automatyczną obsługą błędów
|
||||
* @param {Function} fn - Funkcja do opakowania
|
||||
@@ -237,7 +207,8 @@ export function withErrorHandling(fn, context) {
|
||||
return async function (...args) {
|
||||
try {
|
||||
return await fn.apply(this, args);
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
const handledError = errorHandler.handle(error, context, {
|
||||
functionName: fn.name,
|
||||
arguments: args.length
|
||||
@@ -246,7 +217,6 @@ export function withErrorHandling(fn, context) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator dla metod klasy z automatyczną obsługą błędów
|
||||
* @param {string} context - Kontekst wykonania
|
||||
@@ -254,11 +224,11 @@ export function withErrorHandling(fn, context) {
|
||||
export function handleErrors(context) {
|
||||
return function (target, propertyKey, descriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (...args) {
|
||||
try {
|
||||
return await originalMethod.apply(this, args);
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
const handledError = errorHandler.handle(error, `${context}.${propertyKey}`, {
|
||||
className: target.constructor.name,
|
||||
methodName: propertyKey,
|
||||
@@ -267,86 +237,77 @@ export function handleErrors(context) {
|
||||
throw handledError;
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia błędów walidacji
|
||||
* @param {string} message - Wiadomość błędu
|
||||
* @param {Object} details - Szczegóły walidacji
|
||||
* @param {object} details - Szczegóły walidacji
|
||||
* @returns {AppError} Błąd walidacji
|
||||
*/
|
||||
export function createValidationError(message, details = {}) {
|
||||
return new AppError(message, ErrorTypes.VALIDATION, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia błędów sieciowych
|
||||
* @param {string} message - Wiadomość błędu
|
||||
* @param {Object} details - Szczegóły sieci
|
||||
* @param {object} details - Szczegóły sieci
|
||||
* @returns {AppError} Błąd sieciowy
|
||||
*/
|
||||
export function createNetworkError(message, details = {}) {
|
||||
return new AppError(message, ErrorTypes.NETWORK, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia błędów plików
|
||||
* @param {string} message - Wiadomość błędu
|
||||
* @param {Object} details - Szczegóły pliku
|
||||
* @param {object} details - Szczegóły pliku
|
||||
* @returns {AppError} Błąd pliku
|
||||
*/
|
||||
export function createFileError(message, details = {}) {
|
||||
return new AppError(message, ErrorTypes.FILE_IO, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do bezpiecznego wykonania operacji
|
||||
* @param {Function} operation - Operacja do wykonania
|
||||
* @param {*} fallbackValue - Wartość fallback w przypadku błędu
|
||||
* @param {() => Promise<T>} operation - Operacja do wykonania
|
||||
* @param {T} fallbackValue - Wartość fallback w przypadku błędu
|
||||
* @param {string} context - Kontekst operacji
|
||||
* @returns {*} Wynik operacji lub wartość fallback
|
||||
* @returns {Promise<T>} Wynik operacji lub wartość fallback
|
||||
*/
|
||||
export async function safeExecute(operation, fallbackValue = null, context = 'SafeExecute') {
|
||||
export async function safeExecute(operation, fallbackValue, context = 'SafeExecute') {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
errorHandler.handle(error, context);
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja do retry operacji z exponential backoff
|
||||
* @param {Function} operation - Operacja do powtórzenia
|
||||
* @param {() => Promise<T>} operation - Operacja do powtórzenia
|
||||
* @param {number} maxRetries - Maksymalna liczba prób
|
||||
* @param {number} baseDelay - Podstawowe opóźnienie w ms
|
||||
* @param {string} context - Kontekst operacji
|
||||
* @returns {*} Wynik operacji
|
||||
* @returns {Promise<T>} Wynik operacji
|
||||
*/
|
||||
export async function retryWithBackoff(operation, maxRetries = 3, baseDelay = 1000, context = 'RetryOperation') {
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = baseDelay * Math.pow(2, attempt);
|
||||
log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`, {error: error.message, context});
|
||||
log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`, { error: lastError.message, context });
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw errorHandler.handle(lastError, context, {attempts: maxRetries + 1});
|
||||
throw errorHandler.handle(lastError, context, { attempts: maxRetries + 1 });
|
||||
}
|
||||
|
||||
export {errorHandler};
|
||||
export { errorHandler };
|
||||
export default errorHandler;
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('ImageCache');
|
||||
|
||||
export class ImageCache {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
set(key, imageData) {
|
||||
log.info("Caching image data for key:", key);
|
||||
this.cache.set(key, imageData);
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const data = this.cache.get(key);
|
||||
log.debug("Retrieved cached data for key:", key, !!data);
|
||||
return data;
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
log.info("Clearing image cache");
|
||||
this.cache.clear();
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import {removeImage, getAllImageIds} from "./db.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
import { removeImage, getAllImageIds } from "./db.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('ImageReferenceManager');
|
||||
|
||||
export class ImageReferenceManager {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.imageReferences = new Map(); // imageId -> count
|
||||
this.imageLastUsed = new Map(); // imageId -> timestamp
|
||||
this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane)
|
||||
this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia
|
||||
this.imageLastUsed = new Map(); // imageId -> timestamp
|
||||
this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane)
|
||||
this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia
|
||||
this.gcTimer = null;
|
||||
this.isGcRunning = false;
|
||||
|
||||
this.operationCount = 0;
|
||||
this.operationThreshold = 500; // Uruchom GC po 500 operacjach
|
||||
|
||||
|
||||
this.operationThreshold = 500; // Uruchom GC po 500 operacjach
|
||||
}
|
||||
|
||||
/**
|
||||
* Uruchamia automatyczne garbage collection
|
||||
*/
|
||||
@@ -26,14 +20,11 @@ export class ImageReferenceManager {
|
||||
if (this.gcTimer) {
|
||||
clearInterval(this.gcTimer);
|
||||
}
|
||||
|
||||
this.gcTimer = setInterval(() => {
|
||||
this.gcTimer = window.setInterval(() => {
|
||||
this.performGarbageCollection();
|
||||
}, this.gcInterval);
|
||||
|
||||
log.info("Garbage collection started with interval:", this.gcInterval / 1000, "seconds");
|
||||
}
|
||||
|
||||
/**
|
||||
* Zatrzymuje automatyczne garbage collection
|
||||
*/
|
||||
@@ -44,38 +35,35 @@ export class ImageReferenceManager {
|
||||
}
|
||||
log.info("Garbage collection stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dodaje referencję do obrazu
|
||||
* @param {string} imageId - ID obrazu
|
||||
*/
|
||||
addReference(imageId) {
|
||||
if (!imageId) return;
|
||||
|
||||
if (!imageId)
|
||||
return;
|
||||
const currentCount = this.imageReferences.get(imageId) || 0;
|
||||
this.imageReferences.set(imageId, currentCount + 1);
|
||||
this.imageLastUsed.set(imageId, Date.now());
|
||||
|
||||
log.debug(`Added reference to image ${imageId}, count: ${currentCount + 1}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuwa referencję do obrazu
|
||||
* @param {string} imageId - ID obrazu
|
||||
*/
|
||||
removeReference(imageId) {
|
||||
if (!imageId) return;
|
||||
|
||||
if (!imageId)
|
||||
return;
|
||||
const currentCount = this.imageReferences.get(imageId) || 0;
|
||||
if (currentCount <= 1) {
|
||||
this.imageReferences.delete(imageId);
|
||||
log.debug(`Removed last reference to image ${imageId}`);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this.imageReferences.set(imageId, currentCount - 1);
|
||||
log.debug(`Removed reference to image ${imageId}, count: ${currentCount - 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje referencje na podstawie aktualnego stanu canvas
|
||||
*/
|
||||
@@ -86,117 +74,100 @@ export class ImageReferenceManager {
|
||||
usedImageIds.forEach(imageId => {
|
||||
this.addReference(imageId);
|
||||
});
|
||||
|
||||
log.info(`Updated references for ${usedImageIds.size} unique images`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zbiera wszystkie używane imageId z różnych źródeł
|
||||
* @returns {Set<string>} Zbiór używanych imageId
|
||||
*/
|
||||
collectAllUsedImageIds() {
|
||||
const usedImageIds = new Set();
|
||||
this.canvas.layers.forEach(layer => {
|
||||
this.canvas.layers.forEach((layer) => {
|
||||
if (layer.imageId) {
|
||||
usedImageIds.add(layer.imageId);
|
||||
}
|
||||
});
|
||||
if (this.canvas.canvasState && this.canvas.canvasState.layersUndoStack) {
|
||||
this.canvas.canvasState.layersUndoStack.forEach(layersState => {
|
||||
layersState.forEach(layer => {
|
||||
this.canvas.canvasState.layersUndoStack.forEach((layersState) => {
|
||||
layersState.forEach((layer) => {
|
||||
if (layer.imageId) {
|
||||
usedImageIds.add(layer.imageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.canvas.canvasState && this.canvas.canvasState.layersRedoStack) {
|
||||
this.canvas.canvasState.layersRedoStack.forEach(layersState => {
|
||||
layersState.forEach(layer => {
|
||||
this.canvas.canvasState.layersRedoStack.forEach((layersState) => {
|
||||
layersState.forEach((layer) => {
|
||||
if (layer.imageId) {
|
||||
usedImageIds.add(layer.imageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
log.debug(`Collected ${usedImageIds.size} used image IDs`);
|
||||
return usedImageIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Znajduje nieużywane obrazy
|
||||
* @param {Set<string>} usedImageIds - Zbiór używanych imageId
|
||||
* @returns {Array<string>} Lista nieużywanych imageId
|
||||
* @returns {Promise<string[]>} Lista nieużywanych imageId
|
||||
*/
|
||||
async findUnusedImages(usedImageIds) {
|
||||
try {
|
||||
|
||||
const allImageIds = await getAllImageIds();
|
||||
const unusedImages = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const imageId of allImageIds) {
|
||||
|
||||
if (!usedImageIds.has(imageId)) {
|
||||
const lastUsed = this.imageLastUsed.get(imageId) || 0;
|
||||
const age = now - lastUsed;
|
||||
|
||||
if (age > this.maxAge) {
|
||||
unusedImages.push(imageId);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.debug(`Image ${imageId} is unused but too young (age: ${Math.round(age / 1000)}s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`Found ${unusedImages.length} unused images ready for cleanup`);
|
||||
return unusedImages;
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error finding unused images:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści nieużywane obrazy
|
||||
* @param {Array<string>} unusedImages - Lista nieużywanych imageId
|
||||
* @param {string[]} unusedImages - Lista nieużywanych imageId
|
||||
*/
|
||||
async cleanupUnusedImages(unusedImages) {
|
||||
if (unusedImages.length === 0) {
|
||||
log.debug("No unused images to cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Starting cleanup of ${unusedImages.length} unused images`);
|
||||
let cleanedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const imageId of unusedImages) {
|
||||
try {
|
||||
|
||||
await removeImage(imageId);
|
||||
|
||||
if (this.canvas.imageCache && this.canvas.imageCache.has(imageId)) {
|
||||
this.canvas.imageCache.delete(imageId);
|
||||
}
|
||||
|
||||
this.imageReferences.delete(imageId);
|
||||
this.imageLastUsed.delete(imageId);
|
||||
|
||||
cleanedCount++;
|
||||
log.debug(`Cleaned up image: ${imageId}`);
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
errorCount++;
|
||||
log.error(`Error cleaning up image ${imageId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Garbage collection completed: ${cleanedCount} images cleaned, ${errorCount} errors`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wykonuje pełne garbage collection
|
||||
*/
|
||||
@@ -205,44 +176,35 @@ export class ImageReferenceManager {
|
||||
log.debug("Garbage collection already running, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGcRunning = true;
|
||||
log.info("Starting garbage collection...");
|
||||
|
||||
try {
|
||||
|
||||
this.updateReferences();
|
||||
|
||||
const usedImageIds = this.collectAllUsedImageIds();
|
||||
|
||||
const unusedImages = await this.findUnusedImages(usedImageIds);
|
||||
|
||||
await this.cleanupUnusedImages(unusedImages);
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error during garbage collection:", error);
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
this.isGcRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwiększa licznik operacji i sprawdza czy uruchomić GC
|
||||
*/
|
||||
incrementOperationCount() {
|
||||
this.operationCount++;
|
||||
log.debug(`Operation count: ${this.operationCount}/${this.operationThreshold}`);
|
||||
|
||||
if (this.operationCount >= this.operationThreshold) {
|
||||
log.info(`Operation threshold reached (${this.operationThreshold}), triggering garbage collection`);
|
||||
this.operationCount = 0; // Reset counter
|
||||
|
||||
setTimeout(() => {
|
||||
this.performGarbageCollection();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resetuje licznik operacji
|
||||
*/
|
||||
@@ -250,7 +212,6 @@ export class ImageReferenceManager {
|
||||
this.operationCount = 0;
|
||||
log.debug("Operation count reset");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustawia próg operacji dla automatycznego GC
|
||||
* @param {number} threshold - Nowy próg operacji
|
||||
@@ -259,7 +220,6 @@ export class ImageReferenceManager {
|
||||
this.operationThreshold = Math.max(1, threshold);
|
||||
log.info(`Operation threshold set to: ${this.operationThreshold}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ręczne uruchomienie garbage collection
|
||||
*/
|
||||
@@ -267,10 +227,9 @@ export class ImageReferenceManager {
|
||||
log.info("Manual garbage collection triggered");
|
||||
await this.performGarbageCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca statystyki garbage collection
|
||||
* @returns {Object} Statystyki
|
||||
* @returns {GarbageCollectionStats} Statystyki
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
@@ -281,7 +240,6 @@ export class ImageReferenceManager {
|
||||
maxAge: this.maxAge
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści wszystkie dane (przy usuwaniu canvas)
|
||||
*/
|
||||
|
||||
434
js/MaskEditorIntegration.js
Normal file
434
js/MaskEditorIntegration.js
Normal file
@@ -0,0 +1,434 @@
|
||||
// @ts-ignore
|
||||
import { app } from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import { ComfyApp } from "../../scripts/app.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";
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
const log = createModuleLogger('MaskEditorIntegration');
|
||||
export class MaskEditorIntegration {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.node = canvas.node;
|
||||
this.maskTool = canvas.maskTool;
|
||||
this.savedMaskState = null;
|
||||
this.maskEditorCancelled = false;
|
||||
this.pendingMask = null;
|
||||
this.editorWasShowing = false;
|
||||
}
|
||||
/**
|
||||
* Uruchamia edytor masek
|
||||
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora
|
||||
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
|
||||
*/
|
||||
async startMaskEditor(predefinedMask = null, sendCleanImage = true) {
|
||||
log.info('Starting mask editor', {
|
||||
hasPredefinedMask: !!predefinedMask,
|
||||
sendCleanImage,
|
||||
layersCount: this.canvas.layers.length
|
||||
});
|
||||
this.savedMaskState = await this.saveMaskState();
|
||||
this.maskEditorCancelled = false;
|
||||
if (!predefinedMask && this.maskTool && this.maskTool.maskCanvas) {
|
||||
try {
|
||||
log.debug('Creating mask from current mask tool');
|
||||
predefinedMask = await this.createMaskFromCurrentMask();
|
||||
log.debug('Mask created from current mask tool successfully');
|
||||
}
|
||||
catch (error) {
|
||||
log.warn("Could not create mask from current mask:", error);
|
||||
}
|
||||
}
|
||||
this.pendingMask = predefinedMask;
|
||||
let blob;
|
||||
if (sendCleanImage) {
|
||||
log.debug('Getting flattened canvas as blob (clean image)');
|
||||
blob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
}
|
||||
else {
|
||||
log.debug('Getting flattened canvas for mask editor (with mask)');
|
||||
blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
}
|
||||
if (!blob) {
|
||||
log.warn("Canvas is empty, cannot open mask editor.");
|
||||
return;
|
||||
}
|
||||
log.debug('Canvas blob created successfully, size:', blob.size);
|
||||
try {
|
||||
// Use ImageUploadUtils to upload the blob
|
||||
const uploadResult = await uploadImageBlob(blob, {
|
||||
filenamePrefix: 'layerforge-mask-edit'
|
||||
});
|
||||
this.node.imgs = [uploadResult.imageElement];
|
||||
log.info('Opening ComfyUI mask editor');
|
||||
ComfyApp.copyToClipspace(this.node);
|
||||
ComfyApp.clipspace_return_node = this.node;
|
||||
ComfyApp.open_maskeditor();
|
||||
this.editorWasShowing = false;
|
||||
this.waitWhileMaskEditing();
|
||||
this.setupCancelListener();
|
||||
if (predefinedMask) {
|
||||
log.debug('Will apply predefined mask when editor is ready');
|
||||
this.waitForMaskEditorAndApplyMask();
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error preparing image for mask editor:", error);
|
||||
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
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Czeka na otwarcie mask editora i automatycznie nakłada predefiniowaną maskę
|
||||
*/
|
||||
waitForMaskEditorAndApplyMask() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 100; // Zwiększone do 10 sekund oczekiwania
|
||||
const checkEditor = () => {
|
||||
attempts++;
|
||||
if (mask_editor_showing(app)) {
|
||||
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
|
||||
let editorReady = false;
|
||||
if (useNewEditor) {
|
||||
const MaskEditorDialog = window.MaskEditorDialog;
|
||||
if (MaskEditorDialog && MaskEditorDialog.instance) {
|
||||
try {
|
||||
const messageBroker = MaskEditorDialog.instance.getMessageBroker();
|
||||
if (messageBroker) {
|
||||
editorReady = true;
|
||||
log.info("New mask editor detected as ready via MessageBroker");
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
editorReady = false;
|
||||
}
|
||||
}
|
||||
if (!editorReady) {
|
||||
const maskEditorElement = document.getElementById('maskEditor');
|
||||
if (maskEditorElement && maskEditorElement.style.display !== 'none') {
|
||||
const canvas = maskEditorElement.querySelector('canvas');
|
||||
if (canvas) {
|
||||
editorReady = true;
|
||||
log.info("New mask editor detected as ready via DOM element");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
const maskCanvas = document.getElementById('maskCanvas');
|
||||
if (maskCanvas) {
|
||||
editorReady = !!(maskCanvas.getContext('2d') && maskCanvas.width > 0 && maskCanvas.height > 0);
|
||||
if (editorReady) {
|
||||
log.info("Old mask editor detected as ready");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (editorReady) {
|
||||
// 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;
|
||||
}, waitTime);
|
||||
}
|
||||
else if (attempts < maxAttempts) {
|
||||
if (attempts % 10 === 0) {
|
||||
log.info("Waiting for mask editor to be ready... attempt", attempts, "/", maxAttempts);
|
||||
}
|
||||
setTimeout(checkEditor, 100);
|
||||
}
|
||||
else {
|
||||
log.warn("Mask editor timeout - editor not ready after", maxAttempts * 100, "ms");
|
||||
log.info("Attempting to apply mask anyway...");
|
||||
setTimeout(() => {
|
||||
this.applyMaskToEditor(this.pendingMask);
|
||||
this.pendingMask = null;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
else if (attempts < maxAttempts) {
|
||||
setTimeout(checkEditor, 100);
|
||||
}
|
||||
else {
|
||||
log.warn("Mask editor timeout - editor not showing after", maxAttempts * 100, "ms");
|
||||
this.pendingMask = null;
|
||||
}
|
||||
};
|
||||
checkEditor();
|
||||
}
|
||||
/**
|
||||
* Nakłada maskę na otwarty mask editor
|
||||
* @param {Image|HTMLCanvasElement} maskData - Dane maski do nałożenia
|
||||
*/
|
||||
async applyMaskToEditor(maskData) {
|
||||
try {
|
||||
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
|
||||
if (useNewEditor) {
|
||||
const MaskEditorDialog = window.MaskEditorDialog;
|
||||
if (MaskEditorDialog && MaskEditorDialog.instance) {
|
||||
await this.applyMaskToNewEditor(maskData);
|
||||
}
|
||||
else {
|
||||
log.warn("New editor setting enabled but instance not found, trying old editor");
|
||||
await this.applyMaskToOldEditor(maskData);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await this.applyMaskToOldEditor(maskData);
|
||||
}
|
||||
log.info("Predefined mask applied to mask editor successfully");
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Failed to apply predefined mask to editor:", error);
|
||||
try {
|
||||
log.info("Trying alternative mask application method...");
|
||||
await this.applyMaskToOldEditor(maskData);
|
||||
log.info("Alternative method succeeded");
|
||||
}
|
||||
catch (fallbackError) {
|
||||
log.error("Alternative method also failed:", fallbackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Nakłada maskę na nowy mask editor (przez MessageBroker)
|
||||
* @param {Image|HTMLCanvasElement} maskData - Dane maski
|
||||
*/
|
||||
async applyMaskToNewEditor(maskData) {
|
||||
const MaskEditorDialog = window.MaskEditorDialog;
|
||||
if (!MaskEditorDialog || !MaskEditorDialog.instance) {
|
||||
throw new Error("New mask editor instance not found");
|
||||
}
|
||||
const editor = MaskEditorDialog.instance;
|
||||
const messageBroker = editor.getMessageBroker();
|
||||
const maskCanvas = await messageBroker.pull('maskCanvas');
|
||||
const maskCtx = await messageBroker.pull('maskCtx');
|
||||
const maskColor = await messageBroker.pull('getMaskColor');
|
||||
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(processedMask, 0, 0);
|
||||
messageBroker.publish('saveState');
|
||||
}
|
||||
/**
|
||||
* Nakłada maskę na stary mask editor
|
||||
* @param {Image|HTMLCanvasElement} maskData - Dane maski
|
||||
*/
|
||||
async applyMaskToOldEditor(maskData) {
|
||||
const maskCanvas = document.getElementById('maskCanvas');
|
||||
if (!maskCanvas) {
|
||||
throw new Error("Old mask editor canvas not found");
|
||||
}
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!maskCtx) {
|
||||
throw new Error("Old mask editor context not found");
|
||||
}
|
||||
const maskColor = { r: 255, g: 255, b: 255 };
|
||||
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(processedMask, 0, 0);
|
||||
}
|
||||
/**
|
||||
* Przetwarza maskę do odpowiedniego formatu dla editora
|
||||
* @param {Image|HTMLCanvasElement} maskData - Oryginalne dane maski
|
||||
* @param {number} targetWidth - Docelowa szerokość
|
||||
* @param {number} targetHeight - Docelowa wysokość
|
||||
* @param {Object} maskColor - Kolor maski {r, g, b}
|
||||
* @returns {HTMLCanvasElement} Przetworzona maska
|
||||
*/
|
||||
async processMaskForEditor(maskData, 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
|
||||
* @returns {Promise<Image>} Promise zwracający obiekt Image z maską
|
||||
*/
|
||||
async createMaskFromCurrentMask() {
|
||||
if (!this.maskTool || !this.maskTool.maskCanvas) {
|
||||
throw new Error("No mask canvas available");
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const maskImage = new Image();
|
||||
maskImage.onload = () => resolve(maskImage);
|
||||
maskImage.onerror = reject;
|
||||
maskImage.src = this.maskTool.maskCanvas.toDataURL();
|
||||
});
|
||||
}
|
||||
waitWhileMaskEditing() {
|
||||
if (mask_editor_showing(app)) {
|
||||
this.editorWasShowing = true;
|
||||
}
|
||||
if (!mask_editor_showing(app) && this.editorWasShowing) {
|
||||
this.editorWasShowing = false;
|
||||
setTimeout(() => this.handleMaskEditorClose(), 100);
|
||||
}
|
||||
else {
|
||||
setTimeout(this.waitWhileMaskEditing.bind(this), 100);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Zapisuje obecny stan maski przed otwarciem editora
|
||||
* @returns {Object} Zapisany stan maski
|
||||
*/
|
||||
async saveMaskState() {
|
||||
if (!this.maskTool || !this.maskTool.maskCanvas) {
|
||||
return null;
|
||||
}
|
||||
const maskCanvas = this.maskTool.maskCanvas;
|
||||
const { canvas: savedCanvas, ctx: savedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', { willReadFrequently: true });
|
||||
if (savedCtx) {
|
||||
savedCtx.drawImage(maskCanvas, 0, 0);
|
||||
}
|
||||
return {
|
||||
maskData: savedCanvas,
|
||||
maskPosition: {
|
||||
x: this.maskTool.x,
|
||||
y: this.maskTool.y
|
||||
}
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Przywraca zapisany stan maski
|
||||
* @param {Object} savedState - Zapisany stan maski
|
||||
*/
|
||||
async restoreMaskState(savedState) {
|
||||
if (!savedState || !this.maskTool) {
|
||||
return;
|
||||
}
|
||||
if (savedState.maskData) {
|
||||
const maskCtx = this.maskTool.maskCtx;
|
||||
maskCtx.clearRect(0, 0, this.maskTool.maskCanvas.width, this.maskTool.maskCanvas.height);
|
||||
maskCtx.drawImage(savedState.maskData, 0, 0);
|
||||
}
|
||||
if (savedState.maskPosition) {
|
||||
this.maskTool.x = savedState.maskPosition.x;
|
||||
this.maskTool.y = savedState.maskPosition.y;
|
||||
}
|
||||
this.canvas.render();
|
||||
log.info("Mask state restored after cancel");
|
||||
}
|
||||
/**
|
||||
* Konfiguruje nasłuchiwanie na przycisk Cancel w mask editorze
|
||||
*/
|
||||
setupCancelListener() {
|
||||
mask_editor_listen_for_cancel(app, () => {
|
||||
log.info("Mask editor cancel button clicked");
|
||||
this.maskEditorCancelled = true;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Sprawdza czy mask editor został anulowany i obsługuje to odpowiednio
|
||||
*/
|
||||
async handleMaskEditorClose() {
|
||||
log.info("Handling mask editor close");
|
||||
log.debug("Node object after mask editor close:", this.node);
|
||||
if (this.maskEditorCancelled) {
|
||||
log.info("Mask editor was cancelled - restoring original mask state");
|
||||
if (this.savedMaskState) {
|
||||
await this.restoreMaskState(this.savedMaskState);
|
||||
}
|
||||
this.maskEditorCancelled = false;
|
||||
this.savedMaskState = null;
|
||||
return;
|
||||
}
|
||||
if (!this.node.imgs || this.node.imgs.length === 0 || !this.node.imgs[0].src) {
|
||||
log.warn("Mask editor was closed without a result.");
|
||||
return;
|
||||
}
|
||||
log.debug("Processing mask editor result, image source:", this.node.imgs[0].src.substring(0, 100) + '...');
|
||||
const resultImage = new Image();
|
||||
resultImage.src = this.node.imgs[0].src;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
resultImage.onload = resolve;
|
||||
resultImage.onerror = reject;
|
||||
});
|
||||
log.debug("Result image loaded successfully", {
|
||||
width: resultImage.width,
|
||||
height: resultImage.height
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Failed to load image from mask editor.", error);
|
||||
this.node.imgs = [];
|
||||
return;
|
||||
}
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
1724
js/MaskTool.js
1724
js/MaskTool.js
File diff suppressed because it is too large
Load Diff
419
js/SAMDetectorIntegration.js
Normal file
419
js/SAMDetectorIntegration.js
Normal file
@@ -0,0 +1,419 @@
|
||||
// @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 { validateAndFixClipspace } from "./utils/ClipspaceUtils.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;
|
||||
}
|
||||
}
|
||||
// Store original onClipspaceEditorSave function to restore later
|
||||
let originalOnClipspaceEditorSave = 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;
|
||||
// Ensure proper clipspace structure for updated ComfyUI
|
||||
if (!ComfyApp.clipspace) {
|
||||
ComfyApp.clipspace = {};
|
||||
}
|
||||
// Set up clipspace with proper indices
|
||||
ComfyApp.clipspace.imgs = [uploadResult.imageElement];
|
||||
ComfyApp.clipspace.selectedIndex = 0;
|
||||
ComfyApp.clipspace.combinedIndex = 0;
|
||||
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||
// Copy to ComfyUI clipspace
|
||||
ComfyApp.copyToClipspace(node);
|
||||
// Override onClipspaceEditorSave to fix clipspace structure before pasteFromClipspace
|
||||
if (!originalOnClipspaceEditorSave) {
|
||||
originalOnClipspaceEditorSave = ComfyApp.onClipspaceEditorSave;
|
||||
ComfyApp.onClipspaceEditorSave = function () {
|
||||
log.debug("SAM Detector onClipspaceEditorSave called, using unified clipspace validation");
|
||||
// Use the unified clipspace validation function
|
||||
const isValid = validateAndFixClipspace();
|
||||
if (!isValid) {
|
||||
log.error("Clipspace validation failed, cannot proceed with paste");
|
||||
return;
|
||||
}
|
||||
// Call the original function
|
||||
if (originalOnClipspaceEditorSave) {
|
||||
originalOnClipspaceEditorSave.call(ComfyApp);
|
||||
}
|
||||
// Restore the original function after use
|
||||
if (originalOnClipspaceEditorSave) {
|
||||
ComfyApp.onClipspaceEditorSave = originalOnClipspaceEditorSave;
|
||||
originalOnClipspaceEditorSave = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
3
js/config.js
Normal file
3
js/config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Log level for development.
|
||||
// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE'
|
||||
export const LOG_LEVEL = 'NONE';
|
||||
170
js/css/blend_mode_menu.css
Normal file
170
js/css/blend_mode_menu.css
Normal file
@@ -0,0 +1,170 @@
|
||||
/* Blend Mode Menu Styles */
|
||||
#blend-mode-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-title-bar {
|
||||
background: #3a3a3a;
|
||||
color: white;
|
||||
padding: 8px 10px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
border-radius: 3px 3px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-title-text {
|
||||
flex: 1;
|
||||
cursor: move;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-close-button {
|
||||
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;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-close-button:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-close-button:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-content {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-container {
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-label {
|
||||
color: white;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider::-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;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-container {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-option {
|
||||
padding: 5px 10px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-option:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-option.active {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
display: none;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-container.active .blend-opacity-slider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider::-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;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
669
js/css/canvas_view.css
Normal file
669
js/css/canvas_view.css
Normal file
@@ -0,0 +1,669 @@
|
||||
.painter-button {
|
||||
background-color: #444;
|
||||
border: 1px solid #555;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
margin: 2px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
text-shadow: 0 1px 2px rgb(0,0,0);
|
||||
}
|
||||
|
||||
.painter-button:hover {
|
||||
background-color: #555;
|
||||
border-color: #666;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.painter-button:active {
|
||||
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-color: #3a3a3a;
|
||||
color: #777;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
border-color: #4a4a4a;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.painter-button.primary {
|
||||
background-color: #3a76d6;
|
||||
border-color: #2a6ac4;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgb(0,0,0);
|
||||
}
|
||||
|
||||
.painter-button.primary:hover {
|
||||
background-color: #4a86e4;
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
/* Crop mode button styling */
|
||||
.painter-button#crop-mode-btn {
|
||||
background-color: #444;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.painter-button#crop-mode-btn.primary {
|
||||
background-color: #0080ff;
|
||||
border-color: #0070e0;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 8px rgba(0, 128, 255, 0.3);
|
||||
}
|
||||
|
||||
.painter-button#crop-mode-btn.primary:hover {
|
||||
background-color: #1090ff;
|
||||
border-color: #0080ff;
|
||||
box-shadow: 0 0 12px rgba(0, 128, 255, 0.4);
|
||||
}
|
||||
|
||||
.painter-button#crop-mode-btn:hover {
|
||||
background-color: #555;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.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-color: #2f2f2f;
|
||||
border-bottom: 1px solid #202020;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.painter-slider-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
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: 4px;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.painter-clipboard-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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, opacity 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;
|
||||
}
|
||||
|
||||
/* Disabled state for switch */
|
||||
.clipboard-switch.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
background: #3a3a3a !important; /* Override gradient */
|
||||
border-color: #4a4a4a !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.clipboard-switch.disabled .switch-knob {
|
||||
background-color: #4a4a4a !important;
|
||||
}
|
||||
|
||||
.clipboard-switch.disabled .switch-labels {
|
||||
color: #777 !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.painter-separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: #444;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.painter-container {
|
||||
background: #607080; /* 带蓝色的灰色背景 */
|
||||
border: 1px solid #4a5a6a;
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
|
||||
transition: border-color 0.3s ease; /* Dodano dla płynnej zmiany ramki */
|
||||
}
|
||||
|
||||
.painter-container.drag-over {
|
||||
border-color: #00ff00; /* Zielona ramka podczas przeciągania */
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.painter-dialog {
|
||||
background: #404040;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
padding: 20px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.painter-dialog input {
|
||||
background: #303030;
|
||||
border: 1px solid #505050;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
margin: 4px;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.painter-dialog button {
|
||||
background: #505050;
|
||||
border: 1px solid #606060;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 4px 12px;
|
||||
margin: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.painter-dialog button:hover {
|
||||
background: #606060;
|
||||
}
|
||||
|
||||
.blend-opacity-slider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blend-mode-active .blend-opacity-slider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blend-mode-item {
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.blend-mode-item.active {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.blend-mode-item.active {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.painter-tooltip {
|
||||
position: fixed;
|
||||
display: none;
|
||||
background: #2B2B2B;
|
||||
color: #f0f0f0;
|
||||
border: 1px solid #444;
|
||||
border-top: 2px solid #4a90e2;
|
||||
border-radius: 6px;
|
||||
padding: 12px 18px;
|
||||
z-index: 9999;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
width: auto;
|
||||
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;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.painter-tooltip.scale-down {
|
||||
transform: scale(0.9);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.painter-tooltip.scale-down-more {
|
||||
transform: scale(0.8);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.painter-tooltip table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.painter-tooltip table td {
|
||||
padding: 4px 8px;
|
||||
vertical-align: middle;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.painter-tooltip table td:first-child {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.painter-tooltip table td:last-child {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.painter-tooltip table tr:nth-child(odd) td {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.painter-tooltip table tr:hover td {
|
||||
background-color: rgba(74, 144, 226, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.painter-tooltip {
|
||||
font-size: 11px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.painter-tooltip table td {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.painter-tooltip kbd {
|
||||
padding: 1px 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.painter-tooltip table td:first-child {
|
||||
width: 40%;
|
||||
}
|
||||
.painter-tooltip table td:last-child {
|
||||
width: 60%;
|
||||
}
|
||||
.painter-tooltip h4 {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.painter-tooltip {
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.painter-tooltip table td {
|
||||
padding: 1px 3px;
|
||||
}
|
||||
.painter-tooltip kbd {
|
||||
padding: 0px 3px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.painter-tooltip table td:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
.painter-tooltip table td:last-child {
|
||||
width: 65%;
|
||||
}
|
||||
.painter-tooltip h4 {
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.painter-tooltip h4 {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 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 {
|
||||
list-style: none;
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.painter-tooltip kbd {
|
||||
background-color: #444;
|
||||
border: 1px solid #555;
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: monospace;
|
||||
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 {
|
||||
/* Używamy box-shadow, aby stworzyć efekt zewnętrznej ramki,
|
||||
która nie wpłynie na rozmiar ani pozycję elementu. */
|
||||
box-shadow: 0 0 0 2px white;
|
||||
/* Możesz też zmienić kolor istniejącej ramki, ale box-shadow jest bardziej wyrazisty */
|
||||
/* border-color: white; */
|
||||
}
|
||||
|
||||
.painter-button.matting-button {
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.painter-button.matting-button.loading {
|
||||
padding-right: 36px; /* Make space for spinner */
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.painter-button.matting-button .matting-spinner {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: matting-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.painter-button.matting-button.loading .matting-spinner {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes matting-spin {
|
||||
to {
|
||||
transform: translateY(-50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
.painter-modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 111;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.painter-modal-content {
|
||||
width: 90vw;
|
||||
height: 90vh;
|
||||
background-color: #353535;
|
||||
border: 1px solid #222;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 5px 25px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.painterMainContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.painterCanvasContainer {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
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;
|
||||
}
|
||||
230
js/css/layers_panel.css
Normal file
230
js/css/layers_panel.css
Normal file
@@ -0,0 +1,230 @@
|
||||
/* Layers Panel Styles */
|
||||
.layers-panel {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layers-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.layers-panel-title {
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layers-panel-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layers-btn {
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.layers-btn:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
.layers-btn:active {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layers-btn:disabled {
|
||||
background: #2a2a2a;
|
||||
color: #666666;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.layers-btn:disabled:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layers-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 4px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.layer-row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.layer-row.selected {
|
||||
background: #2d5aa0 !important;
|
||||
box-shadow: inset 0 0 0 1px #4a7bc8;
|
||||
}
|
||||
|
||||
.layer-row.dragging {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.layer-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layer-thumbnail::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #555 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #555 75%);
|
||||
background-size: 8px 8px;
|
||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name.editing {
|
||||
background: #4a4a4a;
|
||||
border: 1px solid #6a6a6a;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.drag-insertion-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #4a7bc8;
|
||||
border-radius: 1px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Icon container styles */
|
||||
.layers-panel .icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.layers-panel .icon-container img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.layers-panel .icon-container.visibility-hidden {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.layers-panel .icon-container.visibility-hidden img {
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.layers-panel .icon-container.fallback-text {
|
||||
font-size: 10px;
|
||||
color: #888888;
|
||||
}
|
||||
53
js/db.js
53
js/db.js
@@ -1,21 +1,17 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('db');
|
||||
|
||||
const DB_NAME = 'CanvasNodeDB';
|
||||
const STATE_STORE_NAME = 'CanvasState';
|
||||
const IMAGE_STORE_NAME = 'CanvasImages';
|
||||
const DB_VERSION = 3;
|
||||
|
||||
let db;
|
||||
|
||||
let db = null;
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów
|
||||
* @param {IDBObjectStore} store - Store IndexedDB
|
||||
* @param {string} operation - Nazwa operacji (get, put, delete, clear)
|
||||
* @param {*} data - Dane dla operacji (opcjonalne)
|
||||
* @param {DBRequestOperation} operation - Nazwa operacji (get, put, delete, clear)
|
||||
* @param {any} data - Dane dla operacji (opcjonalne)
|
||||
* @param {string} errorMessage - Wiadomość błędu
|
||||
* @returns {Promise} Promise z wynikiem operacji
|
||||
* @returns {Promise<any>} Promise z wynikiem operacji
|
||||
*/
|
||||
function createDBRequest(store, operation, data, errorMessage) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -37,130 +33,107 @@ function createDBRequest(store, operation, data, errorMessage) {
|
||||
reject(new Error(`Unknown operation: ${operation}`));
|
||||
return;
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
log.error(errorMessage, event.target.error);
|
||||
reject(errorMessage);
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
resolve(event.target.result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (db) {
|
||||
resolve(db);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Opening IndexedDB...");
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = (event) => {
|
||||
log.error("IndexedDB error:", event.target.error);
|
||||
reject("Error opening IndexedDB.");
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
db = event.target.result;
|
||||
log.info("IndexedDB opened successfully.");
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
log.info("Upgrading IndexedDB...");
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(STATE_STORE_NAME)) {
|
||||
db.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'});
|
||||
const dbInstance = event.target.result;
|
||||
if (!dbInstance.objectStoreNames.contains(STATE_STORE_NAME)) {
|
||||
dbInstance.createObjectStore(STATE_STORE_NAME, { keyPath: 'id' });
|
||||
log.info("Object store created:", STATE_STORE_NAME);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(IMAGE_STORE_NAME)) {
|
||||
db.createObjectStore(IMAGE_STORE_NAME, {keyPath: 'imageId'});
|
||||
if (!dbInstance.objectStoreNames.contains(IMAGE_STORE_NAME)) {
|
||||
dbInstance.createObjectStore(IMAGE_STORE_NAME, { keyPath: 'imageId' });
|
||||
log.info("Object store created:", IMAGE_STORE_NAME);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCanvasState(id) {
|
||||
log.info(`Getting state for id: ${id}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
const result = await createDBRequest(store, 'get', id, "Error getting canvas state");
|
||||
log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found');
|
||||
return result ? result.state : null;
|
||||
}
|
||||
|
||||
export async function setCanvasState(id, state) {
|
||||
log.info(`Setting state for id: ${id}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'put', {id, state}, "Error setting canvas state");
|
||||
await createDBRequest(store, 'put', { id, state }, "Error setting canvas state");
|
||||
log.debug(`Set success for id: ${id}`);
|
||||
}
|
||||
|
||||
export async function removeCanvasState(id) {
|
||||
log.info(`Removing state for id: ${id}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'delete', id, "Error removing canvas state");
|
||||
log.debug(`Remove success for id: ${id}`);
|
||||
}
|
||||
|
||||
export async function saveImage(imageId, imageSrc) {
|
||||
log.info(`Saving image with id: ${imageId}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'put', {imageId, imageSrc}, "Error saving image");
|
||||
await createDBRequest(store, 'put', { imageId, imageSrc }, "Error saving image");
|
||||
log.debug(`Image saved successfully for id: ${imageId}`);
|
||||
}
|
||||
|
||||
export async function getImage(imageId) {
|
||||
log.info(`Getting image with id: ${imageId}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
const result = await createDBRequest(store, 'get', imageId, "Error getting image");
|
||||
log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found');
|
||||
return result ? result.imageSrc : null;
|
||||
}
|
||||
|
||||
export async function removeImage(imageId) {
|
||||
log.info(`Removing image with id: ${imageId}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'delete', imageId, "Error removing image");
|
||||
log.debug(`Remove image success for id: ${imageId}`);
|
||||
}
|
||||
|
||||
export async function getAllImageIds() {
|
||||
log.info("Getting all image IDs...");
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAllKeys();
|
||||
|
||||
request.onerror = (event) => {
|
||||
log.error("Error getting all image IDs:", event.target.error);
|
||||
reject("Error getting all image IDs");
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const imageIds = event.target.result;
|
||||
log.debug(`Found ${imageIds.length} image IDs in database`);
|
||||
@@ -168,13 +141,11 @@ export async function getAllImageIds() {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearAllCanvasStates() {
|
||||
log.info("Clearing all canvas states...");
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'clear', null, "Error clearing canvas states");
|
||||
log.info("All canvas states cleared successfully.");
|
||||
}
|
||||
|
||||
115
js/logger.js
115
js/logger.js
@@ -8,6 +8,20 @@
|
||||
* - Możliwość zapisywania logów do localStorage
|
||||
* - Możliwość eksportu logów
|
||||
*/
|
||||
function padStart(str, targetLength, padString) {
|
||||
targetLength = targetLength >> 0;
|
||||
padString = String(padString || ' ');
|
||||
if (str.length > targetLength) {
|
||||
return String(str);
|
||||
}
|
||||
else {
|
||||
targetLength = targetLength - str.length;
|
||||
if (targetLength > padString.length) {
|
||||
padString += padString.repeat(targetLength / padString.length);
|
||||
}
|
||||
return padString.slice(0, targetLength) + String(str);
|
||||
}
|
||||
}
|
||||
export const LogLevel = {
|
||||
DEBUG: 0,
|
||||
INFO: 1,
|
||||
@@ -36,25 +50,22 @@ const LEVEL_NAMES = {
|
||||
[LogLevel.WARN]: 'WARN',
|
||||
[LogLevel.ERROR]: 'ERROR',
|
||||
};
|
||||
|
||||
class Logger {
|
||||
constructor() {
|
||||
this.config = {...DEFAULT_CONFIG};
|
||||
this.config = { ...DEFAULT_CONFIG };
|
||||
this.logs = [];
|
||||
this.enabled = true;
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfiguracja loggera
|
||||
* @param {Object} config - Obiekt konfiguracyjny
|
||||
* @param {Partial<LoggerConfig>} config - Obiekt konfiguracyjny
|
||||
*/
|
||||
configure(config) {
|
||||
this.config = {...this.config, ...config};
|
||||
this.config = { ...this.config, ...config };
|
||||
this.saveConfig();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Włącz/wyłącz logger globalnie
|
||||
* @param {boolean} enabled - Czy logger ma być włączony
|
||||
@@ -63,42 +74,39 @@ class Logger {
|
||||
this.enabled = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustaw globalny poziom logowania
|
||||
* @param {LogLevel} level - Poziom logowania
|
||||
* @param {LogLevels} level - Poziom logowania
|
||||
*/
|
||||
setGlobalLevel(level) {
|
||||
this.config.globalLevel = level;
|
||||
this.saveConfig();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustaw poziom logowania dla konkretnego modułu
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {LogLevel} level - Poziom logowania
|
||||
* @param {LogLevels} level - Poziom logowania
|
||||
*/
|
||||
setModuleLevel(module, level) {
|
||||
this.config.moduleSettings[module] = level;
|
||||
this.saveConfig();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdź, czy dany poziom logowania jest aktywny dla modułu
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {LogLevel} level - Poziom logowania do sprawdzenia
|
||||
* @param {LogLevels} level - Poziom logowania do sprawdzenia
|
||||
* @returns {boolean} - Czy poziom jest aktywny
|
||||
*/
|
||||
isLevelEnabled(module, level) {
|
||||
if (!this.enabled) return false;
|
||||
if (!this.enabled)
|
||||
return false;
|
||||
if (this.config.moduleSettings[module] !== undefined) {
|
||||
return level >= this.config.moduleSettings[module];
|
||||
}
|
||||
return level >= this.config.globalLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatuj znacznik czasu
|
||||
* @returns {string} - Sformatowany znacznik czasu
|
||||
@@ -107,21 +115,20 @@ class Logger {
|
||||
const now = new Date();
|
||||
const format = this.config.timestampFormat;
|
||||
return format
|
||||
.replace('HH', String(now.getHours()).padStart(2, '0'))
|
||||
.replace('mm', String(now.getMinutes()).padStart(2, '0'))
|
||||
.replace('ss', String(now.getSeconds()).padStart(2, '0'))
|
||||
.replace('SSS', String(now.getMilliseconds()).padStart(3, '0'));
|
||||
.replace('HH', padStart(String(now.getHours()), 2, '0'))
|
||||
.replace('mm', padStart(String(now.getMinutes()), 2, '0'))
|
||||
.replace('ss', padStart(String(now.getSeconds()), 2, '0'))
|
||||
.replace('SSS', padStart(String(now.getMilliseconds()), 3, '0'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisz log
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {LogLevel} level - Poziom logowania
|
||||
* @param {Array} args - Argumenty do zalogowania
|
||||
* @param {LogLevels} level - Poziom logowania
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
log(module, level, ...args) {
|
||||
if (!this.isLevelEnabled(module, level)) return;
|
||||
|
||||
if (!this.isLevelEnabled(module, level))
|
||||
return;
|
||||
const timestamp = this.formatTimestamp();
|
||||
const levelName = LEVEL_NAMES[level];
|
||||
const logData = {
|
||||
@@ -141,13 +148,12 @@ class Logger {
|
||||
}
|
||||
this.printToConsole(logData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wyświetl log w konsoli
|
||||
* @param {Object} logData - Dane logu
|
||||
* @param {LogData} logData - Dane logu
|
||||
*/
|
||||
printToConsole(logData) {
|
||||
const {timestamp, module, level, levelName, args} = logData;
|
||||
const { timestamp, module, level, levelName, args } = logData;
|
||||
const prefix = `[${timestamp}] [${module}] [${levelName}]`;
|
||||
if (this.config.useColors && typeof console.log === 'function') {
|
||||
const color = COLORS[level] || '#000000';
|
||||
@@ -156,36 +162,35 @@ class Logger {
|
||||
}
|
||||
console.log(prefix, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisz logi do localStorage
|
||||
*/
|
||||
saveLogs() {
|
||||
if (typeof localStorage !== 'undefined' && this.config.saveToStorage) {
|
||||
try {
|
||||
const simplifiedLogs = this.logs.map(log => ({
|
||||
const simplifiedLogs = this.logs.map((log) => ({
|
||||
t: log.timestamp,
|
||||
m: log.module,
|
||||
l: log.level,
|
||||
a: log.args.map(arg => {
|
||||
a: log.args.map((arg) => {
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return arg;
|
||||
})
|
||||
}));
|
||||
|
||||
localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs));
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to save logs to localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Załaduj logi z localStorage
|
||||
*/
|
||||
@@ -196,12 +201,12 @@ class Logger {
|
||||
if (storedLogs) {
|
||||
this.logs = JSON.parse(storedLogs);
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to load logs from localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisz konfigurację do localStorage
|
||||
*/
|
||||
@@ -209,12 +214,12 @@ class Logger {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config));
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to save logger config to localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Załaduj konfigurację z localStorage
|
||||
*/
|
||||
@@ -223,14 +228,14 @@ class Logger {
|
||||
try {
|
||||
const storedConfig = localStorage.getItem('layerforge_logger_config');
|
||||
if (storedConfig) {
|
||||
this.config = {...this.config, ...JSON.parse(storedConfig)};
|
||||
this.config = { ...this.config, ...JSON.parse(storedConfig) };
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to load logger config from localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wyczyść wszystkie logi
|
||||
*/
|
||||
@@ -241,33 +246,29 @@ class Logger {
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eksportuj logi do pliku
|
||||
* @param {string} format - Format eksportu ('json' lub 'txt')
|
||||
* @param {'json' | 'txt'} format - Format eksportu
|
||||
*/
|
||||
exportLogs(format = 'json') {
|
||||
if (this.logs.length === 0) {
|
||||
console.warn('No logs to export');
|
||||
return;
|
||||
}
|
||||
|
||||
let content;
|
||||
let mimeType;
|
||||
let extension;
|
||||
|
||||
if (format === 'json') {
|
||||
content = JSON.stringify(this.logs, null, 2);
|
||||
mimeType = 'application/json';
|
||||
extension = 'json';
|
||||
} else {
|
||||
content = this.logs.map(log =>
|
||||
`[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`
|
||||
).join('\n');
|
||||
}
|
||||
else {
|
||||
content = this.logs.map((log) => `[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`).join('\n');
|
||||
mimeType = 'text/plain';
|
||||
extension = 'txt';
|
||||
}
|
||||
const blob = new Blob([content], {type: mimeType});
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -277,44 +278,39 @@ class Logger {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie DEBUG
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {...any} args - Argumenty do zalogowania
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
debug(module, ...args) {
|
||||
this.log(module, LogLevel.DEBUG, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie INFO
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {...any} args - Argumenty do zalogowania
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
info(module, ...args) {
|
||||
this.log(module, LogLevel.INFO, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie WARN
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {...any} args - Argumenty do zalogowania
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
warn(module, ...args) {
|
||||
this.log(module, LogLevel.WARN, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie ERROR
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {...any} args - Argumenty do zalogowania
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
error(module, ...args) {
|
||||
this.log(module, LogLevel.ERROR, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
export const debug = (module, ...args) => logger.debug(module, ...args);
|
||||
export const info = (module, ...args) => logger.info(module, ...args);
|
||||
@@ -323,5 +319,4 @@ export const error = (module, ...args) => logger.error(module, ...args);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.LayerForgeLogger = logger;
|
||||
}
|
||||
|
||||
export default logger;
|
||||
export default logger;
|
||||
|
||||
79
js/state-saver.worker.js
Normal file
79
js/state-saver.worker.js
Normal file
@@ -0,0 +1,79 @@
|
||||
"use strict";
|
||||
console.log('[StateWorker] Worker script loaded and running.');
|
||||
const DB_NAME = 'CanvasNodeDB';
|
||||
const STATE_STORE_NAME = 'CanvasState';
|
||||
const DB_VERSION = 3;
|
||||
let db;
|
||||
function log(...args) {
|
||||
console.log('[StateWorker]', ...args);
|
||||
}
|
||||
function error(...args) {
|
||||
console.error('[StateWorker]', ...args);
|
||||
}
|
||||
function createDBRequest(store, operation, data, errorMessage) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
switch (operation) {
|
||||
case 'put':
|
||||
request = store.put(data);
|
||||
break;
|
||||
default:
|
||||
reject(new Error(`Unknown operation: ${operation}`));
|
||||
return;
|
||||
}
|
||||
request.onerror = (event) => {
|
||||
error(errorMessage, event.target.error);
|
||||
reject(errorMessage);
|
||||
};
|
||||
request.onsuccess = (event) => {
|
||||
resolve(event.target.result);
|
||||
};
|
||||
});
|
||||
}
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (db) {
|
||||
resolve(db);
|
||||
return;
|
||||
}
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = (event) => {
|
||||
error("IndexedDB error:", event.target.error);
|
||||
reject("Error opening IndexedDB.");
|
||||
};
|
||||
request.onsuccess = (event) => {
|
||||
db = event.target.result;
|
||||
log("IndexedDB opened successfully in worker.");
|
||||
resolve(db);
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
log("Upgrading IndexedDB in worker...");
|
||||
const tempDb = event.target.result;
|
||||
if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) {
|
||||
tempDb.createObjectStore(STATE_STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
async function setCanvasState(id, state) {
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
await createDBRequest(store, 'put', { id, state }, "Error setting canvas state");
|
||||
}
|
||||
self.onmessage = async function (e) {
|
||||
log('Message received from main thread:', e.data ? 'data received' : 'no data');
|
||||
const { state, nodeId } = e.data;
|
||||
if (!state || !nodeId) {
|
||||
error('Invalid data received from main thread');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
log(`Saving state for node: ${nodeId}`);
|
||||
await setCanvasState(nodeId, state);
|
||||
log(`State saved successfully for node: ${nodeId}`);
|
||||
}
|
||||
catch (err) {
|
||||
error(`Failed to save state for node: ${nodeId}`, err);
|
||||
}
|
||||
};
|
||||
13
js/templates/clipspace_clipboard_tooltip.html
Normal file
13
js/templates/clipspace_clipboard_tooltip.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<h4>📋 ComfyUI Clipspace Mode</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>ComfyUI Clipspace</strong> as flattened image</td></tr>
|
||||
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
|
||||
<tr><td></td><td>1️⃣ Internal clipboard (copied layers)</td></tr>
|
||||
<tr><td></td><td>2️⃣ ComfyUI Clipspace (workflow images)</td></tr>
|
||||
<tr><td></td><td>3️⃣ System clipboard (fallback)</td></tr>
|
||||
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
|
||||
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
|
||||
</table>
|
||||
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
|
||||
💡 <strong>Bestt for:</strong> ComfyUI workflow integration and node-to-node image transfer
|
||||
</div>
|
||||
9
js/templates/mask_shortcuts.html
Normal file
9
js/templates/mask_shortcuts.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<h4>Mask Mode</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Click + Drag</kbd></td><td>Paint on the mask</td></tr>
|
||||
<tr><td><kbd>Middle Mouse Button + Drag</kbd></td><td>Pan canvas view</td></tr>
|
||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
|
||||
<tr><td><strong>Brush Controls</strong></td><td>Use sliders to control brush <strong>Size</strong>, <strong>Strength</strong>, and <strong>Hardness</strong></td></tr>
|
||||
<tr><td><strong>Clear Mask</strong></td><td>Remove the entire mask</td></tr>
|
||||
<tr><td><strong>Exit Mode</strong></td><td>Click the "Draw Mask" button again</td></tr>
|
||||
</table>
|
||||
42
js/templates/standard_shortcuts.html
Normal file
42
js/templates/standard_shortcuts.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<h4>Canvas Control</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Click + Drag</kbd></td><td>Pan canvas view</td></tr>
|
||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
|
||||
<tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr>
|
||||
<tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr>
|
||||
<tr><td><kbd>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>
|
||||
<table>
|
||||
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layer(s)</td></tr>
|
||||
<tr><td><kbd>Ctrl + V</kbd></td><td>Paste from clipboard (image or internal layers)</td></tr>
|
||||
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Layer Interaction</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
|
||||
<tr><td><kbd>Ctrl + Click</kbd></td><td>Add/Remove layer from selection</td></tr>
|
||||
<tr><td><kbd>Alt + Drag</kbd></td><td>Clone selected layer(s)</td></tr>
|
||||
<tr><td><kbd>Right Click</kbd></td><td>Show blend mode & opacity menu</td></tr>
|
||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Scale layer (snaps to grid)</td></tr>
|
||||
<tr><td><kbd>Ctrl + Mouse Wheel</kbd></td><td>Fine-scale layer</td></tr>
|
||||
<tr><td><kbd>Shift + Mouse Wheel</kbd></td><td>Rotate layer by 5° steps</td></tr>
|
||||
<tr><td><kbd>Shift + Ctrl + Mouse Wheel</kbd></td><td>Snap rotation to 5° increments</td></tr>
|
||||
<tr><td><kbd>Arrow Keys</kbd></td><td>Nudge layer by 1px</td></tr>
|
||||
<tr><td><kbd>Shift + Arrow Keys</kbd></td><td>Nudge layer by 10px</td></tr>
|
||||
<tr><td><kbd>[</kbd> or <kbd>]</kbd></td><td>Rotate by 1°</td></tr>
|
||||
<tr><td><kbd>Shift + [</kbd> or <kbd>]</kbd></td><td>Rotate by 10°</td></tr>
|
||||
<tr><td><kbd>Delete</kbd></td><td>Delete selected layer(s)</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Transform Handles (on selected layer)</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Drag Corner/Side</kbd></td><td>Resize layer</td></tr>
|
||||
<tr><td><kbd>Drag Rotation Handle</kbd></td><td>Rotate layer</td></tr>
|
||||
<tr><td><kbd>Hold Shift</kbd></td><td>Keep aspect ratio / Snap rotation to 15°</td></tr>
|
||||
<tr><td><kbd>Hold Ctrl</kbd></td><td>Snap to grid</td></tr>
|
||||
</table>
|
||||
16
js/templates/system_clipboard_tooltip.html
Normal file
16
js/templates/system_clipboard_tooltip.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<h4>📋 System Clipboard Mode</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>system clipboard</strong> as flattened image</td></tr>
|
||||
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
|
||||
<tr><td></td><td>1️⃣ Internal clipboard (copied layers)</td></tr>
|
||||
<tr><td></td><td>2️⃣ System clipboard (images, screenshots)</td></tr>
|
||||
<tr><td></td><td>3️⃣ System clipboard (file paths, URLs)</td></tr>
|
||||
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
|
||||
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
|
||||
</table>
|
||||
<div style="margin-top: 8px; padding: 6px; background: rgba(255,165,0,0.2); border: 1px solid rgba(255,165,0,0.4); border-radius: 4px; font-size: 11px;">
|
||||
⚠️ <strong>Security Note:</strong> "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop.
|
||||
</div>
|
||||
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
|
||||
💡 <strong>Best for:</strong> Working with screenshots, copied images, file paths, and urls.
|
||||
</div>
|
||||
1
js/types.js
Normal file
1
js/types.js
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
427
js/utils/ClipboardManager.js
Normal file
427
js/utils/ClipboardManager.js
Normal file
@@ -0,0 +1,427 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { showNotification, showInfoNotification } from "./NotificationUtils.js";
|
||||
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
|
||||
import { safeClipspacePaste } from "./ClipspaceUtils.js";
|
||||
// @ts-ignore
|
||||
import { api } from "../../../scripts/api.js";
|
||||
const log = createModuleLogger('ClipboardManager');
|
||||
export class ClipboardManager {
|
||||
constructor(canvas) {
|
||||
/**
|
||||
* 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");
|
||||
this.canvas.canvasLayers.pasteLayers();
|
||||
return true;
|
||||
}
|
||||
if (preference === 'clipspace') {
|
||||
log.info("Attempting paste from ComfyUI Clipspace");
|
||||
const success = await this.tryClipspacePaste(addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
log.info("No image found in ComfyUI Clipspace");
|
||||
}
|
||||
log.info("Attempting paste from system clipboard");
|
||||
return await this.trySystemClipboardPaste(addMode);
|
||||
}, '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");
|
||||
// Use the unified clipspace validation and paste function
|
||||
const pasteSuccess = safeClipspacePaste(this.canvas.node);
|
||||
if (!pasteSuccess) {
|
||||
log.debug("Safe clipspace paste failed");
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}, '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
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async trySystemClipboardPaste(addMode) {
|
||||
log.info("ClipboardManager: Checking system clipboard for images and paths");
|
||||
if (navigator.clipboard?.read) {
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
for (const item of clipboardItems) {
|
||||
log.debug("Clipboard item types:", item.types);
|
||||
const imageType = item.types.find(type => type.startsWith('image/'));
|
||||
if (imageType) {
|
||||
try {
|
||||
const blob = await item.getType(imageType);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from system clipboard");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
};
|
||||
if (event.target?.result) {
|
||||
img.src = event.target.result;
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
log.info("Found image data in system clipboard");
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
log.debug("Error reading image data:", error);
|
||||
}
|
||||
}
|
||||
const textTypes = ['text/plain', 'text/uri-list'];
|
||||
for (const textType of textTypes) {
|
||||
if (item.types.includes(textType)) {
|
||||
try {
|
||||
const textBlob = await item.getType(textType);
|
||||
const text = await textBlob.text();
|
||||
if (this.isValidImagePath(text)) {
|
||||
log.info("Found image path in clipboard:", text);
|
||||
const success = await this.loadImageFromPath(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.debug(`Error reading ${textType}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.debug("Modern clipboard API failed:", error);
|
||||
}
|
||||
}
|
||||
if (navigator.clipboard?.readText) {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
log.debug("Found text in clipboard:", text);
|
||||
if (text && this.isValidImagePath(text)) {
|
||||
log.info("Found valid image path in clipboard:", text);
|
||||
const success = await this.loadImageFromPath(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.debug("Could not read text from clipboard:", error);
|
||||
}
|
||||
}
|
||||
log.debug("No images or valid image paths found in system clipboard");
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Validates if a text string is a valid image file path or URL
|
||||
* @param {string} text - The text to validate
|
||||
* @returns {boolean} - True if the text appears to be a valid image file path or URL
|
||||
*/
|
||||
isValidImagePath(text) {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
text = text.trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) {
|
||||
try {
|
||||
new URL(text);
|
||||
log.debug("Detected valid URL:", text);
|
||||
return true;
|
||||
}
|
||||
catch (e) {
|
||||
log.debug("Invalid URL format:", text);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const imageExtensions = [
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp',
|
||||
'.svg', '.tiff', '.tif', '.ico', '.avif'
|
||||
];
|
||||
const hasImageExtension = imageExtensions.some(ext => text.toLowerCase().endsWith(ext));
|
||||
if (!hasImageExtension) {
|
||||
log.debug("No valid image extension found in:", text);
|
||||
return false;
|
||||
}
|
||||
const pathPatterns = [
|
||||
/^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...)
|
||||
/^[\\\/]/, // Unix absolute path (/...)
|
||||
/^\.{1,2}[\\\/]/, // Relative path (./... or ../...)
|
||||
/^[^\\\/]*[\\\/]/ // Contains path separators
|
||||
];
|
||||
const isValidPath = pathPatterns.some(pattern => pattern.test(text)) ||
|
||||
(!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename
|
||||
if (isValidPath) {
|
||||
log.debug("Detected valid local file path:", text);
|
||||
}
|
||||
else {
|
||||
log.debug("Invalid local file path format:", text);
|
||||
}
|
||||
return isValidPath;
|
||||
}
|
||||
/**
|
||||
* Attempts to load an image from a file path using simplified methods
|
||||
* @param {string} filePath - The file path to load
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async loadImageFromPath(filePath, addMode) {
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
return new Promise((resolve) => {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from URL");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load image from URL:", filePath);
|
||||
resolve(false);
|
||||
};
|
||||
img.src = filePath;
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
log.warn("Error loading image from URL:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
log.info("Attempting to load local file via backend");
|
||||
const success = await this.loadFileViaBackend(filePath, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.warn("Backend loading failed:", error);
|
||||
}
|
||||
try {
|
||||
log.info("Falling back to file picker");
|
||||
const success = await this.promptUserForFile(filePath, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.warn("File picker failed:", error);
|
||||
}
|
||||
this.showFilePathMessage(filePath);
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Prompts the user to select a file when a local path is detected
|
||||
* @param {string} originalPath - The original file path from clipboard
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async promptUserForFile(originalPath, addMode) {
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
const fileName = originalPath.split(/[\\\/]/).pop();
|
||||
fileInput.onchange = async (event) => {
|
||||
const target = event.target;
|
||||
const file = target.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from file picker");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load selected image");
|
||||
resolve(false);
|
||||
};
|
||||
if (e.target?.result) {
|
||||
img.src = e.target.result;
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
log.warn("Failed to read selected file");
|
||||
resolve(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
catch (error) {
|
||||
log.warn("Error processing selected file:", error);
|
||||
resolve(false);
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.warn("Selected file is not an image");
|
||||
resolve(false);
|
||||
}
|
||||
document.body.removeChild(fileInput);
|
||||
};
|
||||
fileInput.oncancel = () => {
|
||||
log.info("File selection cancelled by user");
|
||||
document.body.removeChild(fileInput);
|
||||
resolve(false);
|
||||
};
|
||||
showInfoNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Shows a message to the user about file path limitations
|
||||
* @param {string} filePath - The file path that couldn't be loaded
|
||||
*/
|
||||
showFilePathMessage(filePath) {
|
||||
const fileName = filePath.split(/[\\\/]/).pop();
|
||||
const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`;
|
||||
showNotification(message, "#c54747", 5000);
|
||||
log.info("Showed file path limitation message to user");
|
||||
}
|
||||
/**
|
||||
* Shows a helpful message when clipboard appears empty and offers file picker
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
*/
|
||||
showEmptyClipboardMessage(addMode) {
|
||||
const message = `Copied a file? Browser can't access file paths for security. Click here to select the file manually.`;
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #2d5aa0;
|
||||
color: white;
|
||||
padding: 14px 18px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
z-index: 10001;
|
||||
max-width: 320px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
border: 2px solid #4a7bc8;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
`;
|
||||
notification.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 18px;">📁</span>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; opacity: 0.9; margin-top: 4px;">
|
||||
💡 Tip: You can also drag & drop files directly onto the canvas
|
||||
</div>
|
||||
`;
|
||||
notification.onmouseenter = () => {
|
||||
notification.style.backgroundColor = '#3d6bb0';
|
||||
notification.style.borderColor = '#5a8bd8';
|
||||
notification.style.transform = 'translateY(-1px)';
|
||||
};
|
||||
notification.onmouseleave = () => {
|
||||
notification.style.backgroundColor = '#2d5aa0';
|
||||
notification.style.borderColor = '#4a7bc8';
|
||||
notification.style.transform = 'translateY(0)';
|
||||
};
|
||||
notification.onclick = async () => {
|
||||
document.body.removeChild(notification);
|
||||
try {
|
||||
const success = await this.promptUserForFile('image_file.jpg', addMode);
|
||||
if (success) {
|
||||
log.info("Successfully loaded image via empty clipboard file picker");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.warn("Error with empty clipboard file picker:", error);
|
||||
}
|
||||
};
|
||||
document.body.appendChild(notification);
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 12000);
|
||||
log.info("Showed enhanced empty clipboard message with file picker option");
|
||||
}
|
||||
}
|
||||
99
js/utils/ClipspaceUtils.js
Normal file
99
js/utils/ClipspaceUtils.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
// @ts-ignore
|
||||
import { ComfyApp } from "../../../scripts/app.js";
|
||||
const log = createModuleLogger('ClipspaceUtils');
|
||||
/**
|
||||
* Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors
|
||||
* @returns {boolean} - True if clipspace is valid and ready to use, false otherwise
|
||||
*/
|
||||
export function validateAndFixClipspace() {
|
||||
log.debug("Validating and fixing clipspace structure");
|
||||
// Check if clipspace exists
|
||||
if (!ComfyApp.clipspace) {
|
||||
log.debug("ComfyUI clipspace is not available");
|
||||
return false;
|
||||
}
|
||||
// Validate clipspace structure
|
||||
if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) {
|
||||
log.debug("ComfyUI clipspace has no images");
|
||||
return false;
|
||||
}
|
||||
log.debug("Current clipspace state:", {
|
||||
hasImgs: !!ComfyApp.clipspace.imgs,
|
||||
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||
img_paste_mode: ComfyApp.clipspace.img_paste_mode
|
||||
});
|
||||
// Ensure required indices are set
|
||||
if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) {
|
||||
ComfyApp.clipspace.selectedIndex = 0;
|
||||
log.debug("Fixed clipspace selectedIndex to 0");
|
||||
}
|
||||
if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) {
|
||||
ComfyApp.clipspace.combinedIndex = 0;
|
||||
log.debug("Fixed clipspace combinedIndex to 0");
|
||||
}
|
||||
if (!ComfyApp.clipspace.img_paste_mode) {
|
||||
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||
log.debug("Fixed clipspace img_paste_mode to 'selected'");
|
||||
}
|
||||
// Ensure indices are within bounds
|
||||
const maxIndex = ComfyApp.clipspace.imgs.length - 1;
|
||||
if (ComfyApp.clipspace.selectedIndex > maxIndex) {
|
||||
ComfyApp.clipspace.selectedIndex = maxIndex;
|
||||
log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`);
|
||||
}
|
||||
if (ComfyApp.clipspace.combinedIndex > maxIndex) {
|
||||
ComfyApp.clipspace.combinedIndex = maxIndex;
|
||||
log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`);
|
||||
}
|
||||
// Verify the image at combinedIndex exists and has src
|
||||
const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||
if (!combinedImg || !combinedImg.src) {
|
||||
log.debug("Image at combinedIndex is missing or has no src, trying to find valid image");
|
||||
// Try to use the first available image
|
||||
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
|
||||
if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) {
|
||||
ComfyApp.clipspace.combinedIndex = i;
|
||||
log.debug(`Fixed combinedIndex to ${i} (first valid image)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Final check - if still no valid image found
|
||||
const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
|
||||
if (!finalImg || !finalImg.src) {
|
||||
log.error("No valid images found in clipspace after attempting fixes");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
log.debug("Final clipspace structure:", {
|
||||
selectedIndex: ComfyApp.clipspace.selectedIndex,
|
||||
combinedIndex: ComfyApp.clipspace.combinedIndex,
|
||||
img_paste_mode: ComfyApp.clipspace.img_paste_mode,
|
||||
imgsLength: ComfyApp.clipspace.imgs?.length,
|
||||
combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...'
|
||||
});
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure
|
||||
* @param {any} node - The ComfyUI node to paste to
|
||||
* @returns {boolean} - True if paste was successful, false otherwise
|
||||
*/
|
||||
export function safeClipspacePaste(node) {
|
||||
log.debug("Attempting safe clipspace paste");
|
||||
if (!validateAndFixClipspace()) {
|
||||
log.debug("Clipspace validation failed, cannot paste");
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
ComfyApp.pasteFromClipspace(node);
|
||||
log.debug("Successfully called pasteFromClipspace");
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error calling pasteFromClipspace:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
/**
|
||||
* CommonUtils - Wspólne funkcje pomocnicze
|
||||
* Eliminuje duplikację funkcji używanych w różnych modułach
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generuje unikalny identyfikator UUID
|
||||
* @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
*/
|
||||
export function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja snap do siatki
|
||||
* @param {number} value - Wartość do przyciągnięcia
|
||||
@@ -23,58 +17,48 @@ export function generateUUID() {
|
||||
export function snapToGrid(value, gridSize = 64) {
|
||||
return Math.round(value / gridSize) * gridSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Oblicza dostosowanie snap dla warstwy
|
||||
* @param {Object} layer - Obiekt warstwy
|
||||
* @param {number} gridSize - Rozmiar siatki
|
||||
* @param {number} snapThreshold - Próg przyciągania
|
||||
* @returns {Object} Obiekt z dx i dy
|
||||
* @returns {Point} Obiekt z dx i dy
|
||||
*/
|
||||
export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) {
|
||||
if (!layer) {
|
||||
return {dx: 0, dy: 0};
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const layerEdges = {
|
||||
left: layer.x,
|
||||
right: layer.x + layer.width,
|
||||
top: layer.y,
|
||||
bottom: layer.y + layer.height
|
||||
};
|
||||
|
||||
const x_adjustments = [
|
||||
{type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left},
|
||||
{type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right}
|
||||
];
|
||||
|
||||
{ type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left },
|
||||
{ type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right }
|
||||
].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
|
||||
const y_adjustments = [
|
||||
{type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top},
|
||||
{type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom}
|
||||
];
|
||||
|
||||
x_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
|
||||
y_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
|
||||
|
||||
{ type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top },
|
||||
{ type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom }
|
||||
].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
|
||||
const bestXSnap = x_adjustments
|
||||
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
||||
.sort((a, b) => a.abs - b.abs)[0];
|
||||
const bestYSnap = y_adjustments
|
||||
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
||||
.sort((a, b) => a.abs - b.abs)[0];
|
||||
|
||||
return {
|
||||
dx: bestXSnap ? bestXSnap.delta : 0,
|
||||
dy: bestYSnap ? bestYSnap.delta : 0
|
||||
x: bestXSnap ? bestXSnap.delta : 0,
|
||||
y: bestYSnap ? bestYSnap.delta : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje współrzędne świata na lokalne
|
||||
* @param {number} worldX - Współrzędna X w świecie
|
||||
* @param {number} worldY - Współrzędna Y w świecie
|
||||
* @param {Object} layerProps - Właściwości warstwy
|
||||
* @returns {Object} Lokalne współrzędne {x, y}
|
||||
* @param {any} layerProps - Właściwości warstwy
|
||||
* @returns {Point} Lokalne współrzędne {x, y}
|
||||
*/
|
||||
export function worldToLocal(worldX, worldY, layerProps) {
|
||||
const dx = worldX - layerProps.centerX;
|
||||
@@ -82,46 +66,38 @@ export function worldToLocal(worldX, worldY, layerProps) {
|
||||
const rad = -layerProps.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
return {
|
||||
x: dx * cos - dy * sin,
|
||||
y: dx * sin + dy * cos
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje współrzędne lokalne na świat
|
||||
* @param {number} localX - Lokalna współrzędna X
|
||||
* @param {number} localY - Lokalna współrzędna Y
|
||||
* @param {Object} layerProps - Właściwości warstwy
|
||||
* @returns {Object} Współrzędne świata {x, y}
|
||||
* @param {any} layerProps - Właściwości warstwy
|
||||
* @returns {Point} Współrzędne świata {x, y}
|
||||
*/
|
||||
export function localToWorld(localX, localY, layerProps) {
|
||||
const rad = layerProps.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
return {
|
||||
x: layerProps.centerX + localX * cos - localY * sin,
|
||||
y: layerProps.centerY + localX * sin + localY * cos
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Klonuje warstwy (bez klonowania obiektów Image dla oszczędności pamięci)
|
||||
* @param {Array} layers - Tablica warstw do sklonowania
|
||||
* @returns {Array} Sklonowane warstwy
|
||||
* @param {Layer[]} layers - Tablica warstw do sklonowania
|
||||
* @returns {Layer[]} Sklonowane warstwy
|
||||
*/
|
||||
export function cloneLayers(layers) {
|
||||
return layers.map(layer => {
|
||||
const newLayer = {...layer};
|
||||
return newLayer;
|
||||
});
|
||||
return layers.map(layer => ({ ...layer }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy sygnaturę stanu warstw (dla porównań)
|
||||
* @param {Array} layers - Tablica warstw
|
||||
* @param {Layer[]} layers - Tablica warstw
|
||||
* @returns {string} Sygnatura JSON
|
||||
*/
|
||||
export function getStateSignature(layers) {
|
||||
@@ -135,51 +111,51 @@ export function getStateSignature(layers) {
|
||||
rotation: Math.round((layer.rotation || 0) * 100) / 100,
|
||||
zIndex: layer.zIndex,
|
||||
blendMode: layer.blendMode || 'normal',
|
||||
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1
|
||||
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1,
|
||||
flipH: !!layer.flipH,
|
||||
flipV: !!layer.flipV
|
||||
};
|
||||
|
||||
if (layer.imageId) {
|
||||
sig.imageId = layer.imageId;
|
||||
}
|
||||
|
||||
if (layer.image && layer.image.src) {
|
||||
sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures
|
||||
}
|
||||
|
||||
return sig;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce funkcja - opóźnia wykonanie funkcji
|
||||
* @param {Function} func - Funkcja do wykonania
|
||||
* @param {number} wait - Czas oczekiwania w ms
|
||||
* @param {boolean} immediate - Czy wykonać natychmiast
|
||||
* @returns {Function} Funkcja z debounce
|
||||
* @returns {(...args: any[]) => void} Funkcja z debounce
|
||||
*/
|
||||
export function debounce(func, wait, immediate) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
if (!immediate) func(...args);
|
||||
if (!immediate)
|
||||
func.apply(this, args);
|
||||
};
|
||||
const callNow = immediate && !timeout;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
if (callNow) func(...args);
|
||||
if (timeout)
|
||||
clearTimeout(timeout);
|
||||
timeout = window.setTimeout(later, wait);
|
||||
if (callNow)
|
||||
func.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle funkcja - ogranicza częstotliwość wykonania
|
||||
* @param {Function} func - Funkcja do wykonania
|
||||
* @param {number} limit - Limit czasu w ms
|
||||
* @returns {Function} Funkcja z throttle
|
||||
* @returns {(...args: any[]) => void} Funkcja z throttle
|
||||
*/
|
||||
export function throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function(...args) {
|
||||
return function (...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
@@ -187,7 +163,6 @@ export function throttle(func, limit) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ogranicza wartość do zakresu
|
||||
* @param {number} value - Wartość do ograniczenia
|
||||
@@ -198,7 +173,6 @@ export function throttle(func, limit) {
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolacja liniowa między dwoma wartościami
|
||||
* @param {number} start - Wartość początkowa
|
||||
@@ -209,7 +183,6 @@ export function clamp(value, min, max) {
|
||||
export function lerp(start, end, factor) {
|
||||
return start + (end - start) * factor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje stopnie na radiany
|
||||
* @param {number} degrees - Stopnie
|
||||
@@ -218,7 +191,6 @@ export function lerp(start, end, factor) {
|
||||
export function degreesToRadians(degrees) {
|
||||
return degrees * Math.PI / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje radiany na stopnie
|
||||
* @param {number} radians - Radiany
|
||||
@@ -227,23 +199,23 @@ export function degreesToRadians(degrees) {
|
||||
export function radiansToDegrees(radians) {
|
||||
return radians * 180 / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy canvas z kontekstem - eliminuje duplikaty w kodzie
|
||||
* @param {number} width - Szerokość canvas
|
||||
* @param {number} height - Wysokość canvas
|
||||
* @param {string} contextType - Typ kontekstu (domyślnie '2d')
|
||||
* @param {Object} contextOptions - Opcje kontekstu
|
||||
* @returns {Object} Obiekt z canvas i ctx
|
||||
* @param {object} contextOptions - Opcje kontekstu
|
||||
* @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null}} Obiekt z canvas i ctx
|
||||
*/
|
||||
export function createCanvas(width, height, contextType = '2d', contextOptions = {}) {
|
||||
const canvas = document.createElement('canvas');
|
||||
if (width) canvas.width = width;
|
||||
if (height) canvas.height = height;
|
||||
if (width)
|
||||
canvas.width = width;
|
||||
if (height)
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext(contextType, contextOptions);
|
||||
return { canvas, ctx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizuje wartość do zakresu Uint8 (0-255)
|
||||
* @param {number} value - Wartość do znormalizowania (0-1)
|
||||
@@ -252,11 +224,10 @@ export function createCanvas(width, height, contextType = '2d', contextOptions =
|
||||
export function normalizeToUint8(value) {
|
||||
return Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generuje unikalną nazwę pliku z identyfikatorem node-a
|
||||
* @param {string} baseName - Podstawowa nazwa pliku
|
||||
* @param {string|number} nodeId - Identyfikator node-a
|
||||
* @param {string | number} nodeId - Identyfikator node-a
|
||||
* @returns {string} Unikalna nazwa pliku
|
||||
*/
|
||||
export function generateUniqueFileName(baseName, nodeId) {
|
||||
@@ -271,7 +242,6 @@ export function generateUniqueFileName(baseName, nodeId) {
|
||||
const nameWithoutExt = baseName.replace(`.${extension}`, '');
|
||||
return `${nameWithoutExt}_node_${nodeId}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdza czy punkt jest w prostokącie
|
||||
* @param {number} pointX - X punktu
|
||||
@@ -284,5 +254,5 @@ export function generateUniqueFileName(baseName, nodeId) {
|
||||
*/
|
||||
export function isPointInRect(pointX, pointY, rectX, rectY, rectWidth, rectHeight) {
|
||||
return pointX >= rectX && pointX <= rectX + rectWidth &&
|
||||
pointY >= rectY && pointY <= rectY + rectHeight;
|
||||
pointY >= rectY && pointY <= rectY + rectHeight;
|
||||
}
|
||||
|
||||
191
js/utils/IconLoader.js
Normal file
191
js/utils/IconLoader.js
Normal file
@@ -0,0 +1,191 @@
|
||||
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',
|
||||
CROP: 'crop',
|
||||
TRANSFORM: 'transform',
|
||||
};
|
||||
// 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 CROP_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 15h3V7c0-1.1-.9-2-2-2H10v3h7v7zM7 18V1H4v4H0v3h4v10c0 2 1 3 3 3h10v4h3v-4h4v-3H24z"/></svg>';
|
||||
const TRANSFORM_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M11.3 17.096c.092-.044.34-.052 1.028-.044l.912.008.124.124c.184.184.184.408.004.584l-.128.132-.896.012c-.72.008-.924 0-1.036-.048-.18-.072-.284-.264-.256-.452.028-.168.092-.248.248-.316Zm-3.164 0c.096-.044.328-.052 1.036-.044l.916.008.116.132c.16.18.16.396 0 .576l-.116.132-.876.012c-.552.008-.928-.004-1.02-.032-.388-.112-.428-.62-.056-.784Zm-4.6-1.168.112-.096 1.42.004 1.424.004.116.116.116.116V17.48v1.408l-.116.116-.116.116H5.068h-1.42l-.112-.096-.112-.096L3.42 17.48V16.032l.112-.096ZM4.78 12.336c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.964.964l-.116.128c-.1.112-.144.132-.304.132s-.204-.02-.304-.132L4.644 14.4l-.004-.964v-.964l.136-.136Zm8.868-.648c-.008-.024-.004-.048.008-.048s1.504.512 3.312 1.136c1.812.624 4.252 1.464 5.424 1.868 1.168.404 2.128.744 2.128.76 0 .012-.24.108-.528.212-.292.104-1.468.52-2.616.928l-2.08.74-.936 2.62c-.512 1.44-.944 2.616-.956 2.616-.016 0-.86-2.424-1.88-5.392-1.02-2.964-1.864-5.412-1.876-5.44ZM19.292 9.08c.216-.088.432-.02.548.168.076.124.08.188.072 1.06l-.012.928-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12-.012-.928c-.008-.872-.004-.936.072-1.06.044-.072.12-.148.172-.168Zm-14.516.096c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.956c0 1.064-.004 1.088-.268 1.2-.18.072-.376.012-.492-.148-.076-.104-.08-.172-.08-1.06V9.312l.136-.136ZM19.192 6c.096-.088.168-.116.288-.116s.192.028.288.116l.132.116V7.1v.98l-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12V7.096 6.112l.132-.116ZM4.816 5.964c.048-.044.152-.072.256-.072.144 0 .196.02.292.124l.116.124v.98.968l-.116.116c-.092.092-.152.116-.284.116-.408 0-.44-.28-.44-1.22s.012-1.016.176-1.148Zm9.516-3.192.14-.136.968.004h.968l.112.116c.152.152.188.3.108.468-.124.252-.196.276-1.044.288-.42.008-.84.004-.936-.012-.24-.036-.38-.192-.436-.408-.02-.156-.008-.184.12-.312Zm-3.156-.268.136.136h.956c1.064 0 1.088.004 1.2.268.072.172.016.372-.136.492-.096.076-.16.08-1.06.08h-.96l-.136-.136c-.104-.104-.136-.168-.136-.284s.032-.18.136-.284Zm-3.16 0 .136.136h.96c.94 0 .964.004 1.068.088.2.176.196.508-.004.668-.1.08-.156.084-1.064.084h-.96l-.136-.136c-.188-.188-.188-.38 0-.568Zm10.04-1.14c.044-.02.712-.032 1.476-.028l1.396.008.096.112.096.112v1.424 1.5l-.116.116-.116.116L19.48 4.72H18.072l-.116-.116-.116-.116V3.072c0-1.524.004-1.544.216-1.632ZM3.62 1.456c.184-.08 2.74-.08 2.896 0 .196.104.204.164.204 1.604s-.008 1.5-.204 1.604c-.148.076-2.732.084-2.896.008-.212-.096-.22-.148-.22-1.608s.008-1.516.22-1.608Z"/></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.CROP]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CROP_ICON_SVG)}`,
|
||||
[LAYERFORGE_TOOLS.TRANSFORM]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRANSFORM_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',
|
||||
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
|
||||
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
|
||||
};
|
||||
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,7 +1,7 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
|
||||
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:", {
|
||||
hasData: !!data,
|
||||
@@ -12,306 +12,212 @@ export function validateImageData(data) {
|
||||
dataType: data?.data ? data.data.constructor.name : null,
|
||||
fullData: data
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
log.info("Data is null or undefined");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
log.debug("Data is array, getting first element");
|
||||
data = data[0];
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
log.info("Invalid data type");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.data) {
|
||||
log.info("Missing data property");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(data.data instanceof Float32Array)) {
|
||||
try {
|
||||
data.data = new Float32Array(data.data);
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
log.error("Failed to convert data to Float32Array:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function convertImageData(data) {
|
||||
log.info("Converting image data:", data);
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
data = data[0];
|
||||
}
|
||||
|
||||
const shape = data.shape;
|
||||
const height = shape[1];
|
||||
const width = shape[2];
|
||||
const channels = shape[3];
|
||||
const floatData = new Float32Array(data.data);
|
||||
|
||||
log.debug("Processing dimensions:", {height, width, channels});
|
||||
|
||||
log.debug("Processing dimensions:", { height, width, channels });
|
||||
const rgbaData = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
for (let h = 0; h < height; h++) {
|
||||
for (let w = 0; w < width; w++) {
|
||||
const pixelIndex = (h * width + w) * 4;
|
||||
const tensorIndex = (h * width + w) * channels;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const value = floatData[tensorIndex + c];
|
||||
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
rgbaData[pixelIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: rgbaData,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMaskToImageData(imageData, maskData) {
|
||||
log.info("Applying mask to image data");
|
||||
|
||||
const rgbaData = new Uint8ClampedArray(imageData.data);
|
||||
const width = imageData.width;
|
||||
const height = imageData.height;
|
||||
|
||||
const maskShape = maskData.shape;
|
||||
const maskFloatData = new Float32Array(maskData.data);
|
||||
|
||||
log.debug(`Applying mask of shape: ${maskShape}`);
|
||||
|
||||
for (let h = 0; h < height; h++) {
|
||||
for (let w = 0; w < width; w++) {
|
||||
const pixelIndex = (h * width + w) * 4;
|
||||
const maskIndex = h * width + w;
|
||||
|
||||
const alpha = maskFloatData[maskIndex];
|
||||
rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255)));
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Mask application completed");
|
||||
|
||||
return {
|
||||
data: rgbaData,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
|
||||
export const prepareImageForCanvas = withErrorHandling(function(inputImage) {
|
||||
export const prepareImageForCanvas = withErrorHandling(function (inputImage) {
|
||||
log.info("Preparing image for canvas:", inputImage);
|
||||
|
||||
if (Array.isArray(inputImage)) {
|
||||
inputImage = inputImage[0];
|
||||
}
|
||||
|
||||
if (!inputImage || !inputImage.shape || !inputImage.data) {
|
||||
throw createValidationError("Invalid input image format", { inputImage });
|
||||
}
|
||||
|
||||
const shape = inputImage.shape;
|
||||
const height = shape[1];
|
||||
const width = shape[2];
|
||||
const channels = shape[3];
|
||||
const floatData = new Float32Array(inputImage.data);
|
||||
|
||||
log.debug("Image dimensions:", {height, width, channels});
|
||||
|
||||
log.debug("Image dimensions:", { height, width, channels });
|
||||
const rgbaData = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
for (let h = 0; h < height; h++) {
|
||||
for (let w = 0; w < width; w++) {
|
||||
const pixelIndex = (h * width + w) * 4;
|
||||
const tensorIndex = (h * width + w) * channels;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const value = floatData[tensorIndex + c];
|
||||
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
rgbaData[pixelIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: rgbaData,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}, 'prepareImageForCanvas');
|
||||
|
||||
/**
|
||||
* Konwertuje obraz PIL/Canvas na tensor
|
||||
* @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji
|
||||
* @returns {Promise<Object>} Tensor z danymi obrazu
|
||||
*/
|
||||
export const imageToTensor = withErrorHandling(async function(image) {
|
||||
export const imageToTensor = withErrorHandling(async function (image) {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = image.width || image.naturalWidth;
|
||||
canvas.height = image.height || image.naturalHeight;
|
||||
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = new Float32Array(canvas.width * canvas.height * 3);
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
const pixelIndex = i / 4;
|
||||
data[pixelIndex * 3] = imageData.data[i] / 255;
|
||||
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
|
||||
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
|
||||
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);
|
||||
const data = new Float32Array(canvas.width * canvas.height * 3);
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
const pixelIndex = i / 4;
|
||||
data[pixelIndex * 3] = imageData.data[i] / 255;
|
||||
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
|
||||
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
|
||||
}
|
||||
return {
|
||||
data: data,
|
||||
shape: [1, canvas.height, canvas.width, 3],
|
||||
width: canvas.width,
|
||||
height: canvas.height
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: data,
|
||||
shape: [1, canvas.height, canvas.width, 3],
|
||||
width: canvas.width,
|
||||
height: canvas.height
|
||||
};
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'imageToTensor');
|
||||
|
||||
/**
|
||||
* Konwertuje tensor na obraz HTML
|
||||
* @param {Object} tensor - Tensor z danymi obrazu
|
||||
* @returns {Promise<HTMLImageElement>} Obraz HTML
|
||||
*/
|
||||
export const tensorToImage = withErrorHandling(async function(tensor) {
|
||||
export const tensorToImage = withErrorHandling(async function (tensor) {
|
||||
if (!tensor || !tensor.data || !tensor.shape) {
|
||||
throw createValidationError("Invalid tensor format", { tensor });
|
||||
}
|
||||
|
||||
const [, height, width, channels] = tensor.shape;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = tensor.data;
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
const tensorIndex = i * channels;
|
||||
|
||||
imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255);
|
||||
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255);
|
||||
imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
|
||||
imageData.data[pixelIndex + 3] = 255;
|
||||
const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = tensor.data;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
const tensorIndex = i * channels;
|
||||
imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255);
|
||||
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255);
|
||||
imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
|
||||
imageData.data[pixelIndex + 3] = 255;
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'tensorToImage');
|
||||
|
||||
/**
|
||||
* Zmienia rozmiar obrazu z zachowaniem proporcji
|
||||
* @param {HTMLImageElement} image - Obraz do przeskalowania
|
||||
* @param {number} maxWidth - Maksymalna szerokość
|
||||
* @param {number} maxHeight - Maksymalna wysokość
|
||||
* @returns {Promise<HTMLImageElement>} Przeskalowany obraz
|
||||
*/
|
||||
export const resizeImage = withErrorHandling(async function(image, maxWidth, maxHeight) {
|
||||
export const resizeImage = withErrorHandling(async function (image, maxWidth, maxHeight) {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const originalWidth = image.width || image.naturalWidth;
|
||||
const originalHeight = image.height || image.naturalHeight;
|
||||
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;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
ctx.drawImage(image, 0, 0, newWidth, newHeight);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
const { canvas, ctx } = createCanvas(newWidth, newHeight, '2d', { willReadFrequently: true });
|
||||
if (ctx) {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(image, 0, 0, newWidth, newHeight);
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'resizeImage');
|
||||
|
||||
/**
|
||||
* Tworzy miniaturę obrazu
|
||||
* @param {HTMLImageElement} image - Obraz źródłowy
|
||||
* @param {number} size - Rozmiar miniatury (kwadrat)
|
||||
* @returns {Promise<HTMLImageElement>} Miniatura
|
||||
*/
|
||||
export const createThumbnail = withErrorHandling(async function(image, size = 128) {
|
||||
export const createThumbnail = withErrorHandling(async function (image, size = 128) {
|
||||
return resizeImage(image, size, size);
|
||||
}, 'createThumbnail');
|
||||
|
||||
/**
|
||||
* Konwertuje obraz na base64
|
||||
* @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji
|
||||
* @param {string} format - Format obrazu (png, jpeg, webp)
|
||||
* @param {number} quality - Jakość (0-1) dla formatów stratnych
|
||||
* @returns {string} Base64 string
|
||||
*/
|
||||
export const imageToBase64 = withErrorHandling(function(image, format = 'png', quality = 0.9) {
|
||||
export const imageToBase64 = withErrorHandling(function (image, format = 'png', quality = 0.9) {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = image.width || image.naturalWidth;
|
||||
canvas.height = image.height || image.naturalHeight;
|
||||
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
const mimeType = `image/${format}`;
|
||||
return canvas.toDataURL(mimeType, quality);
|
||||
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}`;
|
||||
return canvas.toDataURL(mimeType, quality);
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'imageToBase64');
|
||||
|
||||
/**
|
||||
* Konwertuje base64 na obraz
|
||||
* @param {string} base64 - Base64 string
|
||||
* @returns {Promise<HTMLImageElement>} Obraz
|
||||
*/
|
||||
export const base64ToImage = withErrorHandling(function(base64) {
|
||||
export const base64ToImage = withErrorHandling(function (base64) {
|
||||
if (!base64) {
|
||||
throw createValidationError("Base64 string is required");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
@@ -319,74 +225,92 @@ export const base64ToImage = withErrorHandling(function(base64) {
|
||||
img.src = base64;
|
||||
});
|
||||
}, 'base64ToImage');
|
||||
|
||||
/**
|
||||
* Sprawdza czy obraz jest prawidłowy
|
||||
* @param {HTMLImageElement} image - Obraz do sprawdzenia
|
||||
* @returns {boolean} Czy obraz jest prawidłowy
|
||||
*/
|
||||
export function isValidImage(image) {
|
||||
return image &&
|
||||
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
|
||||
image.width > 0 &&
|
||||
image.height > 0;
|
||||
return image &&
|
||||
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
|
||||
image.width > 0 &&
|
||||
image.height > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pobiera informacje o obrazie
|
||||
* @param {HTMLImageElement} image - Obraz
|
||||
* @returns {Object} Informacje o obrazie
|
||||
*/
|
||||
export function getImageInfo(image) {
|
||||
if (!isValidImage(image)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
|
||||
const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
|
||||
return {
|
||||
width: image.width || image.naturalWidth,
|
||||
height: image.height || image.naturalHeight,
|
||||
aspectRatio: (image.width || image.naturalWidth) / (image.height || image.naturalHeight),
|
||||
area: (image.width || image.naturalWidth) * (image.height || image.naturalHeight)
|
||||
width,
|
||||
height,
|
||||
aspectRatio: width / height,
|
||||
area: width * height
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy obraz z podanego źródła - eliminuje duplikaty w kodzie
|
||||
* @param {string} source - Źródło obrazu (URL, data URL, etc.)
|
||||
* @returns {Promise<HTMLImageElement>} Promise z obrazem
|
||||
*/
|
||||
export function createImageFromSource(source) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy pusty obraz o podanych wymiarach
|
||||
* @param {number} width - Szerokość
|
||||
* @param {number} height - Wysokość
|
||||
* @param {string} color - Kolor tła (CSS color)
|
||||
* @returns {Promise<HTMLImageElement>} Pusty obraz
|
||||
*/
|
||||
export const createEmptyImage = withErrorHandling(function(width, height, color = 'transparent') {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
if (color !== 'transparent') {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') {
|
||||
const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
|
||||
if (ctx) {
|
||||
if (color !== 'transparent') {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'createEmptyImage');
|
||||
/**
|
||||
* 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 = reject;
|
||||
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();
|
||||
});
|
||||
}, 'createEmptyImage');
|
||||
}
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
* LoggerUtils - Centralizacja inicjalizacji loggerów
|
||||
* Eliminuje powtarzalny kod inicjalizacji loggera w każdym module
|
||||
*/
|
||||
|
||||
import {logger, LogLevel} from "../logger.js";
|
||||
|
||||
import { logger, LogLevel } from "../logger.js";
|
||||
import { LOG_LEVEL } from '../config.js';
|
||||
/**
|
||||
* Tworzy obiekt loggera dla modułu z predefiniowanymi metodami
|
||||
* @param {string} moduleName - Nazwa modułu
|
||||
* @param {LogLevel} level - Poziom logowania (domyślnie DEBUG)
|
||||
* @returns {Object} Obiekt z metodami logowania
|
||||
* @returns {Logger} Obiekt z metodami logowania
|
||||
*/
|
||||
export function createModuleLogger(moduleName, level = LogLevel.NONE) {
|
||||
logger.setModuleLevel(moduleName, level);
|
||||
|
||||
export function createModuleLogger(moduleName) {
|
||||
logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL]);
|
||||
return {
|
||||
debug: (...args) => logger.debug(moduleName, ...args),
|
||||
info: (...args) => logger.info(moduleName, ...args),
|
||||
@@ -21,62 +18,57 @@ export function createModuleLogger(moduleName, level = LogLevel.NONE) {
|
||||
error: (...args) => logger.error(moduleName, ...args)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL
|
||||
* @param {LogLevel} level - Poziom logowania
|
||||
* @returns {Object} Obiekt z metodami logowania
|
||||
* @returns {Logger} Obiekt z metodami logowania
|
||||
*/
|
||||
export function createAutoLogger(level = LogLevel.DEBUG) {
|
||||
export function createAutoLogger() {
|
||||
const stack = new Error().stack;
|
||||
const match = stack.match(/\/([^\/]+)\.js/);
|
||||
const match = stack?.match(/\/([^\/]+)\.js/);
|
||||
const moduleName = match ? match[1] : 'Unknown';
|
||||
|
||||
return createModuleLogger(moduleName, level);
|
||||
return createModuleLogger(moduleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper dla operacji z automatycznym logowaniem błędów
|
||||
* @param {Function} operation - Operacja do wykonania
|
||||
* @param {Object} log - Obiekt loggera
|
||||
* @param {Logger} log - Obiekt loggera
|
||||
* @param {string} operationName - Nazwa operacji (dla logów)
|
||||
* @returns {Function} Opakowana funkcja
|
||||
*/
|
||||
export function withErrorLogging(operation, log, operationName) {
|
||||
return async function(...args) {
|
||||
return async function (...args) {
|
||||
try {
|
||||
log.debug(`Starting ${operationName}`);
|
||||
const result = await operation.apply(this, args);
|
||||
log.debug(`Completed ${operationName}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Error in ${operationName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator dla metod klasy z automatycznym logowaniem
|
||||
* @param {Object} log - Obiekt loggera
|
||||
* @param {Logger} log - Obiekt loggera
|
||||
* @param {string} methodName - Nazwa metody
|
||||
*/
|
||||
export function logMethod(log, methodName) {
|
||||
return function(target, propertyKey, descriptor) {
|
||||
return function (target, propertyKey, descriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function(...args) {
|
||||
descriptor.value = async function (...args) {
|
||||
try {
|
||||
log.debug(`${methodName || propertyKey} started`);
|
||||
const result = await originalMethod.apply(this, args);
|
||||
log.debug(`${methodName || propertyKey} completed`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`${methodName || propertyKey} failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
51
js/utils/ResourceManager.js
Normal file
51
js/utils/ResourceManager.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// @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) {
|
||||
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";
|
||||
}
|
||||
$el("link", {
|
||||
parent: document.head,
|
||||
rel: "stylesheet",
|
||||
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();
|
||||
}
|
||||
else {
|
||||
// @ts-ignore
|
||||
return new URL("../" + path, import.meta.url).toString();
|
||||
}
|
||||
}
|
||||
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 createNetworkError(`Failed to load template: ${url}`, {
|
||||
url,
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
});
|
||||
}
|
||||
const content = await response.text();
|
||||
log.debug('Template loaded successfully:', { path, contentLength: content.length });
|
||||
return content;
|
||||
}, 'loadTemplate');
|
||||
@@ -1,51 +1,34 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
|
||||
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(); // Store callbacks for messages awaiting ACK
|
||||
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;
|
||||
this.reconnectAttempts = 0;
|
||||
log.info("WebSocket connection established.");
|
||||
this.flushMessageQueue();
|
||||
};
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
log.debug("Received message:", data);
|
||||
|
||||
if (data.type === 'ack' && data.nodeId) {
|
||||
const callback = this.ackCallbacks.get(data.nodeId);
|
||||
if (callback) {
|
||||
@@ -54,106 +37,108 @@ class WebSocketManager {
|
||||
this.ackCallbacks.delete(data.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error parsing incoming WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
this.isConnecting = false;
|
||||
if (event.wasClean) {
|
||||
log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.warn("WebSocket connection died. Attempting to reconnect...");
|
||||
this.handleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
this.reconnectAttempts++;
|
||||
log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`);
|
||||
setTimeout(() => this.connect(), this.reconnectInterval);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
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) {
|
||||
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."));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
flushMessageQueue() {
|
||||
log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
|
||||
|
||||
|
||||
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift();
|
||||
this.socket.send(message);
|
||||
if (this.socket && message) {
|
||||
this.socket.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`;
|
||||
export const webSocketManager = new WebSocketManager(wsUrl);
|
||||
|
||||
150
js/utils/mask_utils.js
Normal file
150
js/utils/mask_utils.js
Normal file
@@ -0,0 +1,150 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
const log = createModuleLogger('MaskUtils');
|
||||
export function new_editor(app) {
|
||||
if (!app)
|
||||
return false;
|
||||
return !!app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
|
||||
}
|
||||
function get_mask_editor_element(app) {
|
||||
return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement ?? null;
|
||||
}
|
||||
export function mask_editor_showing(app) {
|
||||
const editor = get_mask_editor_element(app);
|
||||
return !!editor && editor.style.display !== "none";
|
||||
}
|
||||
export function hide_mask_editor(app) {
|
||||
if (mask_editor_showing(app)) {
|
||||
const editor = document.getElementById('maskEditor');
|
||||
if (editor) {
|
||||
editor.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
function get_mask_editor_cancel_button(app) {
|
||||
const cancelButton = document.getElementById("maskEditor_topBarCancelButton");
|
||||
if (cancelButton) {
|
||||
log.debug("Found cancel button by ID: maskEditor_topBarCancelButton");
|
||||
return cancelButton;
|
||||
}
|
||||
const cancelSelectors = [
|
||||
'button[onclick*="cancel"]',
|
||||
'button[onclick*="Cancel"]',
|
||||
'input[value="Cancel"]'
|
||||
];
|
||||
for (const selector of cancelSelectors) {
|
||||
try {
|
||||
const button = document.querySelector(selector);
|
||||
if (button) {
|
||||
log.debug("Found cancel button with selector:", selector);
|
||||
return button;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
log.warn("Invalid selector:", selector, e);
|
||||
}
|
||||
}
|
||||
const allButtons = document.querySelectorAll('button, input[type="button"]');
|
||||
for (const button of allButtons) {
|
||||
const text = button.textContent || button.value || '';
|
||||
if (text.toLowerCase().includes('cancel')) {
|
||||
log.debug("Found cancel button by text content:", text);
|
||||
return button;
|
||||
}
|
||||
}
|
||||
const editorElement = get_mask_editor_element(app);
|
||||
if (editorElement) {
|
||||
const childNodes = editorElement?.parentElement?.lastChild?.childNodes;
|
||||
if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) {
|
||||
return childNodes[2];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function get_mask_editor_save_button(app) {
|
||||
const saveButton = document.getElementById("maskEditor_topBarSaveButton");
|
||||
if (saveButton) {
|
||||
return saveButton;
|
||||
}
|
||||
const editorElement = get_mask_editor_element(app);
|
||||
if (editorElement) {
|
||||
const childNodes = editorElement?.parentElement?.lastChild?.childNodes;
|
||||
if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) {
|
||||
return childNodes[2];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function mask_editor_listen_for_cancel(app, callback) {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 sekund
|
||||
const findAndAttachListener = () => {
|
||||
attempts++;
|
||||
const cancel_button = get_mask_editor_cancel_button(app);
|
||||
if (cancel_button instanceof HTMLElement && !cancel_button.filter_listener_added) {
|
||||
log.info("Cancel button found, attaching listener");
|
||||
cancel_button.addEventListener('click', callback);
|
||||
cancel_button.filter_listener_added = true;
|
||||
}
|
||||
else if (attempts < maxAttempts) {
|
||||
setTimeout(findAndAttachListener, 100);
|
||||
}
|
||||
else {
|
||||
log.warn("Could not find cancel button after", maxAttempts, "attempts");
|
||||
const globalClickHandler = (event) => {
|
||||
const target = event.target;
|
||||
const text = target.textContent || target.value || '';
|
||||
if (target && (text.toLowerCase().includes('cancel') ||
|
||||
target.id.toLowerCase().includes('cancel') ||
|
||||
target.className.toLowerCase().includes('cancel'))) {
|
||||
log.info("Cancel detected via global click handler");
|
||||
callback();
|
||||
document.removeEventListener('click', globalClickHandler);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', globalClickHandler);
|
||||
log.debug("Added global click handler for cancel detection");
|
||||
}
|
||||
};
|
||||
findAndAttachListener();
|
||||
}
|
||||
export function press_maskeditor_save(app) {
|
||||
const button = get_mask_editor_save_button(app);
|
||||
if (button instanceof HTMLElement) {
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
export function press_maskeditor_cancel(app) {
|
||||
const button = get_mask_editor_cancel_button(app);
|
||||
if (button instanceof HTMLElement) {
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Uruchamia mask editor z predefiniowaną maską
|
||||
* @param {Canvas} canvasInstance - Instancja Canvas
|
||||
* @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia
|
||||
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
|
||||
*/
|
||||
export 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 const start_mask_editor_auto = withErrorHandling(function (canvasInstance) {
|
||||
if (!canvasInstance) {
|
||||
throw createValidationError('Canvas instance is required', { canvasInstance });
|
||||
}
|
||||
canvasInstance.startMaskEditor(null, true);
|
||||
}, '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.2.4"
|
||||
license = {file = "LICENSE"}
|
||||
version = "1.5.4"
|
||||
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 = ""
|
||||
3
python/config.py
Normal file
3
python/config.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# Log level for development.
|
||||
# Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE'
|
||||
LOG_LEVEL = 'NONE'
|
||||
318
src/BatchPreviewManager.ts
Normal file
318
src/BatchPreviewManager.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer, Point } from './types';
|
||||
|
||||
const log = createModuleLogger('BatchPreviewManager');
|
||||
|
||||
interface GenerationArea {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class BatchPreviewManager {
|
||||
public active: boolean;
|
||||
private canvas: Canvas;
|
||||
private counterElement: HTMLSpanElement | null;
|
||||
private currentIndex: number;
|
||||
private element: HTMLDivElement | null;
|
||||
public generationArea: GenerationArea | null;
|
||||
private isDragging: boolean;
|
||||
private layers: Layer[];
|
||||
private maskWasVisible: boolean;
|
||||
private uiInitialized: boolean;
|
||||
private worldX: number;
|
||||
private worldY: number;
|
||||
|
||||
constructor(canvas: Canvas, initialPosition: Point = { x: 0, y: 0 }, generationArea: GenerationArea | null = null) {
|
||||
this.canvas = canvas;
|
||||
this.active = false;
|
||||
this.layers = [];
|
||||
this.currentIndex = 0;
|
||||
this.element = null;
|
||||
this.counterElement = null;
|
||||
this.uiInitialized = false;
|
||||
this.maskWasVisible = false;
|
||||
|
||||
this.worldX = initialPosition.x;
|
||||
this.worldY = initialPosition.y;
|
||||
this.isDragging = false;
|
||||
this.generationArea = generationArea;
|
||||
}
|
||||
|
||||
updateScreenPosition(viewport: { x: number, y: number, zoom: number }): void {
|
||||
if (!this.active || !this.element) return;
|
||||
|
||||
const screenX = (this.worldX - viewport.x) * viewport.zoom;
|
||||
const screenY = (this.worldY - viewport.y) * viewport.zoom;
|
||||
|
||||
const scale = 1;
|
||||
|
||||
this.element.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`;
|
||||
}
|
||||
|
||||
private _createUI(): void {
|
||||
if (this.uiInitialized) return;
|
||||
|
||||
this.element = document.createElement('div');
|
||||
this.element.id = 'layerforge-batch-preview';
|
||||
this.element.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #333;
|
||||
color: white;
|
||||
padding: 8px 15px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
font-family: sans-serif;
|
||||
z-index: 1001;
|
||||
border: 1px solid #555;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
this.element.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).tagName === 'BUTTON') return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.isDragging = true;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (this.isDragging) {
|
||||
const deltaX = moveEvent.movementX / this.canvas.viewport.zoom;
|
||||
const deltaY = moveEvent.movementY / this.canvas.viewport.zoom;
|
||||
|
||||
this.worldX += deltaX;
|
||||
this.worldY += deltaY;
|
||||
|
||||
// The render loop will handle updating the screen position, but we need to trigger it.
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
this.isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
const prevButton = this._createButton('◀', 'Previous'); // Left arrow
|
||||
const nextButton = this._createButton('▶', 'Next'); // Right arrow
|
||||
const confirmButton = this._createButton('✔', 'Confirm'); // Checkmark
|
||||
const cancelButton = this._createButton('✖', 'Cancel All');
|
||||
const closeButton = this._createButton('➲', 'Close');
|
||||
|
||||
this.counterElement = document.createElement('span');
|
||||
this.counterElement.style.minWidth = '40px';
|
||||
this.counterElement.style.textAlign = 'center';
|
||||
this.counterElement.style.fontWeight = 'bold';
|
||||
|
||||
prevButton.onclick = () => this.navigate(-1);
|
||||
nextButton.onclick = () => this.navigate(1);
|
||||
confirmButton.onclick = () => this.confirm();
|
||||
cancelButton.onclick = () => this.cancelAndRemoveAll();
|
||||
closeButton.onclick = () => this.hide();
|
||||
|
||||
this.element.append(prevButton, this.counterElement, nextButton, confirmButton, cancelButton, closeButton);
|
||||
if (this.canvas.canvas.parentElement) {
|
||||
this.canvas.canvas.parentElement.appendChild(this.element);
|
||||
} else {
|
||||
log.error("Could not find parent node to attach batch preview UI.");
|
||||
}
|
||||
this.uiInitialized = true;
|
||||
}
|
||||
|
||||
private _createButton(innerHTML: string, title: string): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.innerHTML = innerHTML;
|
||||
button.title = title;
|
||||
button.style.cssText = `
|
||||
background: #555;
|
||||
color: white;
|
||||
border: 1px solid #777;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
button.onmouseover = () => button.style.background = '#666';
|
||||
button.onmouseout = () => button.style.background = '#555';
|
||||
return button;
|
||||
}
|
||||
|
||||
show(layers: Layer[]): void {
|
||||
if (!layers || layers.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._createUI();
|
||||
|
||||
// Auto-hide mask logic
|
||||
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
||||
if (this.maskWasVisible) {
|
||||
this.canvas.maskTool.toggleOverlayVisibility();
|
||||
const toggleSwitch = document.getElementById(`toggle-mask-switch-${this.canvas.node.id}`);
|
||||
if (toggleSwitch) {
|
||||
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
toggleSwitch.classList.remove('primary');
|
||||
const iconContainer = toggleSwitch.querySelector('.switch-icon') as HTMLElement;
|
||||
if (iconContainer) {
|
||||
iconContainer.style.opacity = '0.5';
|
||||
}
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
log.info(`Showing batch preview for ${layers.length} layers.`);
|
||||
this.layers = layers;
|
||||
this.currentIndex = 0;
|
||||
|
||||
if (this.element) {
|
||||
this.element.style.display = 'flex';
|
||||
}
|
||||
this.active = true;
|
||||
|
||||
if (this.element) {
|
||||
const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom;
|
||||
const paddingInWorld = 20 / this.canvas.viewport.zoom;
|
||||
|
||||
this.worldX -= menuWidthInWorld / 2;
|
||||
this.worldY += paddingInWorld;
|
||||
}
|
||||
|
||||
// Hide all batch layers initially, then show only the first one
|
||||
this.layers.forEach((layer: Layer) => {
|
||||
layer.visible = false;
|
||||
});
|
||||
|
||||
this._update();
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
log.info('Hiding batch preview.');
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
}
|
||||
this.active = false;
|
||||
|
||||
const index = this.canvas.batchPreviewManagers.indexOf(this);
|
||||
if (index > -1) {
|
||||
this.canvas.batchPreviewManagers.splice(index, 1);
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
|
||||
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
||||
this.canvas.maskTool.toggleOverlayVisibility();
|
||||
const toggleSwitch = document.getElementById(`toggle-mask-switch-${String(this.canvas.node.id)}`);
|
||||
if (toggleSwitch) {
|
||||
const checkbox = toggleSwitch.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
if (checkbox) {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
toggleSwitch.classList.add('primary');
|
||||
const iconContainer = toggleSwitch.querySelector('.switch-icon') as HTMLElement;
|
||||
if (iconContainer) {
|
||||
iconContainer.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
this.maskWasVisible = false;
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
navigate(direction: number): void {
|
||||
this.currentIndex += direction;
|
||||
if (this.currentIndex < 0) {
|
||||
this.currentIndex = this.layers.length - 1;
|
||||
} else if (this.currentIndex >= this.layers.length) {
|
||||
this.currentIndex = 0;
|
||||
}
|
||||
this._update();
|
||||
}
|
||||
|
||||
confirm(): void {
|
||||
const layerToKeep = this.layers[this.currentIndex];
|
||||
log.info(`Confirming selection: Keeping layer ${layerToKeep.id}.`);
|
||||
|
||||
const layersToDelete = this.layers.filter((l: Layer) => l.id !== layerToKeep.id);
|
||||
const layerIdsToDelete = layersToDelete.map((l: Layer) => l.id);
|
||||
|
||||
this.canvas.removeLayersByIds(layerIdsToDelete);
|
||||
log.info(`Deleted ${layersToDelete.length} other layers.`);
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
cancelAndRemoveAll(): void {
|
||||
log.info('Cancel clicked. Removing all new layers.');
|
||||
|
||||
const layerIdsToDelete = this.layers.map((l: Layer) => l.id);
|
||||
this.canvas.removeLayersByIds(layerIdsToDelete);
|
||||
log.info(`Deleted all ${layerIdsToDelete.length} new layers.`);
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private _update(): void {
|
||||
if (this.counterElement) {
|
||||
this.counterElement.textContent = `${this.currentIndex + 1} / ${this.layers.length}`;
|
||||
}
|
||||
this._focusOnLayer(this.layers[this.currentIndex]);
|
||||
}
|
||||
|
||||
private _focusOnLayer(layer: Layer): void {
|
||||
if (!layer) return;
|
||||
log.debug(`Focusing on layer ${layer.id} using visibility toggle`);
|
||||
|
||||
// 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.render();
|
||||
}
|
||||
}
|
||||
653
src/Canvas.ts
Normal file
653
src/Canvas.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
// @ts-ignore
|
||||
import {api} from "../../scripts/api.js";
|
||||
// @ts-ignore
|
||||
import {app} from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import {ComfyApp} from "../../scripts/app.js";
|
||||
|
||||
import {removeImage} from "./db.js";
|
||||
import {MaskTool} from "./MaskTool.js";
|
||||
import {ShapeTool} from "./ShapeTool.js";
|
||||
import {CustomShapeMenu} from "./CustomShapeMenu.js";
|
||||
import {CanvasState} from "./CanvasState.js";
|
||||
import {CanvasInteractions} from "./CanvasInteractions.js";
|
||||
import {CanvasLayers} from "./CanvasLayers.js";
|
||||
import {CanvasLayersPanel} from "./CanvasLayersPanel.js";
|
||||
import {CanvasRenderer} from "./CanvasRenderer.js";
|
||||
import {CanvasIO} from "./CanvasIO.js";
|
||||
import {ImageReferenceManager} from "./ImageReferenceManager.js";
|
||||
import {BatchPreviewManager} from "./BatchPreviewManager.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import { debounce, createCanvas } from "./utils/CommonUtils.js";
|
||||
import {MaskEditorIntegration} from "./MaskEditorIntegration.js";
|
||||
import {CanvasSelection} from "./CanvasSelection.js";
|
||||
import type { ComfyNode, Layer, Viewport, Point, AddMode, Shape, OutputAreaBounds } from './types';
|
||||
|
||||
const useChainCallback = (original: any, next: any) => {
|
||||
if (original === undefined || original === null) {
|
||||
return next;
|
||||
}
|
||||
return function(this: any, ...args: any[]) {
|
||||
const originalReturn = original.apply(this, args);
|
||||
const nextReturn = next.apply(this, args);
|
||||
return nextReturn === undefined ? originalReturn : nextReturn;
|
||||
};
|
||||
};
|
||||
|
||||
const log = createModuleLogger('Canvas');
|
||||
|
||||
/**
|
||||
* Canvas - Fasada dla systemu rysowania
|
||||
*
|
||||
* Klasa Canvas pełni rolę fasady, oferując uproszczony interfejs wysokiego poziomu
|
||||
* dla złożonego systemu rysowania. Zamiast eksponować wszystkie metody modułów,
|
||||
* udostępnia tylko kluczowe operacje i umożliwia bezpośredni dostęp do modułów
|
||||
* gdy potrzebna jest bardziej szczegółowa kontrola.
|
||||
*/
|
||||
export class Canvas {
|
||||
batchPreviewManagers: BatchPreviewManager[];
|
||||
canvas: HTMLCanvasElement;
|
||||
canvasContainer: HTMLDivElement | null;
|
||||
canvasIO: CanvasIO;
|
||||
canvasInteractions: CanvasInteractions;
|
||||
canvasLayers: CanvasLayers;
|
||||
canvasLayersPanel: CanvasLayersPanel;
|
||||
maskEditorIntegration: MaskEditorIntegration;
|
||||
canvasRenderer: CanvasRenderer;
|
||||
canvasSelection: CanvasSelection;
|
||||
canvasState: CanvasState;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
dataInitialized: boolean;
|
||||
height: number;
|
||||
imageCache: Map<string, any>;
|
||||
imageReferenceManager: ImageReferenceManager;
|
||||
interaction: any;
|
||||
isMouseOver: boolean;
|
||||
lastMousePosition: Point;
|
||||
layers: Layer[];
|
||||
maskTool: MaskTool;
|
||||
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;
|
||||
previewVisible: boolean;
|
||||
requestSaveState: () => void;
|
||||
viewport: Viewport;
|
||||
widget: any;
|
||||
width: number;
|
||||
|
||||
constructor(node: ComfyNode, widget: any, callbacks: { onStateChange?: () => void, onHistoryChange?: (historyInfo: { canUndo: boolean; canRedo: boolean; }) => void } = {}) {
|
||||
this.node = node;
|
||||
this.widget = widget;
|
||||
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 / 1.5),
|
||||
y: -(this.height / 2),
|
||||
zoom: 0.8,
|
||||
};
|
||||
|
||||
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.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);
|
||||
this.canvasLayers = new CanvasLayers(this);
|
||||
this.canvasLayersPanel = new CanvasLayersPanel(this);
|
||||
this.canvasRenderer = new CanvasRenderer(this);
|
||||
this.canvasIO = new CanvasIO(this);
|
||||
this.imageReferenceManager = new ImageReferenceManager(this);
|
||||
this.batchPreviewManagers = [];
|
||||
this.pendingBatchContext = null;
|
||||
this.interaction = this.canvasInteractions.interaction;
|
||||
this.previewVisible = false;
|
||||
this.isMouseOver = false;
|
||||
|
||||
this._initializeModules();
|
||||
this._setupCanvas();
|
||||
|
||||
log.debug('Canvas widget element:', this.node);
|
||||
log.info('Canvas initialized', {
|
||||
nodeId: this.node.id,
|
||||
dimensions: {width: this.width, height: this.height},
|
||||
viewport: this.viewport
|
||||
});
|
||||
|
||||
this.previewVisible = false;
|
||||
}
|
||||
|
||||
async waitForWidget(name: any, node: any, interval = 100, timeout = 20000) {
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const check = () => {
|
||||
const widget = node.widgets.find((w: any) => w.name === name);
|
||||
if (widget) {
|
||||
resolve(widget);
|
||||
} else if (Date.now() - startTime > timeout) {
|
||||
reject(new Error(`Widget "${name}" not found within timeout.`));
|
||||
} else {
|
||||
setTimeout(check, interval);
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontroluje widoczność podglądu canvas
|
||||
* @param {boolean} visible - Czy podgląd ma być widoczny
|
||||
*/
|
||||
async setPreviewVisibility(visible: boolean) {
|
||||
this.previewVisible = visible;
|
||||
log.info("Canvas preview visibility set to:", visible);
|
||||
|
||||
const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node) as any;
|
||||
if (imagePreviewWidget) {
|
||||
log.debug("Found $$canvas-image-preview widget, controlling visibility");
|
||||
|
||||
if (visible) {
|
||||
if (imagePreviewWidget.options) {
|
||||
imagePreviewWidget.options.hidden = false;
|
||||
}
|
||||
if ('visible' in imagePreviewWidget) {
|
||||
imagePreviewWidget.visible = true;
|
||||
}
|
||||
if ('hidden' in imagePreviewWidget) {
|
||||
imagePreviewWidget.hidden = false;
|
||||
}
|
||||
imagePreviewWidget.computeSize = function () {
|
||||
return [0, 250]; // Szerokość 0 (auto), wysokość 250
|
||||
};
|
||||
} else {
|
||||
if (imagePreviewWidget.options) {
|
||||
imagePreviewWidget.options.hidden = true;
|
||||
}
|
||||
if ('visible' in imagePreviewWidget) {
|
||||
imagePreviewWidget.visible = false;
|
||||
}
|
||||
if ('hidden' in imagePreviewWidget) {
|
||||
imagePreviewWidget.hidden = true;
|
||||
}
|
||||
|
||||
imagePreviewWidget.computeSize = function () {
|
||||
return [0, 0]; // Szerokość 0, wysokość 0
|
||||
};
|
||||
}
|
||||
this.render()
|
||||
} else {
|
||||
log.warn("$$canvas-image-preview widget not found in Canvas.js");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicjalizuje moduły systemu canvas
|
||||
* @private
|
||||
*/
|
||||
_initializeModules() {
|
||||
log.debug('Initializing Canvas modules...');
|
||||
|
||||
// Stwórz opóźnioną wersję funkcji zapisu stanu
|
||||
this.requestSaveState = debounce(() => this.saveState(), 500);
|
||||
|
||||
this._setupAutoRefreshHandlers();
|
||||
|
||||
log.debug('Canvas modules initialized successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfiguruje podstawowe właściwości canvas
|
||||
* @private
|
||||
*/
|
||||
_setupCanvas() {
|
||||
this.initCanvas();
|
||||
this.canvasInteractions.setupEventListeners();
|
||||
this.canvasIO.initNodeData();
|
||||
|
||||
this.layers = this.layers.map((layer: Layer) => ({
|
||||
...layer,
|
||||
opacity: 1
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje stan canvas z bazy danych
|
||||
*/
|
||||
async loadInitialState() {
|
||||
log.info("Loading initial state for node:", this.node.id);
|
||||
const loaded = await this.canvasState.loadStateFromDB();
|
||||
if (!loaded) {
|
||||
log.info("No saved state found, initializing from node data.");
|
||||
await this.canvasIO.initNodeData();
|
||||
}
|
||||
this.saveState();
|
||||
this.render();
|
||||
|
||||
// Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisuje obecny stan
|
||||
* @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii
|
||||
*/
|
||||
saveState(replaceLast = false) {
|
||||
log.debug('Saving canvas state', {replaceLast, layersCount: this.layers.length});
|
||||
this.canvasState.saveState(replaceLast);
|
||||
this.incrementOperationCount();
|
||||
this._notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cofnij ostatnią operację
|
||||
*/
|
||||
undo() {
|
||||
log.info('Performing undo operation');
|
||||
const historyInfo = this.canvasState.getHistoryInfo();
|
||||
log.debug('History state before undo:', historyInfo);
|
||||
|
||||
this.canvasState.undo();
|
||||
this.incrementOperationCount();
|
||||
this._notifyStateChange();
|
||||
|
||||
// Powiadom panel warstw o zmianie stanu warstw
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
this.canvasLayersPanel.onSelectionChanged();
|
||||
}
|
||||
|
||||
log.debug('Undo completed, layers count:', this.layers.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ponów cofniętą operację
|
||||
*/
|
||||
redo() {
|
||||
log.info('Performing redo operation');
|
||||
const historyInfo = this.canvasState.getHistoryInfo();
|
||||
log.debug('History state before redo:', historyInfo);
|
||||
|
||||
this.canvasState.redo();
|
||||
this.incrementOperationCount();
|
||||
this._notifyStateChange();
|
||||
|
||||
// Powiadom panel warstw o zmianie stanu warstw
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
this.canvasLayersPanel.onSelectionChanged();
|
||||
}
|
||||
|
||||
log.debug('Redo completed, layers count:', this.layers.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderuje canvas
|
||||
*/
|
||||
render() {
|
||||
this.canvasRenderer.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dodaje warstwę z obrazem
|
||||
* @param {Image} image - Obraz do dodania
|
||||
* @param {Object} layerProps - Właściwości warstwy
|
||||
* @param {string} addMode - Tryb dodawania
|
||||
*/
|
||||
async addLayer(image: HTMLImageElement, layerProps = {}, addMode: AddMode = 'default') {
|
||||
const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
|
||||
|
||||
// Powiadom panel warstw o dodaniu nowej warstwy
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuwa wybrane warstwy
|
||||
*/
|
||||
removeLayersByIds(layerIds: string[]) {
|
||||
if (!layerIds || layerIds.length === 0) return;
|
||||
|
||||
const initialCount = this.layers.length;
|
||||
this.saveState();
|
||||
this.layers = this.layers.filter((l: Layer) => !layerIds.includes(l.id));
|
||||
|
||||
// If the current selection was part of the removal, clear it
|
||||
const newSelection = this.canvasSelection.selectedLayers.filter((l: Layer) => !layerIds.includes(l.id));
|
||||
this.canvasSelection.updateSelection(newSelection);
|
||||
|
||||
this.render();
|
||||
this.saveState();
|
||||
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
log.info(`Removed ${initialCount - this.layers.length} layers by ID.`);
|
||||
}
|
||||
|
||||
removeSelectedLayers() {
|
||||
return this.canvasSelection.removeSelectedLayers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
|
||||
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
|
||||
* @param {Array} newSelection - Nowa lista zaznaczonych warstw
|
||||
*/
|
||||
updateSelection(newSelection: any) {
|
||||
return this.canvasSelection.updateSelection(newSelection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
|
||||
*/
|
||||
updateSelectionLogic(layer: Layer, isCtrlPressed: boolean, isShiftPressed: boolean, index: number) {
|
||||
return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||
}
|
||||
|
||||
defineOutputAreaWithShape(shape: Shape): void {
|
||||
this.canvasInteractions.defineOutputAreaWithShape(shape);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zmienia rozmiar obszaru wyjściowego
|
||||
* @param {number} width - Nowa szerokość
|
||||
* @param {number} height - Nowa wysokość
|
||||
* @param {boolean} saveHistory - Czy zapisać w historii
|
||||
*/
|
||||
updateOutputAreaSize(width: number, height: number, saveHistory = true) {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eksportuje spłaszczony canvas jako blob
|
||||
*/
|
||||
async getFlattenedCanvasAsBlob() {
|
||||
return this.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Eksportuje spłaszczony canvas z maską jako kanałem alpha
|
||||
*/
|
||||
async getFlattenedCanvasWithMaskAsBlob() {
|
||||
return this.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Importuje najnowszy obraz
|
||||
*/
|
||||
async importLatestImage() {
|
||||
return this.canvasIO.importLatestImage();
|
||||
}
|
||||
|
||||
_setupAutoRefreshHandlers() {
|
||||
let lastExecutionStartTime = 0;
|
||||
|
||||
// Helper function to get auto-refresh value from node widget
|
||||
const getAutoRefreshValue = (): boolean => {
|
||||
const widget = this.node.widgets.find((w: any) => w.name === 'auto_refresh_after_generation');
|
||||
return widget ? widget.value : false;
|
||||
};
|
||||
|
||||
const handleExecutionStart = () => {
|
||||
if (getAutoRefreshValue()) {
|
||||
lastExecutionStartTime = Date.now();
|
||||
// Store a snapshot of the context for the upcoming batch
|
||||
this.pendingBatchContext = {
|
||||
// For the menu position - position relative to outputAreaBounds, not canvas center
|
||||
spawnPosition: {
|
||||
x: this.outputAreaBounds.x + this.outputAreaBounds.width / 2,
|
||||
y: this.outputAreaBounds.y + this.outputAreaBounds.height
|
||||
},
|
||||
// For the image placement - use actual outputAreaBounds instead of hardcoded (0,0)
|
||||
outputArea: {
|
||||
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);
|
||||
this.render(); // Trigger render to show the pending outline immediately
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecutionSuccess = async () => {
|
||||
if (getAutoRefreshValue()) {
|
||||
log.info('Auto-refresh triggered, importing latest images.');
|
||||
|
||||
if (!this.pendingBatchContext) {
|
||||
log.warn("execution_start did not fire, cannot process batch. Awaiting next execution.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the captured output area for image import
|
||||
const newLayers = await this.canvasIO.importLatestImages(
|
||||
lastExecutionStartTime,
|
||||
this.pendingBatchContext.outputArea
|
||||
);
|
||||
|
||||
if (newLayers && newLayers.length > 1) {
|
||||
const newManager = new BatchPreviewManager(
|
||||
this,
|
||||
this.pendingBatchContext.spawnPosition,
|
||||
this.pendingBatchContext.outputArea
|
||||
);
|
||||
this.batchPreviewManagers.push(newManager);
|
||||
newManager.show(newLayers);
|
||||
}
|
||||
|
||||
// Consume the context
|
||||
this.pendingBatchContext = null;
|
||||
// Final render to clear the outline if it was the last one
|
||||
this.render();
|
||||
}
|
||||
};
|
||||
|
||||
api.addEventListener('execution_start', handleExecutionStart);
|
||||
api.addEventListener('execution_success', handleExecutionSuccess);
|
||||
|
||||
(this.node as any).onRemoved = useChainCallback((this.node as any).onRemoved, () => {
|
||||
log.info('Node removed, cleaning up auto-refresh listeners.');
|
||||
api.removeEventListener('execution_start', handleExecutionStart);
|
||||
api.removeEventListener('execution_success', handleExecutionSuccess);
|
||||
});
|
||||
|
||||
log.debug('Auto-refresh handlers setup complete, reading from node widget: auto_refresh_after_generation');
|
||||
}
|
||||
|
||||
/**
|
||||
* Uruchamia edytor masek
|
||||
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora
|
||||
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
|
||||
*/
|
||||
async startMaskEditor(predefinedMask: HTMLImageElement | HTMLCanvasElement | null = null, sendCleanImage: boolean = true) {
|
||||
return this.maskEditorIntegration.startMaskEditor(predefinedMask as any, sendCleanImage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicjalizuje podstawowe właściwości canvas
|
||||
*/
|
||||
initCanvas() {
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
this.canvas.style.border = '1px solid black';
|
||||
this.canvas.style.maxWidth = '100%';
|
||||
this.canvas.style.backgroundColor = '#606060';
|
||||
this.canvas.style.width = '100%';
|
||||
this.canvas.style.height = '100%';
|
||||
this.canvas.tabIndex = 0;
|
||||
this.canvas.style.outline = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pobiera współrzędne myszy w układzie świata
|
||||
* @param {MouseEvent} e - Zdarzenie myszy
|
||||
*/
|
||||
getMouseWorldCoordinates(e: any) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
|
||||
const mouseX_DOM = e.clientX - rect.left;
|
||||
const mouseY_DOM = e.clientY - rect.top;
|
||||
|
||||
if (!this.offscreenCanvas) throw new Error("Offscreen canvas not initialized");
|
||||
const scaleX = this.offscreenCanvas.width / rect.width;
|
||||
const scaleY = this.offscreenCanvas.height / rect.height;
|
||||
|
||||
const mouseX_Buffer = mouseX_DOM * scaleX;
|
||||
const mouseY_Buffer = mouseY_DOM * scaleY;
|
||||
|
||||
const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x;
|
||||
const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y;
|
||||
|
||||
return {x: worldX, y: worldY};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pobiera współrzędne myszy w układzie widoku
|
||||
* @param {MouseEvent} e - Zdarzenie myszy
|
||||
*/
|
||||
getMouseViewCoordinates(e: any) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mouseX_DOM = e.clientX - rect.left;
|
||||
const mouseY_DOM = e.clientY - rect.top;
|
||||
|
||||
const scaleX = this.canvas.width / rect.width;
|
||||
const scaleY = this.canvas.height / rect.height;
|
||||
|
||||
const mouseX_Canvas = mouseX_DOM * scaleX;
|
||||
const mouseY_Canvas = mouseY_DOM * scaleY;
|
||||
|
||||
return {x: mouseX_Canvas, y: mouseY_Canvas};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje zaznaczenie po operacji historii
|
||||
*/
|
||||
updateSelectionAfterHistory() {
|
||||
return this.canvasSelection.updateSelectionAfterHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje przyciski historii
|
||||
*/
|
||||
updateHistoryButtons() {
|
||||
if (this.onHistoryChange) {
|
||||
const historyInfo = this.canvasState.getHistoryInfo();
|
||||
this.onHistoryChange({
|
||||
canUndo: historyInfo.canUndo,
|
||||
canRedo: historyInfo.canRedo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwiększa licznik operacji (dla garbage collection)
|
||||
*/
|
||||
incrementOperationCount() {
|
||||
if (this.imageReferenceManager) {
|
||||
this.imageReferenceManager.incrementOperationCount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści zasoby canvas
|
||||
*/
|
||||
destroy() {
|
||||
if (this.imageReferenceManager) {
|
||||
this.imageReferenceManager.destroy();
|
||||
}
|
||||
log.info("Canvas destroyed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Powiadamia o zmianie stanu
|
||||
* @private
|
||||
*/
|
||||
_notifyStateChange() {
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
688
src/CanvasIO.ts
Normal file
688
src/CanvasIO.ts
Normal file
@@ -0,0 +1,688 @@
|
||||
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, Shape } from './types';
|
||||
|
||||
const log = createModuleLogger('CanvasIO');
|
||||
|
||||
export class CanvasIO {
|
||||
private _saveInProgress: Promise<any> | null;
|
||||
private canvas: Canvas;
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
this.canvas = canvas;
|
||||
this._saveInProgress = null;
|
||||
}
|
||||
|
||||
async saveToServer(fileName: string, outputMode = 'disk'): Promise<any> {
|
||||
if (outputMode === 'disk') {
|
||||
if (!(window as any).canvasSaveStates) {
|
||||
(window as any).canvasSaveStates = new Map();
|
||||
}
|
||||
|
||||
const nodeId = this.canvas.node.id;
|
||||
const saveKey = `${nodeId}_${fileName}`;
|
||||
if (this._saveInProgress || (window as any).canvasSaveStates.get(saveKey)) {
|
||||
log.warn(`Save already in progress for node ${nodeId}, waiting...`);
|
||||
return this._saveInProgress || (window as any).canvasSaveStates.get(saveKey);
|
||||
}
|
||||
|
||||
log.info(`Starting saveToServer (disk) with fileName: ${fileName} for node: ${nodeId}`);
|
||||
this._saveInProgress = this._performSave(fileName, outputMode);
|
||||
(window as any).canvasSaveStates.set(saveKey, this._saveInProgress);
|
||||
|
||||
try {
|
||||
return await this._saveInProgress;
|
||||
} finally {
|
||||
this._saveInProgress = null;
|
||||
(window as any).canvasSaveStates.delete(saveKey);
|
||||
log.debug(`Save completed for node ${nodeId}, lock released`);
|
||||
}
|
||||
} else {
|
||||
log.info(`Starting saveToServer (RAM) for node: ${this.canvas.node.id}`);
|
||||
return this._performSave(fileName, outputMode);
|
||||
}
|
||||
}
|
||||
|
||||
async _performSave(fileName: string, outputMode: string): Promise<any> {
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
await this.canvas.canvasState.saveStateToDB();
|
||||
const nodeId = this.canvas.node.id;
|
||||
const delay = (nodeId % 10) * 50;
|
||||
if (delay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(this.canvas.width, this.canvas.height);
|
||||
const {canvas: maskCanvas, ctx: maskCtx} = createCanvas(this.canvas.width, this.canvas.height);
|
||||
|
||||
const 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");
|
||||
maskCtx.fillStyle = '#ffffff';
|
||||
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
log.debug(`Canvas contexts created, starting layer rendering`);
|
||||
|
||||
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
|
||||
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
|
||||
log.debug(`Finished rendering layers`);
|
||||
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
||||
const alpha = visibilityData.data[i + 3];
|
||||
const maskValue = 255 - alpha;
|
||||
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
|
||||
maskData.data[i + 3] = 255;
|
||||
}
|
||||
|
||||
maskCtx.putImageData(maskData, 0, 0);
|
||||
|
||||
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`);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
if (outputMode === 'ram') {
|
||||
const imageData = tempCanvas.toDataURL('image/png');
|
||||
const maskData = maskCanvas.toDataURL('image/png');
|
||||
log.info("Returning image and mask data as base64 for RAM mode.");
|
||||
resolve({image: imageData, mask: maskData});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileNameWithoutMask = fileName.replace('.png', '_without_mask.png');
|
||||
log.info(`Saving image without mask as: ${fileNameWithoutMask}`);
|
||||
|
||||
tempCanvas.toBlob(async (blobWithoutMask) => {
|
||||
if (!blobWithoutMask) return;
|
||||
log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`);
|
||||
const formDataWithoutMask = new FormData();
|
||||
formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask);
|
||||
formDataWithoutMask.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const response = await fetch("/upload/image", {
|
||||
method: "POST",
|
||||
body: formDataWithoutMask,
|
||||
});
|
||||
log.debug(`Image without mask upload response: ${response.status}`);
|
||||
} catch (error) {
|
||||
log.error(`Error uploading image without mask:`, error);
|
||||
}
|
||||
}, "image/png");
|
||||
log.info(`Saving main image as: ${fileName}`);
|
||||
tempCanvas.toBlob(async (blob) => {
|
||||
if (!blob) return;
|
||||
log.debug(`Created blob for main image, size: ${blob.size} bytes`);
|
||||
const formData = new FormData();
|
||||
formData.append("image", blob, fileName);
|
||||
formData.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const resp = await fetch("/upload/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
log.debug(`Main image upload response: ${resp.status}`);
|
||||
|
||||
if (resp.status === 200) {
|
||||
const maskFileName = fileName.replace('.png', '_mask.png');
|
||||
log.info(`Saving mask as: ${maskFileName}`);
|
||||
|
||||
maskCanvas.toBlob(async (maskBlob) => {
|
||||
if (!maskBlob) return;
|
||||
log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`);
|
||||
const maskFormData = new FormData();
|
||||
maskFormData.append("image", maskBlob, maskFileName);
|
||||
maskFormData.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const maskResp = await fetch("/upload/image", {
|
||||
method: "POST",
|
||||
body: maskFormData,
|
||||
});
|
||||
log.debug(`Mask upload response: ${maskResp.status}`);
|
||||
|
||||
if (maskResp.status === 200) {
|
||||
const data = await resp.json();
|
||||
if (this.canvas.widget) {
|
||||
this.canvas.widget.value = fileName;
|
||||
}
|
||||
log.info(`All files saved successfully, widget value set to: ${fileName}`);
|
||||
resolve(true);
|
||||
} else {
|
||||
log.error(`Error saving mask: ${maskResp.status}`);
|
||||
resolve(false);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Error saving mask:`, error);
|
||||
resolve(false);
|
||||
}
|
||||
}, "image/png");
|
||||
} else {
|
||||
log.error(`Main image upload failed: ${resp.status} - ${resp.statusText}`);
|
||||
resolve(false);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Error uploading main image:`, error);
|
||||
resolve(false);
|
||||
}
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
async _renderOutputData(): Promise<{ image: string, mask: string }> {
|
||||
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> {
|
||||
log.info(`Preparing to send data for node ${nodeId} via WebSocket.`);
|
||||
|
||||
const { image, mask } = await this._renderOutputData();
|
||||
|
||||
try {
|
||||
log.info(`Sending data for node ${nodeId}...`);
|
||||
await webSocketManager.sendMessage({
|
||||
type: 'canvas_data',
|
||||
nodeId: String(nodeId),
|
||||
image: image,
|
||||
mask: mask,
|
||||
}, true); // `true` requires an acknowledgment
|
||||
|
||||
log.info(`Data for node ${nodeId} has been sent and acknowledged by the server.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error(`Failed to send data for node ${nodeId}:`, error);
|
||||
|
||||
|
||||
throw new Error(
|
||||
`Failed to get confirmation from server for node ${nodeId}. ` +
|
||||
`Make sure that the nodeId: (${nodeId}) matches the "node_id" value in the node options. If they don't match, you may need to manually set the node_id to ${nodeId}.` +
|
||||
`If the issue persists, try using a different browser. Some issues have been observed specifically with portable versions of Chrome, ` +
|
||||
`which may have limitations related to memory or WebSocket handling. Consider testing in a standard Chrome installation, Firefox, or another browser.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async addInputToCanvas(inputImage: any, inputMask: any): Promise<boolean> {
|
||||
try {
|
||||
log.debug("Adding input to canvas:", { inputImage });
|
||||
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
|
||||
if (!tempCtx) throw new Error("Could not create temp context");
|
||||
|
||||
const imgData = new ImageData(
|
||||
new Uint8ClampedArray(inputImage.data),
|
||||
inputImage.width,
|
||||
inputImage.height
|
||||
);
|
||||
tempCtx.putImageData(imgData, 0, 0);
|
||||
|
||||
const image = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = reject;
|
||||
image.src = tempCanvas.toDataURL();
|
||||
});
|
||||
|
||||
const 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: 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,
|
||||
});
|
||||
|
||||
if (inputMask && layer) {
|
||||
(layer as any).mask = inputMask.data;
|
||||
}
|
||||
|
||||
log.info("Layer added successfully");
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
log.error("Error in addInputToCanvas:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async convertTensorToImage(tensor: any): Promise<HTMLImageElement> {
|
||||
try {
|
||||
log.debug("Converting tensor to image:", tensor);
|
||||
|
||||
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
|
||||
throw new Error("Invalid tensor data");
|
||||
}
|
||||
|
||||
const { canvas, ctx } = createCanvas(tensor.width, tensor.height, '2d', { willReadFrequently: true });
|
||||
if (!ctx) throw new Error("Could not create canvas context");
|
||||
|
||||
const imageData = new ImageData(
|
||||
new Uint8ClampedArray(tensor.data),
|
||||
tensor.width,
|
||||
tensor.height
|
||||
);
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Error converting tensor to image:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async convertTensorToMask(tensor: any): Promise<Float32Array> {
|
||||
if (!tensor || !tensor.data) {
|
||||
throw new Error("Invalid mask tensor");
|
||||
}
|
||||
|
||||
try {
|
||||
return new Float32Array(tensor.data);
|
||||
} catch (error: any) {
|
||||
throw new Error(`Mask conversion failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async initNodeData(): Promise<void> {
|
||||
try {
|
||||
log.info("Starting node data initialization...");
|
||||
|
||||
if (!this.canvas.node || !(this.canvas.node as any).inputs) {
|
||||
log.debug("Node or inputs not ready");
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
|
||||
if ((this.canvas.node as any).inputs[0] && (this.canvas.node as any).inputs[0].link) {
|
||||
const imageLinkId = (this.canvas.node as any).inputs[0].link;
|
||||
const imageData = (window as any).app.nodeOutputs[imageLinkId];
|
||||
|
||||
if (imageData) {
|
||||
log.debug("Found image data:", imageData);
|
||||
await this.processImageData(imageData);
|
||||
this.canvas.dataInitialized = true;
|
||||
} else {
|
||||
log.debug("Image data not available yet");
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
}
|
||||
|
||||
if ((this.canvas.node as any).inputs[1] && (this.canvas.node as any).inputs[1].link) {
|
||||
const maskLinkId = (this.canvas.node as any).inputs[1].link;
|
||||
const maskData = (window as any).app.nodeOutputs[maskLinkId];
|
||||
|
||||
if (maskData) {
|
||||
log.debug("Found mask data:", maskData);
|
||||
await this.processMaskData(maskData);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log.error("Error in initNodeData:", error);
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
}
|
||||
|
||||
scheduleDataCheck(): void {
|
||||
if (this.canvas.pendingDataCheck) {
|
||||
clearTimeout(this.canvas.pendingDataCheck);
|
||||
}
|
||||
|
||||
this.canvas.pendingDataCheck = window.setTimeout(() => {
|
||||
this.canvas.pendingDataCheck = null;
|
||||
if (!this.canvas.dataInitialized) {
|
||||
this.initNodeData();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async processImageData(imageData: any): Promise<void> {
|
||||
try {
|
||||
if (!imageData) return;
|
||||
|
||||
log.debug("Processing image data:", {
|
||||
type: typeof imageData,
|
||||
isArray: Array.isArray(imageData),
|
||||
shape: imageData.shape,
|
||||
hasData: !!imageData.data
|
||||
});
|
||||
|
||||
if (Array.isArray(imageData)) {
|
||||
imageData = imageData[0];
|
||||
}
|
||||
|
||||
if (!imageData.shape || !imageData.data) {
|
||||
throw new Error("Invalid image data format");
|
||||
}
|
||||
|
||||
const originalWidth = imageData.shape[2];
|
||||
const originalHeight = imageData.shape[1];
|
||||
|
||||
const scale = Math.min(
|
||||
this.canvas.width / originalWidth * 0.8,
|
||||
this.canvas.height / originalHeight * 0.8
|
||||
);
|
||||
|
||||
const convertedData = this.convertTensorToImageData(imageData);
|
||||
if (convertedData) {
|
||||
const image = await this.createImageFromData(convertedData);
|
||||
|
||||
this.addScaledLayer(image, scale);
|
||||
log.info("Image layer added successfully with scale:", scale);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Error processing image data:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
addScaledLayer(image: HTMLImageElement, scale: number): void {
|
||||
try {
|
||||
const scaledWidth = image.width * scale;
|
||||
const scaledHeight = image.height * scale;
|
||||
|
||||
const layer: Layer = {
|
||||
id: '', // This will be set in addLayerWithImage
|
||||
imageId: '', // This will be set in addLayerWithImage
|
||||
name: 'Layer',
|
||||
image: image,
|
||||
x: (this.canvas.width - scaledWidth) / 2,
|
||||
y: (this.canvas.height - scaledHeight) / 2,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
rotation: 0,
|
||||
zIndex: this.canvas.layers.length,
|
||||
originalWidth: image.width,
|
||||
originalHeight: image.height,
|
||||
blendMode: 'normal',
|
||||
opacity: 1,
|
||||
visible: true
|
||||
};
|
||||
|
||||
this.canvas.layers.push(layer);
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.canvas.render();
|
||||
|
||||
log.debug("Scaled layer added:", {
|
||||
originalSize: `${image.width}x${image.height}`,
|
||||
scaledSize: `${scaledWidth}x${scaledHeight}`,
|
||||
scale: scale
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Error adding scaled layer:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
convertTensorToImageData(tensor: any): ImageData | null {
|
||||
try {
|
||||
const shape = tensor.shape;
|
||||
const height = shape[1];
|
||||
const width = shape[2];
|
||||
const channels = shape[3];
|
||||
|
||||
log.debug("Converting tensor:", {
|
||||
shape: shape,
|
||||
dataRange: {
|
||||
min: tensor.min_val,
|
||||
max: tensor.max_val
|
||||
}
|
||||
});
|
||||
|
||||
const imageData = new ImageData(width, height);
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
const flatData = tensor.data;
|
||||
const pixelCount = width * height;
|
||||
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
const tensorIndex = i * channels;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const value = flatData[tensorIndex + c];
|
||||
|
||||
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
|
||||
data[pixelIndex + c] = Math.round(normalizedValue * 255);
|
||||
}
|
||||
|
||||
data[pixelIndex + 3] = 255;
|
||||
}
|
||||
|
||||
imageData.data.set(data);
|
||||
return imageData;
|
||||
} catch (error) {
|
||||
log.error("Error converting tensor:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createImageFromData(imageData: ImageData): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
|
||||
if (!ctx) throw new Error("Could not create canvas context");
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
|
||||
async processMaskData(maskData: any): Promise<void> {
|
||||
try {
|
||||
if (!maskData) return;
|
||||
|
||||
log.debug("Processing mask data:", maskData);
|
||||
|
||||
if (Array.isArray(maskData)) {
|
||||
maskData = maskData[0];
|
||||
}
|
||||
|
||||
if (!maskData.shape || !maskData.data) {
|
||||
throw new Error("Invalid mask data format");
|
||||
}
|
||||
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
const maskTensor = await this.convertTensorToMask(maskData);
|
||||
(this.canvas.canvasSelection.selectedLayers[0] as any).mask = maskTensor;
|
||||
this.canvas.render();
|
||||
log.info("Mask applied to selected layer");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Error processing mask data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async importLatestImage(): Promise<boolean> {
|
||||
try {
|
||||
log.info("Fetching latest image from server...");
|
||||
const response = await fetch('/ycnode/get_latest_image');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.image_data) {
|
||||
log.info("Latest image received, adding to canvas.");
|
||||
const img = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = result.image_data;
|
||||
});
|
||||
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit');
|
||||
log.info("Latest image imported and placed on canvas successfully.");
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.error || "Failed to fetch the latest image.");
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error("Error importing latest image:", error);
|
||||
showErrorNotification(`Failed to import latest image: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async importLatestImages(sinceTimestamp: number, targetArea: { x: number, y: number, width: number, height: number } | null = null): Promise<Layer[]> {
|
||||
try {
|
||||
log.info(`Fetching latest images since ${sinceTimestamp}...`);
|
||||
const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.images && result.images.length > 0) {
|
||||
log.info(`Received ${result.images.length} new images, adding to canvas.`);
|
||||
const newLayers: (Layer | null)[] = [];
|
||||
|
||||
for (const imageData of result.images) {
|
||||
const img = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = imageData;
|
||||
});
|
||||
|
||||
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.");
|
||||
return newLayers.filter(l => l !== null) as Layer[];
|
||||
|
||||
} else if (result.success) {
|
||||
log.info("No new images found since last generation.");
|
||||
return [];
|
||||
} else {
|
||||
throw new Error(result.error || "Failed to fetch latest images.");
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error("Error importing latest images:", error);
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
1224
src/CanvasInteractions.ts
Normal file
1224
src/CanvasInteractions.ts
Normal file
File diff suppressed because it is too large
Load Diff
1942
src/CanvasLayers.ts
Normal file
1942
src/CanvasLayers.ts
Normal file
File diff suppressed because it is too large
Load Diff
576
src/CanvasLayersPanel.ts
Normal file
576
src/CanvasLayersPanel.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer } from './types';
|
||||
|
||||
const log = createModuleLogger('CanvasLayersPanel');
|
||||
|
||||
export class CanvasLayersPanel {
|
||||
private canvas: Canvas;
|
||||
private container: HTMLElement | null;
|
||||
private layersContainer: HTMLElement | null;
|
||||
private draggedElements: Layer[];
|
||||
private dragInsertionLine: HTMLElement | null;
|
||||
private isMultiSelecting: boolean;
|
||||
private lastSelectedIndex: number;
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
this.canvas = canvas;
|
||||
this.container = null;
|
||||
this.layersContainer = null;
|
||||
this.draggedElements = [];
|
||||
this.dragInsertionLine = null;
|
||||
this.isMultiSelecting = false;
|
||||
this.lastSelectedIndex = -1;
|
||||
|
||||
this.handleLayerClick = this.handleLayerClick.bind(this);
|
||||
this.handleDragStart = this.handleDragStart.bind(this);
|
||||
this.handleDragOver = this.handleDragOver.bind(this);
|
||||
this.handleDragEnd = this.handleDragEnd.bind(this);
|
||||
this.handleDrop = this.handleDrop.bind(this);
|
||||
|
||||
// Preload icons
|
||||
this.initializeIcons();
|
||||
|
||||
// Load CSS for layers panel
|
||||
addStylesheet(getUrl('./css/layers_panel.css'));
|
||||
|
||||
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.className = 'icon-container';
|
||||
iconContainer.style.width = `${size}px`;
|
||||
iconContainer.style.height = `${size}px`;
|
||||
|
||||
const icon = iconLoader.getIcon(toolName);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode() as HTMLImageElement;
|
||||
img.style.width = `${size}px`;
|
||||
img.style.height = `${size}px`;
|
||||
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.classList.add('fallback-text');
|
||||
iconContainer.textContent = toolName.charAt(0).toUpperCase();
|
||||
iconContainer.style.fontSize = `${size * 0.6}px`;
|
||||
}
|
||||
|
||||
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.className = 'icon-container visibility-hidden';
|
||||
iconContainer.style.width = '16px';
|
||||
iconContainer.style.height = '16px';
|
||||
|
||||
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
|
||||
if (icon) {
|
||||
if (icon instanceof HTMLImageElement) {
|
||||
const img = icon.cloneNode() as HTMLImageElement;
|
||||
img.style.width = '16px';
|
||||
img.style.height = '16px';
|
||||
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.classList.add('fallback-text');
|
||||
iconContainer.textContent = 'H';
|
||||
iconContainer.style.fontSize = '10px';
|
||||
}
|
||||
|
||||
return iconContainer;
|
||||
}
|
||||
}
|
||||
|
||||
createPanelStructure(): HTMLElement {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'layers-panel';
|
||||
this.container.tabIndex = 0; // Umożliwia fokus na panelu
|
||||
this.container.innerHTML = `
|
||||
<div class="layers-panel-header">
|
||||
<span class="layers-panel-title">Layers</span>
|
||||
<div class="layers-panel-controls">
|
||||
<button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layers-container" id="layers-container">
|
||||
<!-- Lista warstw będzie renderowana tutaj -->
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container');
|
||||
|
||||
// Setup event listeners dla przycisków
|
||||
this.setupControlButtons();
|
||||
|
||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||
this.container.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.deleteSelectedLayers();
|
||||
}
|
||||
});
|
||||
|
||||
log.debug('Panel structure created');
|
||||
return this.container;
|
||||
}
|
||||
|
||||
|
||||
setupControlButtons(): void {
|
||||
if (!this.container) return;
|
||||
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
// Initial button state update
|
||||
this.updateButtonStates();
|
||||
}
|
||||
|
||||
renderLayers(): void {
|
||||
if (!this.layersContainer) {
|
||||
log.warn('Layers container not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wyczyść istniejącą zawartość
|
||||
this.layersContainer.innerHTML = '';
|
||||
|
||||
// Usuń linię wstawiania jeśli istnieje
|
||||
this.removeDragInsertionLine();
|
||||
|
||||
// Sortuj warstwy według zIndex (od najwyższej do najniższej)
|
||||
const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => b.zIndex - a.zIndex);
|
||||
|
||||
sortedLayers.forEach((layer: Layer, index: number) => {
|
||||
const layerElement = this.createLayerElement(layer, index);
|
||||
if(this.layersContainer)
|
||||
this.layersContainer.appendChild(layerElement);
|
||||
});
|
||||
|
||||
log.debug(`Rendered ${sortedLayers.length} layers`);
|
||||
}
|
||||
|
||||
createLayerElement(layer: Layer, index: number): HTMLElement {
|
||||
const layerRow = document.createElement('div');
|
||||
layerRow.className = 'layer-row';
|
||||
layerRow.draggable = true;
|
||||
layerRow.dataset.layerIndex = String(index);
|
||||
|
||||
const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
|
||||
if (isSelected) {
|
||||
layerRow.classList.add('selected');
|
||||
}
|
||||
|
||||
// Ustawienie domyślnych właściwości jeśli nie istnieją
|
||||
if (!layer.name) {
|
||||
layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer);
|
||||
} else {
|
||||
// Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu)
|
||||
layer.name = this.ensureUniqueName(layer.name, layer);
|
||||
}
|
||||
|
||||
layerRow.innerHTML = `
|
||||
<div class="layer-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);
|
||||
}
|
||||
|
||||
this.setupLayerEventListeners(layerRow, layer, index);
|
||||
|
||||
return layerRow;
|
||||
}
|
||||
|
||||
generateThumbnail(layer: Layer, thumbnailContainer: HTMLElement): void {
|
||||
if (!layer.image) {
|
||||
thumbnailContainer.style.background = '#4a4a4a';
|
||||
return;
|
||||
}
|
||||
|
||||
const { canvas, ctx } = createCanvas(48, 48, '2d', { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
|
||||
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
|
||||
const scaledWidth = layer.image.width * scale;
|
||||
const scaledHeight = layer.image.height * scale;
|
||||
|
||||
// Wycentruj obraz
|
||||
const x = (48 - scaledWidth) / 2;
|
||||
const y = (48 - scaledHeight) / 2;
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight);
|
||||
|
||||
thumbnailContainer.appendChild(canvas);
|
||||
}
|
||||
|
||||
setupLayerEventListeners(layerRow: HTMLElement, layer: Layer, index: number): void {
|
||||
layerRow.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
const nameElement = layerRow.querySelector<HTMLElement>('.layer-name');
|
||||
if (nameElement && nameElement.classList.contains('editing')) {
|
||||
return;
|
||||
}
|
||||
this.handleLayerClick(e, layer, index);
|
||||
});
|
||||
|
||||
// --- 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();
|
||||
this.updateButtonStates();
|
||||
}
|
||||
});
|
||||
|
||||
layerRow.addEventListener('dblclick', (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const nameElement = layerRow.querySelector<HTMLElement>('.layer-name');
|
||||
if (nameElement) {
|
||||
this.startEditingLayerName(nameElement, layer);
|
||||
}
|
||||
});
|
||||
|
||||
// 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));
|
||||
layerRow.addEventListener('drop', (e: DragEvent) => this.handleDrop(e, index));
|
||||
}
|
||||
|
||||
handleLayerClick(e: MouseEvent, layer: Layer, index: number): void {
|
||||
const isCtrlPressed = e.ctrlKey || e.metaKey;
|
||||
const isShiftPressed = e.shiftKey;
|
||||
|
||||
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
|
||||
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
|
||||
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||
|
||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||
this.updateSelectionAppearance();
|
||||
this.updateButtonStates();
|
||||
|
||||
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||
}
|
||||
|
||||
startEditingLayerName(nameElement: HTMLElement, layer: Layer): void {
|
||||
const currentName = layer.name;
|
||||
nameElement.classList.add('editing');
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = currentName;
|
||||
input.style.width = '100%';
|
||||
|
||||
nameElement.innerHTML = '';
|
||||
nameElement.appendChild(input);
|
||||
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const finishEditing = () => {
|
||||
let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`;
|
||||
newName = this.ensureUniqueName(newName, layer);
|
||||
layer.name = newName;
|
||||
nameElement.classList.remove('editing');
|
||||
nameElement.textContent = newName;
|
||||
|
||||
this.canvas.saveState();
|
||||
log.info(`Layer renamed to: ${newName}`);
|
||||
};
|
||||
|
||||
input.addEventListener('blur', finishEditing);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
finishEditing();
|
||||
} else if (e.key === 'Escape') {
|
||||
nameElement.classList.remove('editing');
|
||||
nameElement.textContent = currentName;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ensureUniqueName(proposedName: string, currentLayer: Layer): string {
|
||||
const existingNames = this.canvas.layers
|
||||
.filter((layer: Layer) => layer !== currentLayer)
|
||||
.map((layer: Layer) => layer.name);
|
||||
|
||||
if (!existingNames.includes(proposedName)) {
|
||||
return proposedName;
|
||||
}
|
||||
|
||||
// Sprawdź czy nazwa już ma numerację w nawiasach
|
||||
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
|
||||
let baseName, startNumber;
|
||||
|
||||
if (match) {
|
||||
baseName = match[1].trim();
|
||||
startNumber = parseInt(match[2]) + 1;
|
||||
} else {
|
||||
baseName = proposedName;
|
||||
startNumber = 1;
|
||||
}
|
||||
|
||||
// Znajdź pierwszą dostępną numerację
|
||||
let counter = startNumber;
|
||||
let uniqueName;
|
||||
|
||||
do {
|
||||
uniqueName = `${baseName} (${counter})`;
|
||||
counter++;
|
||||
} while (existingNames.includes(uniqueName));
|
||||
|
||||
return uniqueName;
|
||||
}
|
||||
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Deleting ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
|
||||
this.canvas.removeSelectedLayers();
|
||||
this.renderLayers();
|
||||
}
|
||||
|
||||
handleDragStart(e: DragEvent, layer: Layer, index: number): void {
|
||||
if (!this.layersContainer || !e.dataTransfer) return;
|
||||
const editingElement = this.layersContainer.querySelector('.layer-name.editing');
|
||||
if (editingElement) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją
|
||||
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.renderLayers();
|
||||
}
|
||||
|
||||
this.draggedElements = [...this.canvas.canvasSelection.selectedLayers];
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', '');
|
||||
|
||||
this.layersContainer.querySelectorAll('.layer-row').forEach((row: Element, idx: number) => {
|
||||
const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => b.zIndex - a.zIndex);
|
||||
if (this.draggedElements.includes(sortedLayers[idx])) {
|
||||
row.classList.add('dragging');
|
||||
}
|
||||
});
|
||||
|
||||
log.debug(`Started dragging ${this.draggedElements.length} layers`);
|
||||
}
|
||||
|
||||
handleDragOver(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const layerRow = e.currentTarget as HTMLElement;
|
||||
const rect = layerRow.getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
const isUpperHalf = e.clientY < midpoint;
|
||||
|
||||
this.showDragInsertionLine(layerRow, isUpperHalf);
|
||||
}
|
||||
|
||||
showDragInsertionLine(targetRow: HTMLElement, isUpperHalf: boolean): void {
|
||||
this.removeDragInsertionLine();
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = 'drag-insertion-line';
|
||||
|
||||
if (isUpperHalf) {
|
||||
line.style.top = '-1px';
|
||||
} else {
|
||||
line.style.bottom = '-1px';
|
||||
}
|
||||
|
||||
targetRow.style.position = 'relative';
|
||||
targetRow.appendChild(line);
|
||||
this.dragInsertionLine = line;
|
||||
}
|
||||
|
||||
removeDragInsertionLine(): void {
|
||||
if (this.dragInsertionLine) {
|
||||
this.dragInsertionLine.remove();
|
||||
this.dragInsertionLine = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleDrop(e: DragEvent, targetIndex: number): void {
|
||||
e.preventDefault();
|
||||
this.removeDragInsertionLine();
|
||||
|
||||
if (this.draggedElements.length === 0 || !(e.currentTarget instanceof HTMLElement)) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
const isUpperHalf = e.clientY < midpoint;
|
||||
|
||||
// Oblicz docelowy indeks
|
||||
let insertIndex = targetIndex;
|
||||
if (!isUpperHalf) {
|
||||
insertIndex = targetIndex + 1;
|
||||
}
|
||||
|
||||
// Użyj nowej, centralnej funkcji do przesuwania warstw
|
||||
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
|
||||
|
||||
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
|
||||
}
|
||||
|
||||
handleDragEnd(e: DragEvent): void {
|
||||
this.removeDragInsertionLine();
|
||||
if (!this.layersContainer) return;
|
||||
this.layersContainer.querySelectorAll('.layer-row').forEach((row: Element) => {
|
||||
row.classList.remove('dragging');
|
||||
});
|
||||
|
||||
this.draggedElements = [];
|
||||
}
|
||||
|
||||
onLayersChanged(): void {
|
||||
this.renderLayers();
|
||||
}
|
||||
|
||||
updateSelectionAppearance(): void {
|
||||
if (!this.layersContainer) return;
|
||||
const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => b.zIndex - a.zIndex);
|
||||
const layerRows = this.layersContainer.querySelectorAll('.layer-row');
|
||||
|
||||
layerRows.forEach((row: Element, index: number) => {
|
||||
const layer = sortedLayers[index];
|
||||
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
row.classList.add('selected');
|
||||
} else {
|
||||
row.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje stan przycisków w zależności od zaznaczenia warstw
|
||||
*/
|
||||
updateButtonStates(): void {
|
||||
if (!this.container) return;
|
||||
|
||||
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
|
||||
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
|
||||
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = !hasSelectedLayers;
|
||||
deleteBtn.title = hasSelectedLayers
|
||||
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
|
||||
: 'No layers selected';
|
||||
}
|
||||
|
||||
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
||||
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
||||
*/
|
||||
onSelectionChanged(): void {
|
||||
this.updateSelectionAppearance();
|
||||
this.updateButtonStates();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.container && this.container.parentNode) {
|
||||
this.container.parentNode.removeChild(this.container);
|
||||
}
|
||||
this.container = null;
|
||||
this.layersContainer = null;
|
||||
this.draggedElements = [];
|
||||
this.removeDragInsertionLine();
|
||||
|
||||
log.info('CanvasLayersPanel destroyed');
|
||||
}
|
||||
}
|
||||
713
src/CanvasRenderer.ts
Normal file
713
src/CanvasRenderer.ts
Normal file
@@ -0,0 +1,713 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
const log = createModuleLogger('CanvasRenderer');
|
||||
|
||||
export class CanvasRenderer {
|
||||
canvas: any;
|
||||
isDirty: any;
|
||||
lastRenderTime: any;
|
||||
renderAnimationFrame: any;
|
||||
renderInterval: any;
|
||||
constructor(canvas: any) {
|
||||
this.canvas = canvas;
|
||||
this.renderAnimationFrame = null;
|
||||
this.lastRenderTime = 0;
|
||||
this.renderInterval = 1000 / 60;
|
||||
this.isDirty = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return;
|
||||
}
|
||||
this.renderAnimationFrame = requestAnimationFrame(() => {
|
||||
const now = performance.now();
|
||||
if (now - this.lastRenderTime >= this.renderInterval) {
|
||||
this.lastRenderTime = now;
|
||||
this.actualRender();
|
||||
this.isDirty = false;
|
||||
}
|
||||
|
||||
if (this.isDirty) {
|
||||
this.renderAnimationFrame = null;
|
||||
this.render();
|
||||
} else {
|
||||
this.renderAnimationFrame = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
actualRender() {
|
||||
if (this.canvas.offscreenCanvas.width !== this.canvas.canvas.clientWidth ||
|
||||
this.canvas.offscreenCanvas.height !== this.canvas.canvas.clientHeight) {
|
||||
const newWidth = Math.max(1, this.canvas.canvas.clientWidth);
|
||||
const newHeight = Math.max(1, this.canvas.canvas.clientHeight);
|
||||
this.canvas.offscreenCanvas.width = newWidth;
|
||||
this.canvas.offscreenCanvas.height = newHeight;
|
||||
}
|
||||
|
||||
const ctx = this.canvas.offscreenCtx;
|
||||
|
||||
ctx.fillStyle = '#606060';
|
||||
ctx.fillRect(0, 0, this.canvas.offscreenCanvas.width, this.canvas.offscreenCanvas.height);
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
|
||||
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
|
||||
|
||||
this.drawGrid(ctx);
|
||||
|
||||
// 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) {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 0.5;
|
||||
} else {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
this.canvas.canvas.width = this.canvas.offscreenCanvas.width;
|
||||
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
||||
}
|
||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
||||
|
||||
// Update Batch Preview UI positions
|
||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||
this.canvas.batchPreviewManagers.forEach((manager: any) => {
|
||||
manager.updateScreenPosition(this.canvas.viewport);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderInteractionElements(ctx: any) {
|
||||
const interaction = this.canvas.interaction;
|
||||
|
||||
if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) {
|
||||
const rect = interaction.canvasResizeRect;
|
||||
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);
|
||||
|
||||
this.drawTextWithBackground(ctx, text, textWorldX, textWorldY, {
|
||||
backgroundColor: "rgba(0, 128, 0, 0.7)"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) {
|
||||
const rect = interaction.canvasMoveRect;
|
||||
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);
|
||||
|
||||
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 || !layer.visible) return;
|
||||
|
||||
const layerIndex = this.canvas.layers.indexOf(layer);
|
||||
const currentWidth = Math.round(layer.width);
|
||||
const currentHeight = Math.round(layer.height);
|
||||
const rotation = Math.round(layer.rotation % 360);
|
||||
let text = `${currentWidth}x${currentHeight} | ${rotation}° | Layer #${layerIndex + 1}`;
|
||||
if (layer.originalWidth && layer.originalHeight) {
|
||||
text += `\nOriginal: ${layer.originalWidth}x${layer.originalHeight}`;
|
||||
}
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
|
||||
const localCorners = [
|
||||
{x: -halfW, y: -halfH},
|
||||
{x: halfW, y: -halfH},
|
||||
{x: halfW, y: halfH},
|
||||
{x: -halfW, y: halfH}
|
||||
];
|
||||
const worldCorners = localCorners.map(p => ({
|
||||
x: centerX + p.x * cos - p.y * sin,
|
||||
y: centerY + p.x * sin + p.y * cos
|
||||
}));
|
||||
let minX = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
worldCorners.forEach(p => {
|
||||
minX = Math.min(minX, p.x);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
});
|
||||
const padding = 20 / this.canvas.viewport.zoom;
|
||||
const textWorldX = (minX + maxX) / 2;
|
||||
const textWorldY = maxY + padding;
|
||||
|
||||
this.drawTextWithBackground(ctx, text, textWorldX, textWorldY);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drawGrid(ctx: any) {
|
||||
const gridSize = 64;
|
||||
const lineWidth = 0.5 / this.canvas.viewport.zoom;
|
||||
|
||||
const viewLeft = this.canvas.viewport.x;
|
||||
const viewTop = this.canvas.viewport.y;
|
||||
const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom;
|
||||
const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.viewport.zoom;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#707070';
|
||||
ctx.lineWidth = lineWidth;
|
||||
|
||||
for (let x = Math.floor(viewLeft / gridSize) * gridSize; x < viewRight; x += gridSize) {
|
||||
ctx.moveTo(x, viewTop);
|
||||
ctx.lineTo(x, viewBottom);
|
||||
}
|
||||
|
||||
for (let y = Math.floor(viewTop / gridSize) * gridSize; y < viewBottom; y += gridSize) {
|
||||
ctx.moveTo(viewLeft, y);
|
||||
ctx.lineTo(viewRight, y);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]);
|
||||
|
||||
// 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) {
|
||||
const lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
const handleRadius = 5 / this.canvas.viewport.zoom;
|
||||
|
||||
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
|
||||
// --- CROP MODE ---
|
||||
ctx.lineWidth = lineWidth;
|
||||
|
||||
// 1. Draw dashed blue line for the full transform frame (the "original size" container)
|
||||
ctx.strokeStyle = '#007bff';
|
||||
ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
|
||||
ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// 2. Draw solid blue line for the crop bounds
|
||||
const layerScaleX = layer.width / layer.originalWidth;
|
||||
const layerScaleY = layer.height / layer.originalHeight;
|
||||
const s = layer.cropBounds;
|
||||
|
||||
const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
|
||||
const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
|
||||
const cropRectW = s.width * layerScaleX;
|
||||
const cropRectH = s.height * layerScaleY;
|
||||
|
||||
ctx.strokeStyle = '#007bff'; // Solid blue
|
||||
this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
|
||||
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
|
||||
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY + cropRectH, cropRectX, cropRectY + cropRectH, layer); // Bottom
|
||||
this.drawAdaptiveLine(ctx, cropRectX, cropRectY + cropRectH, cropRectX, cropRectY, layer); // Left
|
||||
|
||||
} else {
|
||||
// --- TRANSFORM MODE ---
|
||||
ctx.strokeStyle = '#00ff00'; // Green
|
||||
ctx.lineWidth = lineWidth;
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
|
||||
// Draw adaptive solid green line for transform frame
|
||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
||||
|
||||
// Draw line to rotation handle
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
const startY = layer.flipV ? halfH : -halfH;
|
||||
const endY = startY + (layer.flipV ? 1 : -1) * (20 / this.canvas.viewport.zoom);
|
||||
ctx.moveTo(0, startY);
|
||||
ctx.lineTo(0, endY);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// --- DRAW HANDLES (Unified Logic) ---
|
||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
|
||||
for (const key in handles) {
|
||||
// Skip rotation handle in crop mode
|
||||
if (layer.cropMode && key === 'rot') continue;
|
||||
|
||||
const point = handles[key];
|
||||
// The handle position is already in world space.
|
||||
// We need to convert it to the layer's local, un-rotated space.
|
||||
const dx = point.x - centerX;
|
||||
const dy = point.y - centerY;
|
||||
|
||||
// "Un-rotate" the position to get it in the layer's local, un-rotated space
|
||||
const rad = -layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const localX = dx * cos - dy * sin;
|
||||
const localY = dx * sin + dy * cos;
|
||||
|
||||
// The context is already flipped. We need to flip the coordinates
|
||||
// to match the visual transformation, so the arc is drawn in the correct place.
|
||||
const finalX = localX * (layer.flipH ? -1 : 1);
|
||||
const finalY = localY * (layer.flipV ? -1 : 1);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(finalX, finalY, handleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
drawOutputAreaExtensionPreview(ctx: any) {
|
||||
if (!this.canvas.outputAreaExtensionPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
});
|
||||
}
|
||||
}
|
||||
172
src/CanvasSelection.ts
Normal file
172
src/CanvasSelection.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { generateUUID } from "./utils/CommonUtils.js";
|
||||
|
||||
const log = createModuleLogger('CanvasSelection');
|
||||
|
||||
export class CanvasSelection {
|
||||
canvas: any;
|
||||
onSelectionChange: any;
|
||||
selectedLayer: any;
|
||||
selectedLayers: any;
|
||||
constructor(canvas: any) {
|
||||
this.canvas = canvas;
|
||||
this.selectedLayers = [];
|
||||
this.selectedLayer = null;
|
||||
this.onSelectionChange = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
|
||||
*/
|
||||
duplicateSelectedLayers() {
|
||||
if (this.selectedLayers.length === 0) return [];
|
||||
|
||||
const newLayers: any = [];
|
||||
const sortedLayers = [...this.selectedLayers].sort((a,b) => a.zIndex - b.zIndex);
|
||||
|
||||
sortedLayers.forEach(layer => {
|
||||
const newLayer = {
|
||||
...layer,
|
||||
id: generateUUID(),
|
||||
zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu
|
||||
};
|
||||
this.canvas.layers.push(newLayer);
|
||||
newLayers.push(newLayer);
|
||||
});
|
||||
|
||||
// Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego)
|
||||
this.updateSelection(newLayers);
|
||||
|
||||
// Powiadom panel o zmianie struktury, aby się przerysował
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
|
||||
log.info(`Duplicated ${newLayers.length} layers (in-memory).`);
|
||||
return newLayers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
|
||||
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
|
||||
* @param {Array} newSelection - Nowa lista zaznaczonych warstw
|
||||
*/
|
||||
updateSelection(newSelection: any) {
|
||||
const previousSelection = this.selectedLayers.length;
|
||||
// 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
|
||||
const hasChanged = previousSelection !== this.selectedLayers.length ||
|
||||
this.selectedLayers.some((layer: any, i: any) => this.selectedLayers[i] !== (newSelection || [])[i]);
|
||||
|
||||
if (!hasChanged && previousSelection > 0) {
|
||||
// return; // Zablokowane na razie, może powodować problemy
|
||||
}
|
||||
|
||||
log.debug('Selection updated', {
|
||||
previousCount: previousSelection,
|
||||
newCount: this.selectedLayers.length,
|
||||
selectedLayerIds: this.selectedLayers.map((l: any) => l.id || 'unknown')
|
||||
});
|
||||
|
||||
// 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji
|
||||
this.canvas.render();
|
||||
|
||||
// 2. Powiadom inne części aplikacji (jeśli są)
|
||||
if (this.onSelectionChange) {
|
||||
this.onSelectionChange();
|
||||
}
|
||||
|
||||
// 3. Powiadom panel warstw, aby zaktualizował swój wygląd
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
|
||||
*/
|
||||
updateSelectionLogic(layer: any, isCtrlPressed: any, isShiftPressed: any, index: any) {
|
||||
let newSelection = [...this.selectedLayers];
|
||||
let selectionChanged = false;
|
||||
|
||||
if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) {
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||
const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
|
||||
const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
|
||||
|
||||
newSelection = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (sortedLayers[i]) {
|
||||
newSelection.push(sortedLayers[i]);
|
||||
}
|
||||
}
|
||||
selectionChanged = true;
|
||||
} else if (isCtrlPressed) {
|
||||
const layerIndex = newSelection.indexOf(layer);
|
||||
if (layerIndex === -1) {
|
||||
newSelection.push(layer);
|
||||
} else {
|
||||
newSelection.splice(layerIndex, 1);
|
||||
}
|
||||
this.canvas.canvasLayersPanel.lastSelectedIndex = index;
|
||||
selectionChanged = true;
|
||||
} else {
|
||||
// Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia,
|
||||
// wyczyść zaznaczenie i zaznacz tylko ją.
|
||||
if (!this.selectedLayers.includes(layer)) {
|
||||
newSelection = [layer];
|
||||
selectionChanged = true;
|
||||
}
|
||||
// Jeśli kliknięta warstwa JEST już zaznaczona (potencjalnie z innymi),
|
||||
// NIE rób nic, aby umożliwić przeciąganie całej grupy.
|
||||
this.canvas.canvasLayersPanel.lastSelectedIndex = index;
|
||||
}
|
||||
|
||||
// Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło
|
||||
if (selectionChanged) {
|
||||
this.updateSelection(newSelection);
|
||||
}
|
||||
}
|
||||
|
||||
removeSelectedLayers() {
|
||||
if (this.selectedLayers.length > 0) {
|
||||
log.info('Removing selected layers', {
|
||||
layersToRemove: this.selectedLayers.length,
|
||||
totalLayers: this.canvas.layers.length
|
||||
});
|
||||
|
||||
this.canvas.saveState();
|
||||
this.canvas.layers = this.canvas.layers.filter((l: any) => !this.selectedLayers.includes(l));
|
||||
|
||||
this.updateSelection([]);
|
||||
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
|
||||
log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length);
|
||||
} else {
|
||||
log.debug('No layers selected for removal');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje zaznaczenie po operacji historii
|
||||
*/
|
||||
updateSelectionAfterHistory() {
|
||||
const newSelectedLayers: any = [];
|
||||
if (this.selectedLayers) {
|
||||
this.selectedLayers.forEach((sl: any) => {
|
||||
const found = this.canvas.layers.find((l: any) => l.id === sl.id);
|
||||
if (found) newSelectedLayers.push(found);
|
||||
});
|
||||
}
|
||||
this.updateSelection(newSelectedLayers);
|
||||
}
|
||||
}
|
||||
526
src/CanvasState.ts
Normal file
526
src/CanvasState.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
import {getCanvasState, setCanvasState, saveImage, getImage} from "./db.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.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';
|
||||
|
||||
const log = createModuleLogger('CanvasState');
|
||||
|
||||
interface HistoryInfo {
|
||||
undoCount: number;
|
||||
redoCount: number;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
historyLimit: number;
|
||||
}
|
||||
|
||||
export class CanvasState {
|
||||
private _debouncedSave: (() => void) | null;
|
||||
private _loadInProgress: Promise<boolean> | null;
|
||||
private canvas: Canvas & { node: ComfyNode, layers: Layer[] };
|
||||
private historyLimit: number;
|
||||
private lastSavedStateSignature: string | null;
|
||||
public layersRedoStack: Layer[][];
|
||||
public layersUndoStack: Layer[][];
|
||||
public maskRedoStack: HTMLCanvasElement[];
|
||||
public maskUndoStack: HTMLCanvasElement[];
|
||||
private saveTimeout: number | null;
|
||||
private stateSaverWorker: Worker | null;
|
||||
|
||||
constructor(canvas: Canvas & { node: ComfyNode, layers: Layer[] }) {
|
||||
this.canvas = canvas;
|
||||
this.layersUndoStack = [];
|
||||
this.layersRedoStack = [];
|
||||
this.maskUndoStack = [];
|
||||
this.maskRedoStack = [];
|
||||
this.historyLimit = 100;
|
||||
this.saveTimeout = null;
|
||||
this.lastSavedStateSignature = null;
|
||||
this._loadInProgress = null;
|
||||
this._debouncedSave = null;
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' });
|
||||
log.info("State saver worker initialized successfully.");
|
||||
|
||||
this.stateSaverWorker.onmessage = (e: MessageEvent) => {
|
||||
log.info("Message from state saver worker:", e.data);
|
||||
};
|
||||
this.stateSaverWorker.onerror = (e: ErrorEvent) => {
|
||||
log.error("Error in state saver worker:", e.message, e.filename, e.lineno);
|
||||
this.stateSaverWorker = null;
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Failed to initialize state saver worker:", e);
|
||||
this.stateSaverWorker = null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadStateFromDB(): Promise<boolean> {
|
||||
if (this._loadInProgress) {
|
||||
log.warn("Load already in progress, waiting...");
|
||||
return this._loadInProgress;
|
||||
}
|
||||
|
||||
log.info("Attempting to load state from IndexedDB for node:", this.canvas.node.id);
|
||||
const loadPromise = this._performLoad();
|
||||
this._loadInProgress = loadPromise;
|
||||
|
||||
try {
|
||||
const result = await loadPromise;
|
||||
this._loadInProgress = null;
|
||||
return result;
|
||||
} catch (error) {
|
||||
this._loadInProgress = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async _performLoad(): Promise<boolean> {
|
||||
try {
|
||||
if (!this.canvas.node.id) {
|
||||
log.error("Node ID is not available for loading state from DB.");
|
||||
return false;
|
||||
}
|
||||
const savedState = await getCanvasState(String(this.canvas.node.id));
|
||||
if (!savedState) {
|
||||
log.info("No saved state found in IndexedDB for node:", this.canvas.node.id);
|
||||
return false;
|
||||
}
|
||||
log.info("Found saved state in IndexedDB.");
|
||||
this.canvas.width = savedState.width || 512;
|
||||
this.canvas.height = savedState.height || 512;
|
||||
this.canvas.viewport = savedState.viewport || {
|
||||
x: -(this.canvas.width / 4),
|
||||
y: -(this.canvas.height / 4),
|
||||
zoom: 0.8
|
||||
};
|
||||
|
||||
// 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);
|
||||
this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null);
|
||||
log.info(`Loaded ${this.canvas.layers.length} layers.`);
|
||||
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn("No valid layers loaded, state may be corrupted.");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error("Error during state load:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje warstwy z zapisanego stanu
|
||||
* @param {any[]} layersData - Dane warstw do załadowania
|
||||
* @returns {Promise<(Layer | null)[]>} Załadowane warstwy
|
||||
*/
|
||||
async _loadLayers(layersData: any[]): Promise<(Layer | null)[]> {
|
||||
const imagePromises = layersData.map((layerData: any, index: number) =>
|
||||
this._loadSingleLayer(layerData, index)
|
||||
);
|
||||
return Promise.all(imagePromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje pojedynczą warstwę
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @returns {Promise<Layer | null>} Załadowana warstwa lub null
|
||||
*/
|
||||
async _loadSingleLayer(layerData: Layer, index: number): Promise<Layer | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (layerData.imageId) {
|
||||
this._loadLayerFromImageId(layerData, index, resolve);
|
||||
} else if ((layerData as any).imageSrc) {
|
||||
this._convertLegacyLayer(layerData, index, resolve);
|
||||
} else {
|
||||
log.error(`Layer ${index}: No imageId or imageSrc found, skipping layer.`);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje warstwę z imageId
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
||||
*/
|
||||
_loadLayerFromImageId(layerData: Layer, index: number, resolve: (value: Layer | null) => void): void {
|
||||
log.debug(`Layer ${index}: Loading image with id: ${layerData.imageId}`);
|
||||
|
||||
if (this.canvas.imageCache.has(layerData.imageId)) {
|
||||
log.debug(`Layer ${index}: Image found in cache.`);
|
||||
const imageData = this.canvas.imageCache.get(layerData.imageId);
|
||||
if (imageData) {
|
||||
const imageSrc = URL.createObjectURL(new Blob([imageData.data]));
|
||||
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
} else {
|
||||
getImage(layerData.imageId)
|
||||
.then(imageSrc => {
|
||||
if (imageSrc) {
|
||||
log.debug(`Layer ${index}: Loading image from data:URL...`);
|
||||
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
||||
} else {
|
||||
log.error(`Layer ${index}: Image not found in IndexedDB.`);
|
||||
resolve(null);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
log.error(`Layer ${index}: Error loading image from IndexedDB:`, err);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje starą warstwę z imageSrc na nowy format
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
||||
*/
|
||||
_convertLegacyLayer(layerData: Layer, index: number, resolve: (value: Layer | null) => void): void {
|
||||
log.info(`Layer ${index}: Found imageSrc, converting to new format with imageId.`);
|
||||
const imageId = generateUUID();
|
||||
|
||||
saveImage(imageId, (layerData as any).imageSrc)
|
||||
.then(() => {
|
||||
log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`);
|
||||
const newLayerData = {...layerData, imageId};
|
||||
delete (newLayerData as any).imageSrc;
|
||||
this._createLayerFromSrc(newLayerData, (layerData as any).imageSrc, index, resolve);
|
||||
})
|
||||
.catch(err => {
|
||||
log.error(`Layer ${index}: Error saving image to IndexedDB:`, err);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy warstwę z src obrazu
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {string} imageSrc - Źródło obrazu
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
||||
*/
|
||||
_createLayerFromSrc(layerData: Layer, imageSrc: string | ImageBitmap, index: number, resolve: (value: Layer | null) => void): void {
|
||||
if (typeof imageSrc === 'string') {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully.`);
|
||||
const newLayer: Layer = {...layerData, image: img};
|
||||
resolve(newLayer);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.error(`Layer ${index}: Failed to load image from src.`);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = imageSrc;
|
||||
} else {
|
||||
const { canvas, ctx } = createCanvas(imageSrc.width, imageSrc.height);
|
||||
if (ctx) {
|
||||
ctx.drawImage(imageSrc, 0, 0);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
||||
const newLayer: Layer = {...layerData, image: img};
|
||||
resolve(newLayer);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.error(`Layer ${index}: Failed to load image from ImageBitmap.`);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = canvas.toDataURL();
|
||||
} else {
|
||||
log.error(`Layer ${index}: Failed to get 2d context from canvas.`);
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveStateToDB(): Promise<void> {
|
||||
if (!this.canvas.node.id) {
|
||||
log.error("Node ID is not available for saving state to DB.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 = {
|
||||
layers: layers.filter(layer => layer !== null),
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stateSaverWorker) {
|
||||
log.info("Posting state to worker for background saving.");
|
||||
this.stateSaverWorker.postMessage({
|
||||
nodeId: String(this.canvas.node.id),
|
||||
state: state
|
||||
});
|
||||
this.canvas.render();
|
||||
} else {
|
||||
log.warn("State saver worker not available. Saving on main thread.");
|
||||
await setCanvasState(String(this.canvas.node.id), state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Przygotowuje warstwy do zapisu
|
||||
* @returns {Promise<(Omit<Layer, 'image'> & { imageId: string })[]>} Przygotowane warstwy
|
||||
*/
|
||||
async _prepareLayers(): Promise<(Omit<Layer, 'image'> & { imageId: string })[]> {
|
||||
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer: Layer, index: number) => {
|
||||
const newLayer: Omit<Layer, 'image'> & { imageId: string } = { ...layer, imageId: layer.imageId || '' };
|
||||
delete (newLayer as any).image;
|
||||
|
||||
if (layer.image instanceof HTMLImageElement) {
|
||||
if (layer.imageId) {
|
||||
newLayer.imageId = layer.imageId;
|
||||
} else {
|
||||
log.debug(`Layer ${index}: No imageId found, generating new one and saving image.`);
|
||||
newLayer.imageId = generateUUID();
|
||||
const imageBitmap = await createImageBitmap(layer.image);
|
||||
await saveImage(newLayer.imageId, imageBitmap);
|
||||
}
|
||||
} else if (!layer.imageId) {
|
||||
log.error(`Layer ${index}: No image or imageId found, skipping layer.`);
|
||||
return null;
|
||||
}
|
||||
return newLayer;
|
||||
}));
|
||||
return preparedLayers.filter((layer): layer is Omit<Layer, 'image'> & { imageId: string } => layer !== null);
|
||||
}
|
||||
|
||||
saveState(replaceLast = false): void {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.saveMaskState(replaceLast);
|
||||
} else {
|
||||
this.saveLayersState(replaceLast);
|
||||
}
|
||||
}
|
||||
|
||||
saveLayersState(replaceLast = false): void {
|
||||
if (replaceLast && this.layersUndoStack.length > 0) {
|
||||
this.layersUndoStack.pop();
|
||||
}
|
||||
|
||||
const currentState = cloneLayers(this.canvas.layers);
|
||||
const currentStateSignature = getStateSignature(currentState);
|
||||
|
||||
if (this.layersUndoStack.length > 0) {
|
||||
const lastState = this.layersUndoStack[this.layersUndoStack.length - 1];
|
||||
if (getStateSignature(lastState) === currentStateSignature) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.layersUndoStack.push(currentState);
|
||||
|
||||
if (this.layersUndoStack.length > this.historyLimit) {
|
||||
this.layersUndoStack.shift();
|
||||
}
|
||||
this.layersRedoStack = [];
|
||||
this.canvas.updateHistoryButtons();
|
||||
|
||||
if (!this._debouncedSave) {
|
||||
this._debouncedSave = debounce(this.saveStateToDB.bind(this), 1000);
|
||||
}
|
||||
this._debouncedSave();
|
||||
}
|
||||
|
||||
saveMaskState(replaceLast = false): void {
|
||||
if (!this.canvas.maskTool) return;
|
||||
|
||||
if (replaceLast && this.maskUndoStack.length > 0) {
|
||||
this.maskUndoStack.pop();
|
||||
}
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const { canvas: clonedCanvas, ctx: clonedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', { willReadFrequently: true });
|
||||
if (clonedCtx) {
|
||||
clonedCtx.drawImage(maskCanvas, 0, 0);
|
||||
}
|
||||
|
||||
this.maskUndoStack.push(clonedCanvas);
|
||||
|
||||
if (this.maskUndoStack.length > this.historyLimit) {
|
||||
this.maskUndoStack.shift();
|
||||
}
|
||||
this.maskRedoStack = [];
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.undoMaskState();
|
||||
} else {
|
||||
this.undoLayersState();
|
||||
}
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.redoMaskState();
|
||||
} else {
|
||||
this.redoLayersState();
|
||||
}
|
||||
}
|
||||
|
||||
undoLayersState(): void {
|
||||
if (this.layersUndoStack.length <= 1) return;
|
||||
|
||||
const currentState = this.layersUndoStack.pop();
|
||||
if (currentState) {
|
||||
this.layersRedoStack.push(currentState);
|
||||
}
|
||||
const prevState = this.layersUndoStack[this.layersUndoStack.length - 1];
|
||||
this.canvas.layers = cloneLayers(prevState);
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
redoLayersState(): void {
|
||||
if (this.layersRedoStack.length === 0) return;
|
||||
|
||||
const nextState = this.layersRedoStack.pop();
|
||||
if (nextState) {
|
||||
this.layersUndoStack.push(nextState);
|
||||
this.canvas.layers = cloneLayers(nextState);
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
}
|
||||
|
||||
undoMaskState(): void {
|
||||
if (!this.canvas.maskTool || this.maskUndoStack.length <= 1) return;
|
||||
|
||||
const currentState = this.maskUndoStack.pop();
|
||||
if (currentState) {
|
||||
this.maskRedoStack.push(currentState);
|
||||
}
|
||||
|
||||
if (this.maskUndoStack.length > 0) {
|
||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (maskCtx) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(prevState, 0, 0);
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
redoMaskState(): void {
|
||||
if (!this.canvas.maskTool || this.maskRedoStack.length === 0) return;
|
||||
|
||||
const nextState = this.maskRedoStack.pop();
|
||||
if (nextState) {
|
||||
this.maskUndoStack.push(nextState);
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (maskCtx) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(nextState, 0, 0);
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści historię undo/redo
|
||||
*/
|
||||
clearHistory(): void {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.maskUndoStack = [];
|
||||
this.maskRedoStack = [];
|
||||
} else {
|
||||
this.layersUndoStack = [];
|
||||
this.layersRedoStack = [];
|
||||
}
|
||||
this.canvas.updateHistoryButtons();
|
||||
log.info("History cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca informacje o historii
|
||||
* @returns {HistoryInfo} Informacje o historii
|
||||
*/
|
||||
getHistoryInfo(): HistoryInfo {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
return {
|
||||
undoCount: this.maskUndoStack.length,
|
||||
redoCount: this.maskRedoStack.length,
|
||||
canUndo: this.maskUndoStack.length > 1,
|
||||
canRedo: this.maskRedoStack.length > 0,
|
||||
historyLimit: this.historyLimit
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
undoCount: this.layersUndoStack.length,
|
||||
redoCount: this.layersRedoStack.length,
|
||||
canUndo: this.layersUndoStack.length > 1,
|
||||
canRedo: this.layersRedoStack.length > 0,
|
||||
historyLimit: this.historyLimit
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
1502
src/CanvasView.ts
Normal file
1502
src/CanvasView.ts
Normal file
File diff suppressed because it is too large
Load Diff
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}`);
|
||||
}
|
||||
}
|
||||
383
src/ErrorHandler.ts
Normal file
383
src/ErrorHandler.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* ErrorHandler - Centralna obsługa błędów
|
||||
* Eliminuje powtarzalne wzorce obsługi błędów w całym projekcie
|
||||
*/
|
||||
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
const log = createModuleLogger('ErrorHandler');
|
||||
|
||||
/**
|
||||
* Typy błędów w aplikacji
|
||||
*/
|
||||
export const ErrorTypes = {
|
||||
VALIDATION: 'VALIDATION_ERROR',
|
||||
NETWORK: 'NETWORK_ERROR',
|
||||
FILE_IO: 'FILE_IO_ERROR',
|
||||
CANVAS: 'CANVAS_ERROR',
|
||||
IMAGE_PROCESSING: 'IMAGE_PROCESSING_ERROR',
|
||||
STATE_MANAGEMENT: 'STATE_MANAGEMENT_ERROR',
|
||||
USER_INPUT: 'USER_INPUT_ERROR',
|
||||
SYSTEM: 'SYSTEM_ERROR'
|
||||
} as const;
|
||||
|
||||
export type ErrorType = typeof ErrorTypes[keyof typeof ErrorTypes];
|
||||
|
||||
interface ErrorHistoryEntry {
|
||||
timestamp: string;
|
||||
type: ErrorType;
|
||||
message: string;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
interface ErrorStats {
|
||||
totalErrors: number;
|
||||
errorCounts: { [key: string]: number };
|
||||
recentErrors: ErrorHistoryEntry[];
|
||||
errorsByType: { [key: string]: ErrorHistoryEntry[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Klasa błędu aplikacji z dodatkowymi informacjami
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
details: any;
|
||||
originalError: Error | null;
|
||||
timestamp: string;
|
||||
type: ErrorType;
|
||||
constructor(message: string, type: ErrorType = ErrorTypes.SYSTEM, details: any = null, originalError: Error | null = null) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
this.type = type;
|
||||
this.details = details;
|
||||
this.originalError = originalError;
|
||||
this.timestamp = new Date().toISOString();
|
||||
if ((Error as any).captureStackTrace) {
|
||||
(Error as any).captureStackTrace(this, AppError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler błędów z automatycznym logowaniem i kategoryzacją
|
||||
*/
|
||||
export class ErrorHandler {
|
||||
private errorCounts: Map<ErrorType, number>;
|
||||
private errorHistory: ErrorHistoryEntry[];
|
||||
private maxHistorySize: number;
|
||||
|
||||
constructor() {
|
||||
this.errorCounts = new Map();
|
||||
this.errorHistory = [];
|
||||
this.maxHistorySize = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsługuje błąd z automatycznym logowaniem
|
||||
* @param {Error | AppError | string} error - Błąd do obsłużenia
|
||||
* @param {string} context - Kontekst wystąpienia błędu
|
||||
* @param {object} additionalInfo - Dodatkowe informacje
|
||||
* @returns {AppError} Znormalizowany błąd
|
||||
*/
|
||||
handle(error: Error | AppError | string, context = 'Unknown', additionalInfo: object = {}): AppError {
|
||||
const normalizedError = this.normalizeError(error, context, additionalInfo);
|
||||
this.logError(normalizedError, context);
|
||||
this.recordError(normalizedError);
|
||||
this.incrementErrorCount(normalizedError.type);
|
||||
|
||||
return normalizedError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizuje błąd do standardowego formatu
|
||||
* @param {Error | AppError | string} error - Błąd do znormalizowania
|
||||
* @param {string} context - Kontekst
|
||||
* @param {object} additionalInfo - Dodatkowe informacje
|
||||
* @returns {AppError} Znormalizowany błąd
|
||||
*/
|
||||
normalizeError(error: Error | AppError | string, context: string, additionalInfo: object): AppError {
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const type = this.categorizeError(error, context);
|
||||
return new AppError(
|
||||
error.message,
|
||||
type,
|
||||
{context, ...additionalInfo},
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return new AppError(
|
||||
error,
|
||||
ErrorTypes.SYSTEM,
|
||||
{context, ...additionalInfo}
|
||||
);
|
||||
}
|
||||
|
||||
return new AppError(
|
||||
'Unknown error occurred',
|
||||
ErrorTypes.SYSTEM,
|
||||
{context, originalError: error, ...additionalInfo}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategoryzuje błąd na podstawie wiadomości i kontekstu
|
||||
* @param {Error} error - Błąd do skategoryzowania
|
||||
* @param {string} context - Kontekst
|
||||
* @returns {ErrorType} Typ błędu
|
||||
*/
|
||||
categorizeError(error: Error, context: string): ErrorType {
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('fetch') || message.includes('network') ||
|
||||
message.includes('connection') || message.includes('timeout')) {
|
||||
return ErrorTypes.NETWORK;
|
||||
}
|
||||
if (message.includes('file') || message.includes('read') ||
|
||||
message.includes('write') || message.includes('path')) {
|
||||
return ErrorTypes.FILE_IO;
|
||||
}
|
||||
if (message.includes('invalid') || message.includes('required') ||
|
||||
message.includes('validation') || message.includes('format')) {
|
||||
return ErrorTypes.VALIDATION;
|
||||
}
|
||||
if (message.includes('image') || message.includes('canvas') ||
|
||||
message.includes('blob') || message.includes('tensor')) {
|
||||
return ErrorTypes.IMAGE_PROCESSING;
|
||||
}
|
||||
if (message.includes('state') || message.includes('cache') ||
|
||||
message.includes('storage')) {
|
||||
return ErrorTypes.STATE_MANAGEMENT;
|
||||
}
|
||||
if (context.toLowerCase().includes('canvas')) {
|
||||
return ErrorTypes.CANVAS;
|
||||
}
|
||||
|
||||
return ErrorTypes.SYSTEM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loguje błąd z odpowiednim poziomem
|
||||
* @param {AppError} error - Błąd do zalogowania
|
||||
* @param {string} context - Kontekst
|
||||
*/
|
||||
logError(error: AppError, context: string): void {
|
||||
const logMessage = `[${error.type}] ${error.message}`;
|
||||
const logDetails = {
|
||||
context,
|
||||
timestamp: error.timestamp,
|
||||
details: error.details,
|
||||
stack: error.stack
|
||||
};
|
||||
switch (error.type) {
|
||||
case ErrorTypes.VALIDATION:
|
||||
case ErrorTypes.USER_INPUT:
|
||||
log.warn(logMessage, logDetails);
|
||||
break;
|
||||
case ErrorTypes.NETWORK:
|
||||
log.error(logMessage, logDetails);
|
||||
break;
|
||||
default:
|
||||
log.error(logMessage, logDetails);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisuje błąd w historii
|
||||
* @param {AppError} error - Błąd do zapisania
|
||||
*/
|
||||
recordError(error: AppError): void {
|
||||
this.errorHistory.push({
|
||||
timestamp: error.timestamp,
|
||||
type: error.type,
|
||||
message: error.message,
|
||||
context: error.details?.context
|
||||
});
|
||||
if (this.errorHistory.length > this.maxHistorySize) {
|
||||
this.errorHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwiększa licznik błędów dla danego typu
|
||||
* @param {ErrorType} errorType - Typ błędu
|
||||
*/
|
||||
incrementErrorCount(errorType: ErrorType): void {
|
||||
const current = this.errorCounts.get(errorType) || 0;
|
||||
this.errorCounts.set(errorType, current + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca statystyki błędów
|
||||
* @returns {ErrorStats} Statystyki błędów
|
||||
*/
|
||||
getErrorStats(): ErrorStats {
|
||||
const errorCountsObj: { [key: string]: number } = {};
|
||||
for (const [key, value] of this.errorCounts.entries()) {
|
||||
errorCountsObj[key] = value;
|
||||
}
|
||||
return {
|
||||
totalErrors: this.errorHistory.length,
|
||||
errorCounts: errorCountsObj,
|
||||
recentErrors: this.errorHistory.slice(-10),
|
||||
errorsByType: this.groupErrorsByType()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Grupuje błędy według typu
|
||||
* @returns {{ [key: string]: ErrorHistoryEntry[] }} Błędy pogrupowane według typu
|
||||
*/
|
||||
groupErrorsByType(): { [key: string]: ErrorHistoryEntry[] } {
|
||||
const grouped: { [key: string]: ErrorHistoryEntry[] } = {};
|
||||
this.errorHistory.forEach((error) => {
|
||||
if (!grouped[error.type]) {
|
||||
grouped[error.type] = [];
|
||||
}
|
||||
grouped[error.type].push(error);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści historię błędów
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.errorHistory = [];
|
||||
this.errorCounts.clear();
|
||||
log.info('Error history cleared');
|
||||
}
|
||||
}
|
||||
|
||||
const errorHandler = new ErrorHandler();
|
||||
|
||||
/**
|
||||
* Wrapper funkcji z automatyczną obsługą błędów
|
||||
* @param {Function} fn - Funkcja do opakowania
|
||||
* @param {string} context - Kontekst wykonania
|
||||
* @returns {Function} Opakowana funkcja
|
||||
*/
|
||||
export function withErrorHandling<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
context: string
|
||||
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
|
||||
return async function(this: any, ...args: Parameters<T>): Promise<ReturnType<T>> {
|
||||
try {
|
||||
return await fn.apply(this, args);
|
||||
} catch (error) {
|
||||
const handledError = errorHandler.handle(error as Error, context, {
|
||||
functionName: fn.name,
|
||||
arguments: args.length
|
||||
});
|
||||
throw handledError;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator dla metod klasy z automatyczną obsługą błędów
|
||||
* @param {string} context - Kontekst wykonania
|
||||
*/
|
||||
export function handleErrors(context: string) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
try {
|
||||
return await originalMethod.apply(this, args);
|
||||
} catch (error) {
|
||||
const handledError = errorHandler.handle(error as Error, `${context}.${propertyKey}`, {
|
||||
className: target.constructor.name,
|
||||
methodName: propertyKey,
|
||||
arguments: args.length
|
||||
});
|
||||
throw handledError;
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia błędów walidacji
|
||||
* @param {string} message - Wiadomość błędu
|
||||
* @param {object} details - Szczegóły walidacji
|
||||
* @returns {AppError} Błąd walidacji
|
||||
*/
|
||||
export function createValidationError(message: string, details: object = {}): AppError {
|
||||
return new AppError(message, ErrorTypes.VALIDATION, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia błędów sieciowych
|
||||
* @param {string} message - Wiadomość błędu
|
||||
* @param {object} details - Szczegóły sieci
|
||||
* @returns {AppError} Błąd sieciowy
|
||||
*/
|
||||
export function createNetworkError(message: string, details: object = {}): AppError {
|
||||
return new AppError(message, ErrorTypes.NETWORK, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia błędów plików
|
||||
* @param {string} message - Wiadomość błędu
|
||||
* @param {object} details - Szczegóły pliku
|
||||
* @returns {AppError} Błąd pliku
|
||||
*/
|
||||
export function createFileError(message: string, details: object = {}): AppError {
|
||||
return new AppError(message, ErrorTypes.FILE_IO, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do bezpiecznego wykonania operacji
|
||||
* @param {() => Promise<T>} operation - Operacja do wykonania
|
||||
* @param {T} fallbackValue - Wartość fallback w przypadku błędu
|
||||
* @param {string} context - Kontekst operacji
|
||||
* @returns {Promise<T>} Wynik operacji lub wartość fallback
|
||||
*/
|
||||
export async function safeExecute<T>(operation: () => Promise<T>, fallbackValue: T, context = 'SafeExecute'): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
errorHandler.handle(error as Error, context);
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja do retry operacji z exponential backoff
|
||||
* @param {() => Promise<T>} operation - Operacja do powtórzenia
|
||||
* @param {number} maxRetries - Maksymalna liczba prób
|
||||
* @param {number} baseDelay - Podstawowe opóźnienie w ms
|
||||
* @param {string} context - Kontekst operacji
|
||||
* @returns {Promise<T>} Wynik operacji
|
||||
*/
|
||||
export async function retryWithBackoff<T>(operation: () => Promise<T>, maxRetries = 3, baseDelay = 1000, context = 'RetryOperation'): Promise<T> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = baseDelay * Math.pow(2, attempt);
|
||||
log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`, {error: lastError.message, context});
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw errorHandler.handle(lastError!, context, {attempts: maxRetries + 1});
|
||||
}
|
||||
|
||||
export {errorHandler};
|
||||
export default errorHandler;
|
||||
32
src/ImageCache.ts
Normal file
32
src/ImageCache.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import type { ImageDataPixel } from './types';
|
||||
|
||||
const log = createModuleLogger('ImageCache');
|
||||
|
||||
export class ImageCache {
|
||||
private cache: Map<string, ImageDataPixel>;
|
||||
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
set(key: string, imageData: ImageDataPixel): void {
|
||||
log.info("Caching image data for key:", key);
|
||||
this.cache.set(key, imageData);
|
||||
}
|
||||
|
||||
get(key: string): ImageDataPixel | undefined {
|
||||
const data = this.cache.get(key);
|
||||
log.debug("Retrieved cached data for key:", key, !!data);
|
||||
return data;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
log.info("Clearing image cache");
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
309
src/ImageReferenceManager.ts
Normal file
309
src/ImageReferenceManager.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import {removeImage, getAllImageIds} from "./db.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer, CanvasState } from './types';
|
||||
|
||||
const log = createModuleLogger('ImageReferenceManager');
|
||||
|
||||
interface GarbageCollectionStats {
|
||||
trackedImages: number;
|
||||
totalReferences: number;
|
||||
isRunning: boolean;
|
||||
gcInterval: number;
|
||||
maxAge: number;
|
||||
}
|
||||
|
||||
export class ImageReferenceManager {
|
||||
private canvas: Canvas & { canvasState: CanvasState };
|
||||
private gcInterval: number;
|
||||
private gcTimer: number | null;
|
||||
private imageLastUsed: Map<string, number>;
|
||||
private imageReferences: Map<string, number>;
|
||||
private isGcRunning: boolean;
|
||||
private maxAge: number;
|
||||
public operationCount: number;
|
||||
public operationThreshold: number;
|
||||
|
||||
constructor(canvas: Canvas & { canvasState: CanvasState }) {
|
||||
this.canvas = canvas;
|
||||
this.imageReferences = new Map(); // imageId -> count
|
||||
this.imageLastUsed = new Map(); // imageId -> timestamp
|
||||
this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane)
|
||||
this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia
|
||||
this.gcTimer = null;
|
||||
this.isGcRunning = false;
|
||||
this.operationCount = 0;
|
||||
this.operationThreshold = 500; // Uruchom GC po 500 operacjach
|
||||
}
|
||||
|
||||
/**
|
||||
* Uruchamia automatyczne garbage collection
|
||||
*/
|
||||
startGarbageCollection(): void {
|
||||
if (this.gcTimer) {
|
||||
clearInterval(this.gcTimer);
|
||||
}
|
||||
|
||||
this.gcTimer = window.setInterval(() => {
|
||||
this.performGarbageCollection();
|
||||
}, this.gcInterval);
|
||||
|
||||
log.info("Garbage collection started with interval:", this.gcInterval / 1000, "seconds");
|
||||
}
|
||||
|
||||
/**
|
||||
* Zatrzymuje automatyczne garbage collection
|
||||
*/
|
||||
stopGarbageCollection(): void {
|
||||
if (this.gcTimer) {
|
||||
clearInterval(this.gcTimer);
|
||||
this.gcTimer = null;
|
||||
}
|
||||
log.info("Garbage collection stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dodaje referencję do obrazu
|
||||
* @param {string} imageId - ID obrazu
|
||||
*/
|
||||
addReference(imageId: string): void {
|
||||
if (!imageId) return;
|
||||
|
||||
const currentCount = this.imageReferences.get(imageId) || 0;
|
||||
this.imageReferences.set(imageId, currentCount + 1);
|
||||
this.imageLastUsed.set(imageId, Date.now());
|
||||
|
||||
log.debug(`Added reference to image ${imageId}, count: ${currentCount + 1}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuwa referencję do obrazu
|
||||
* @param {string} imageId - ID obrazu
|
||||
*/
|
||||
removeReference(imageId: string): void {
|
||||
if (!imageId) return;
|
||||
|
||||
const currentCount = this.imageReferences.get(imageId) || 0;
|
||||
if (currentCount <= 1) {
|
||||
this.imageReferences.delete(imageId);
|
||||
log.debug(`Removed last reference to image ${imageId}`);
|
||||
} else {
|
||||
this.imageReferences.set(imageId, currentCount - 1);
|
||||
log.debug(`Removed reference to image ${imageId}, count: ${currentCount - 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje referencje na podstawie aktualnego stanu canvas
|
||||
*/
|
||||
updateReferences(): void {
|
||||
log.debug("Updating image references...");
|
||||
this.imageReferences.clear();
|
||||
const usedImageIds = this.collectAllUsedImageIds();
|
||||
usedImageIds.forEach(imageId => {
|
||||
this.addReference(imageId);
|
||||
});
|
||||
|
||||
log.info(`Updated references for ${usedImageIds.size} unique images`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zbiera wszystkie używane imageId z różnych źródeł
|
||||
* @returns {Set<string>} Zbiór używanych imageId
|
||||
*/
|
||||
collectAllUsedImageIds(): Set<string> {
|
||||
const usedImageIds = new Set<string>();
|
||||
this.canvas.layers.forEach((layer: Layer) => {
|
||||
if (layer.imageId) {
|
||||
usedImageIds.add(layer.imageId);
|
||||
}
|
||||
});
|
||||
if (this.canvas.canvasState && this.canvas.canvasState.layersUndoStack) {
|
||||
this.canvas.canvasState.layersUndoStack.forEach((layersState: Layer[]) => {
|
||||
layersState.forEach((layer: Layer) => {
|
||||
if (layer.imageId) {
|
||||
usedImageIds.add(layer.imageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.canvas.canvasState && this.canvas.canvasState.layersRedoStack) {
|
||||
this.canvas.canvasState.layersRedoStack.forEach((layersState: Layer[]) => {
|
||||
layersState.forEach((layer: Layer) => {
|
||||
if (layer.imageId) {
|
||||
usedImageIds.add(layer.imageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
log.debug(`Collected ${usedImageIds.size} used image IDs`);
|
||||
return usedImageIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Znajduje nieużywane obrazy
|
||||
* @param {Set<string>} usedImageIds - Zbiór używanych imageId
|
||||
* @returns {Promise<string[]>} Lista nieużywanych imageId
|
||||
*/
|
||||
async findUnusedImages(usedImageIds: Set<string>): Promise<string[]> {
|
||||
try {
|
||||
const allImageIds = await getAllImageIds();
|
||||
const unusedImages: string[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const imageId of allImageIds) {
|
||||
if (!usedImageIds.has(imageId)) {
|
||||
const lastUsed = this.imageLastUsed.get(imageId) || 0;
|
||||
const age = now - lastUsed;
|
||||
|
||||
if (age > this.maxAge) {
|
||||
unusedImages.push(imageId);
|
||||
} else {
|
||||
log.debug(`Image ${imageId} is unused but too young (age: ${Math.round(age / 1000)}s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`Found ${unusedImages.length} unused images ready for cleanup`);
|
||||
return unusedImages;
|
||||
} catch (error) {
|
||||
log.error("Error finding unused images:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści nieużywane obrazy
|
||||
* @param {string[]} unusedImages - Lista nieużywanych imageId
|
||||
*/
|
||||
async cleanupUnusedImages(unusedImages: string[]): Promise<void> {
|
||||
if (unusedImages.length === 0) {
|
||||
log.debug("No unused images to cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Starting cleanup of ${unusedImages.length} unused images`);
|
||||
let cleanedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const imageId of unusedImages) {
|
||||
try {
|
||||
|
||||
await removeImage(imageId);
|
||||
|
||||
if (this.canvas.imageCache && this.canvas.imageCache.has(imageId)) {
|
||||
this.canvas.imageCache.delete(imageId);
|
||||
}
|
||||
|
||||
this.imageReferences.delete(imageId);
|
||||
this.imageLastUsed.delete(imageId);
|
||||
|
||||
cleanedCount++;
|
||||
log.debug(`Cleaned up image: ${imageId}`);
|
||||
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
log.error(`Error cleaning up image ${imageId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Garbage collection completed: ${cleanedCount} images cleaned, ${errorCount} errors`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wykonuje pełne garbage collection
|
||||
*/
|
||||
async performGarbageCollection(): Promise<void> {
|
||||
if (this.isGcRunning) {
|
||||
log.debug("Garbage collection already running, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGcRunning = true;
|
||||
log.info("Starting garbage collection...");
|
||||
|
||||
try {
|
||||
|
||||
this.updateReferences();
|
||||
|
||||
const usedImageIds = this.collectAllUsedImageIds();
|
||||
|
||||
const unusedImages = await this.findUnusedImages(usedImageIds);
|
||||
|
||||
await this.cleanupUnusedImages(unusedImages);
|
||||
|
||||
} catch (error) {
|
||||
log.error("Error during garbage collection:", error);
|
||||
} finally {
|
||||
this.isGcRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwiększa licznik operacji i sprawdza czy uruchomić GC
|
||||
*/
|
||||
incrementOperationCount(): void {
|
||||
this.operationCount++;
|
||||
log.debug(`Operation count: ${this.operationCount}/${this.operationThreshold}`);
|
||||
|
||||
if (this.operationCount >= this.operationThreshold) {
|
||||
log.info(`Operation threshold reached (${this.operationThreshold}), triggering garbage collection`);
|
||||
this.operationCount = 0; // Reset counter
|
||||
|
||||
setTimeout(() => {
|
||||
this.performGarbageCollection();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resetuje licznik operacji
|
||||
*/
|
||||
resetOperationCount(): void {
|
||||
this.operationCount = 0;
|
||||
log.debug("Operation count reset");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustawia próg operacji dla automatycznego GC
|
||||
* @param {number} threshold - Nowy próg operacji
|
||||
*/
|
||||
setOperationThreshold(threshold: number): void {
|
||||
this.operationThreshold = Math.max(1, threshold);
|
||||
log.info(`Operation threshold set to: ${this.operationThreshold}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ręczne uruchomienie garbage collection
|
||||
*/
|
||||
async manualGarbageCollection(): Promise<void> {
|
||||
log.info("Manual garbage collection triggered");
|
||||
await this.performGarbageCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca statystyki garbage collection
|
||||
* @returns {GarbageCollectionStats} Statystyki
|
||||
*/
|
||||
getStats(): GarbageCollectionStats {
|
||||
return {
|
||||
trackedImages: this.imageReferences.size,
|
||||
totalReferences: Array.from(this.imageReferences.values()).reduce((sum, count) => sum + count, 0),
|
||||
isRunning: this.isGcRunning,
|
||||
gcInterval: this.gcInterval,
|
||||
maxAge: this.maxAge
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści wszystkie dane (przy usuwaniu canvas)
|
||||
*/
|
||||
destroy(): void {
|
||||
this.stopGarbageCollection();
|
||||
this.imageReferences.clear();
|
||||
this.imageLastUsed.clear();
|
||||
log.info("ImageReferenceManager destroyed");
|
||||
}
|
||||
}
|
||||
519
src/MaskEditorIntegration.ts
Normal file
519
src/MaskEditorIntegration.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
// @ts-ignore
|
||||
import {app} from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import {ComfyApp} from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import {api} from "../../scripts/api.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { 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('MaskEditorIntegration');
|
||||
|
||||
export class MaskEditorIntegration {
|
||||
canvas: any;
|
||||
editorWasShowing: any;
|
||||
maskEditorCancelled: any;
|
||||
maskTool: any;
|
||||
node: any;
|
||||
pendingMask: any;
|
||||
savedMaskState: any;
|
||||
constructor(canvas: any) {
|
||||
this.canvas = canvas;
|
||||
this.node = canvas.node;
|
||||
this.maskTool = canvas.maskTool;
|
||||
|
||||
this.savedMaskState = null;
|
||||
this.maskEditorCancelled = false;
|
||||
this.pendingMask = null;
|
||||
this.editorWasShowing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uruchamia edytor masek
|
||||
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora
|
||||
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
|
||||
*/
|
||||
async startMaskEditor(predefinedMask: any = null, sendCleanImage = true) {
|
||||
log.info('Starting mask editor', {
|
||||
hasPredefinedMask: !!predefinedMask,
|
||||
sendCleanImage,
|
||||
layersCount: this.canvas.layers.length
|
||||
});
|
||||
|
||||
this.savedMaskState = await this.saveMaskState();
|
||||
this.maskEditorCancelled = false;
|
||||
|
||||
if (!predefinedMask && this.maskTool && this.maskTool.maskCanvas) {
|
||||
try {
|
||||
log.debug('Creating mask from current mask tool');
|
||||
predefinedMask = await this.createMaskFromCurrentMask();
|
||||
log.debug('Mask created from current mask tool successfully');
|
||||
} catch (error) {
|
||||
log.warn("Could not create mask from current mask:", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingMask = predefinedMask;
|
||||
|
||||
let blob;
|
||||
if (sendCleanImage) {
|
||||
log.debug('Getting flattened canvas as blob (clean image)');
|
||||
blob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
} else {
|
||||
log.debug('Getting flattened canvas for mask editor (with mask)');
|
||||
blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
}
|
||||
|
||||
if (!blob) {
|
||||
log.warn("Canvas is empty, cannot open mask editor.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('Canvas blob created successfully, size:', blob.size);
|
||||
|
||||
try {
|
||||
// Use ImageUploadUtils to upload the blob
|
||||
const uploadResult = await uploadImageBlob(blob, {
|
||||
filenamePrefix: 'layerforge-mask-edit'
|
||||
});
|
||||
|
||||
this.node.imgs = [uploadResult.imageElement];
|
||||
|
||||
log.info('Opening ComfyUI mask editor');
|
||||
ComfyApp.copyToClipspace(this.node);
|
||||
ComfyApp.clipspace_return_node = this.node;
|
||||
ComfyApp.open_maskeditor();
|
||||
|
||||
this.editorWasShowing = false;
|
||||
this.waitWhileMaskEditing();
|
||||
|
||||
this.setupCancelListener();
|
||||
|
||||
if (predefinedMask) {
|
||||
log.debug('Will apply predefined mask when editor is ready');
|
||||
this.waitForMaskEditorAndApplyMask();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log.error("Error preparing image for mask editor:", error);
|
||||
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ę
|
||||
*/
|
||||
waitForMaskEditorAndApplyMask() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 100; // Zwiększone do 10 sekund oczekiwania
|
||||
|
||||
const checkEditor = () => {
|
||||
attempts++;
|
||||
|
||||
if (mask_editor_showing(app)) {
|
||||
|
||||
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
|
||||
let editorReady = false;
|
||||
|
||||
if (useNewEditor) {
|
||||
|
||||
const MaskEditorDialog = window.MaskEditorDialog;
|
||||
if (MaskEditorDialog && MaskEditorDialog.instance) {
|
||||
|
||||
try {
|
||||
const messageBroker = MaskEditorDialog.instance.getMessageBroker();
|
||||
if (messageBroker) {
|
||||
editorReady = true;
|
||||
log.info("New mask editor detected as ready via MessageBroker");
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
editorReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!editorReady) {
|
||||
const maskEditorElement = document.getElementById('maskEditor');
|
||||
if (maskEditorElement && maskEditorElement.style.display !== 'none') {
|
||||
|
||||
const canvas = maskEditorElement.querySelector('canvas');
|
||||
if (canvas) {
|
||||
editorReady = true;
|
||||
log.info("New mask editor detected as ready via DOM element");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
const maskCanvas = document.getElementById('maskCanvas') as HTMLCanvasElement;
|
||||
if (maskCanvas) {
|
||||
editorReady = !!(maskCanvas.getContext('2d') && maskCanvas.width > 0 && maskCanvas.height > 0);
|
||||
if (editorReady) {
|
||||
log.info("Old mask editor detected as ready");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editorReady) {
|
||||
// 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;
|
||||
}, waitTime);
|
||||
} else if (attempts < maxAttempts) {
|
||||
|
||||
if (attempts % 10 === 0) {
|
||||
log.info("Waiting for mask editor to be ready... attempt", attempts, "/", maxAttempts);
|
||||
}
|
||||
setTimeout(checkEditor, 100);
|
||||
} else {
|
||||
log.warn("Mask editor timeout - editor not ready after", maxAttempts * 100, "ms");
|
||||
|
||||
log.info("Attempting to apply mask anyway...");
|
||||
setTimeout(() => {
|
||||
this.applyMaskToEditor(this.pendingMask);
|
||||
this.pendingMask = null;
|
||||
}, 100);
|
||||
}
|
||||
} else if (attempts < maxAttempts) {
|
||||
|
||||
setTimeout(checkEditor, 100);
|
||||
} else {
|
||||
log.warn("Mask editor timeout - editor not showing after", maxAttempts * 100, "ms");
|
||||
this.pendingMask = null;
|
||||
}
|
||||
};
|
||||
|
||||
checkEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nakłada maskę na otwarty mask editor
|
||||
* @param {Image|HTMLCanvasElement} maskData - Dane maski do nałożenia
|
||||
*/
|
||||
async applyMaskToEditor(maskData: any) {
|
||||
try {
|
||||
|
||||
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
|
||||
|
||||
if (useNewEditor) {
|
||||
|
||||
const MaskEditorDialog = window.MaskEditorDialog;
|
||||
if (MaskEditorDialog && MaskEditorDialog.instance) {
|
||||
|
||||
await this.applyMaskToNewEditor(maskData);
|
||||
} else {
|
||||
log.warn("New editor setting enabled but instance not found, trying old editor");
|
||||
await this.applyMaskToOldEditor(maskData);
|
||||
}
|
||||
} else {
|
||||
|
||||
await this.applyMaskToOldEditor(maskData);
|
||||
}
|
||||
|
||||
log.info("Predefined mask applied to mask editor successfully");
|
||||
} catch (error) {
|
||||
log.error("Failed to apply predefined mask to editor:", error);
|
||||
|
||||
try {
|
||||
log.info("Trying alternative mask application method...");
|
||||
await this.applyMaskToOldEditor(maskData);
|
||||
log.info("Alternative method succeeded");
|
||||
} catch (fallbackError) {
|
||||
log.error("Alternative method also failed:", fallbackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nakłada maskę na nowy mask editor (przez MessageBroker)
|
||||
* @param {Image|HTMLCanvasElement} maskData - Dane maski
|
||||
*/
|
||||
async applyMaskToNewEditor(maskData: any) {
|
||||
|
||||
const MaskEditorDialog = window.MaskEditorDialog;
|
||||
if (!MaskEditorDialog || !MaskEditorDialog.instance) {
|
||||
throw new Error("New mask editor instance not found");
|
||||
}
|
||||
|
||||
const editor = MaskEditorDialog.instance;
|
||||
const messageBroker = editor.getMessageBroker();
|
||||
|
||||
const maskCanvas = await messageBroker.pull('maskCanvas');
|
||||
const maskCtx = await messageBroker.pull('maskCtx');
|
||||
const maskColor = await messageBroker.pull('getMaskColor');
|
||||
|
||||
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
|
||||
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(processedMask, 0, 0);
|
||||
|
||||
messageBroker.publish('saveState');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nakłada maskę na stary mask editor
|
||||
* @param {Image|HTMLCanvasElement} maskData - Dane maski
|
||||
*/
|
||||
async applyMaskToOldEditor(maskData: any) {
|
||||
|
||||
const maskCanvas = document.getElementById('maskCanvas') as HTMLCanvasElement;
|
||||
if (!maskCanvas) {
|
||||
throw new Error("Old mask editor canvas not found");
|
||||
}
|
||||
|
||||
const maskCtx = maskCanvas.getContext('2d', {willReadFrequently: true});
|
||||
if (!maskCtx) {
|
||||
throw new Error("Old mask editor context not found");
|
||||
}
|
||||
|
||||
const maskColor = {r: 255, g: 255, b: 255};
|
||||
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
|
||||
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(processedMask, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Przetwarza maskę do odpowiedniego formatu dla editora
|
||||
* @param {Image|HTMLCanvasElement} maskData - Oryginalne dane maski
|
||||
* @param {number} targetWidth - Docelowa szerokość
|
||||
* @param {number} targetHeight - Docelowa wysokość
|
||||
* @param {Object} maskColor - Kolor maski {r, g, b}
|
||||
* @returns {HTMLCanvasElement} Przetworzona maska
|
||||
*/
|
||||
async processMaskForEditor(maskData: any, targetWidth: any, targetHeight: any, maskColor: any) {
|
||||
// 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
|
||||
* @returns {Promise<Image>} Promise zwracający obiekt Image z maską
|
||||
*/
|
||||
async createMaskFromCurrentMask() {
|
||||
if (!this.maskTool || !this.maskTool.maskCanvas) {
|
||||
throw new Error("No mask canvas available");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const maskImage = new Image();
|
||||
maskImage.onload = () => resolve(maskImage);
|
||||
maskImage.onerror = reject;
|
||||
maskImage.src = this.maskTool.maskCanvas.toDataURL();
|
||||
});
|
||||
}
|
||||
|
||||
waitWhileMaskEditing() {
|
||||
if (mask_editor_showing(app)) {
|
||||
this.editorWasShowing = true;
|
||||
}
|
||||
|
||||
if (!mask_editor_showing(app) && this.editorWasShowing) {
|
||||
this.editorWasShowing = false;
|
||||
setTimeout(() => this.handleMaskEditorClose(), 100);
|
||||
} else {
|
||||
setTimeout(this.waitWhileMaskEditing.bind(this), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisuje obecny stan maski przed otwarciem editora
|
||||
* @returns {Object} Zapisany stan maski
|
||||
*/
|
||||
async saveMaskState() {
|
||||
if (!this.maskTool || !this.maskTool.maskCanvas) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maskCanvas = this.maskTool.maskCanvas;
|
||||
const { canvas: savedCanvas, ctx: savedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', {willReadFrequently: true});
|
||||
if (savedCtx) {
|
||||
savedCtx.drawImage(maskCanvas, 0, 0);
|
||||
}
|
||||
|
||||
return {
|
||||
maskData: savedCanvas,
|
||||
maskPosition: {
|
||||
x: this.maskTool.x,
|
||||
y: this.maskTool.y
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Przywraca zapisany stan maski
|
||||
* @param {Object} savedState - Zapisany stan maski
|
||||
*/
|
||||
async restoreMaskState(savedState: any) {
|
||||
if (!savedState || !this.maskTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedState.maskData) {
|
||||
const maskCtx = this.maskTool.maskCtx;
|
||||
maskCtx.clearRect(0, 0, this.maskTool.maskCanvas.width, this.maskTool.maskCanvas.height);
|
||||
maskCtx.drawImage(savedState.maskData, 0, 0);
|
||||
}
|
||||
|
||||
if (savedState.maskPosition) {
|
||||
this.maskTool.x = savedState.maskPosition.x;
|
||||
this.maskTool.y = savedState.maskPosition.y;
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
log.info("Mask state restored after cancel");
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfiguruje nasłuchiwanie na przycisk Cancel w mask editorze
|
||||
*/
|
||||
setupCancelListener() {
|
||||
mask_editor_listen_for_cancel(app, () => {
|
||||
log.info("Mask editor cancel button clicked");
|
||||
this.maskEditorCancelled = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdza czy mask editor został anulowany i obsługuje to odpowiednio
|
||||
*/
|
||||
async handleMaskEditorClose() {
|
||||
log.info("Handling mask editor close");
|
||||
log.debug("Node object after mask editor close:", this.node);
|
||||
|
||||
if (this.maskEditorCancelled) {
|
||||
log.info("Mask editor was cancelled - restoring original mask state");
|
||||
|
||||
if (this.savedMaskState) {
|
||||
await this.restoreMaskState(this.savedMaskState);
|
||||
}
|
||||
|
||||
this.maskEditorCancelled = false;
|
||||
this.savedMaskState = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.node.imgs || this.node.imgs.length === 0 || !this.node.imgs[0].src) {
|
||||
log.warn("Mask editor was closed without a result.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("Processing mask editor result, image source:", this.node.imgs[0].src.substring(0, 100) + '...');
|
||||
|
||||
const resultImage = new Image();
|
||||
resultImage.src = this.node.imgs[0].src;
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
resultImage.onload = resolve;
|
||||
resultImage.onerror = reject;
|
||||
});
|
||||
|
||||
log.debug("Result image loaded successfully", {
|
||||
width: resultImage.width,
|
||||
height: resultImage.height
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Failed to load image from mask editor.", error);
|
||||
this.node.imgs = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
2127
src/MaskTool.ts
Normal file
2127
src/MaskTool.ts
Normal file
File diff suppressed because it is too large
Load Diff
490
src/SAMDetectorIntegration.ts
Normal file
490
src/SAMDetectorIntegration.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
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 { validateAndFixClipspace } from "./utils/ClipspaceUtils.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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Store original onClipspaceEditorSave function to restore later
|
||||
let originalOnClipspaceEditorSave: (() => void) | null = 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;
|
||||
|
||||
// Ensure proper clipspace structure for updated ComfyUI
|
||||
if (!ComfyApp.clipspace) {
|
||||
ComfyApp.clipspace = {};
|
||||
}
|
||||
|
||||
// Set up clipspace with proper indices
|
||||
ComfyApp.clipspace.imgs = [uploadResult.imageElement];
|
||||
ComfyApp.clipspace.selectedIndex = 0;
|
||||
ComfyApp.clipspace.combinedIndex = 0;
|
||||
ComfyApp.clipspace.img_paste_mode = 'selected';
|
||||
|
||||
// Copy to ComfyUI clipspace
|
||||
ComfyApp.copyToClipspace(node);
|
||||
|
||||
// Override onClipspaceEditorSave to fix clipspace structure before pasteFromClipspace
|
||||
if (!originalOnClipspaceEditorSave) {
|
||||
originalOnClipspaceEditorSave = ComfyApp.onClipspaceEditorSave;
|
||||
ComfyApp.onClipspaceEditorSave = function() {
|
||||
log.debug("SAM Detector onClipspaceEditorSave called, using unified clipspace validation");
|
||||
|
||||
// Use the unified clipspace validation function
|
||||
const isValid = validateAndFixClipspace();
|
||||
if (!isValid) {
|
||||
log.error("Clipspace validation failed, cannot proceed with paste");
|
||||
return;
|
||||
}
|
||||
|
||||
// Call the original function
|
||||
if (originalOnClipspaceEditorSave) {
|
||||
originalOnClipspaceEditorSave.call(ComfyApp);
|
||||
}
|
||||
|
||||
// Restore the original function after use
|
||||
if (originalOnClipspaceEditorSave) {
|
||||
ComfyApp.onClipspaceEditorSave = originalOnClipspaceEditorSave;
|
||||
originalOnClipspaceEditorSave = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
5
src/config.ts
Normal file
5
src/config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LogLevel } from "./logger";
|
||||
|
||||
// Log level for development.
|
||||
// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE'
|
||||
export const LOG_LEVEL: keyof typeof LogLevel = 'NONE';
|
||||
170
src/css/blend_mode_menu.css
Normal file
170
src/css/blend_mode_menu.css
Normal file
@@ -0,0 +1,170 @@
|
||||
/* Blend Mode Menu Styles */
|
||||
#blend-mode-menu {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-title-bar {
|
||||
background: #3a3a3a;
|
||||
color: white;
|
||||
padding: 8px 10px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
border-radius: 3px 3px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-title-text {
|
||||
flex: 1;
|
||||
cursor: move;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-close-button {
|
||||
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;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-close-button:hover {
|
||||
background-color: #4a4a4a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-close-button:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-menu-content {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-container {
|
||||
padding: 5px 10px;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-label {
|
||||
color: white;
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider::-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;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-area-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-container {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-option {
|
||||
padding: 5px 10px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-option:hover {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-option.active {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
display: none;
|
||||
-webkit-appearance: none;
|
||||
height: 4px;
|
||||
background: #555;
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-mode-container.active .blend-opacity-slider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider::-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;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
#blend-mode-menu .blend-opacity-slider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid #555;
|
||||
}
|
||||
669
src/css/canvas_view.css
Normal file
669
src/css/canvas_view.css
Normal file
@@ -0,0 +1,669 @@
|
||||
.painter-button {
|
||||
background-color: #444;
|
||||
border: 1px solid #555;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
margin: 2px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
text-shadow: 0 1px 2px rgb(0,0,0);
|
||||
}
|
||||
|
||||
.painter-button:hover {
|
||||
background-color: #555;
|
||||
border-color: #666;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.painter-button:active {
|
||||
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-color: #3a3a3a;
|
||||
color: #777;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
border-color: #4a4a4a;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.painter-button.primary {
|
||||
background-color: #3a76d6;
|
||||
border-color: #2a6ac4;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgb(0,0,0);
|
||||
}
|
||||
|
||||
.painter-button.primary:hover {
|
||||
background-color: #4a86e4;
|
||||
border-color: #3a76d6;
|
||||
}
|
||||
|
||||
/* Crop mode button styling */
|
||||
.painter-button#crop-mode-btn {
|
||||
background-color: #444;
|
||||
border-color: #555;
|
||||
color: #fff;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.painter-button#crop-mode-btn.primary {
|
||||
background-color: #0080ff;
|
||||
border-color: #0070e0;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 8px rgba(0, 128, 255, 0.3);
|
||||
}
|
||||
|
||||
.painter-button#crop-mode-btn.primary:hover {
|
||||
background-color: #1090ff;
|
||||
border-color: #0080ff;
|
||||
box-shadow: 0 0 12px rgba(0, 128, 255, 0.4);
|
||||
}
|
||||
|
||||
.painter-button#crop-mode-btn:hover {
|
||||
background-color: #555;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.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-color: #2f2f2f;
|
||||
border-bottom: 1px solid #202020;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.painter-slider-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
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: 4px;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.painter-clipboard-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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, opacity 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;
|
||||
}
|
||||
|
||||
/* Disabled state for switch */
|
||||
.clipboard-switch.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
background: #3a3a3a !important; /* Override gradient */
|
||||
border-color: #4a4a4a !important;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.clipboard-switch.disabled .switch-knob {
|
||||
background-color: #4a4a4a !important;
|
||||
}
|
||||
|
||||
.clipboard-switch.disabled .switch-labels {
|
||||
color: #777 !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.painter-separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: #444;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.painter-container {
|
||||
background: #607080; /* 带蓝色的灰色背景 */
|
||||
border: 1px solid #4a5a6a;
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
|
||||
transition: border-color 0.3s ease; /* Dodano dla płynnej zmiany ramki */
|
||||
}
|
||||
|
||||
.painter-container.drag-over {
|
||||
border-color: #00ff00; /* Zielona ramka podczas przeciągania */
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.painter-dialog {
|
||||
background: #404040;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
padding: 20px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.painter-dialog input {
|
||||
background: #303030;
|
||||
border: 1px solid #505050;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
margin: 4px;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.painter-dialog button {
|
||||
background: #505050;
|
||||
border: 1px solid #606060;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 4px 12px;
|
||||
margin: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.painter-dialog button:hover {
|
||||
background: #606060;
|
||||
}
|
||||
|
||||
.blend-opacity-slider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blend-mode-active .blend-opacity-slider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blend-mode-item {
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.blend-mode-item.active {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.blend-mode-item.active {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.painter-tooltip {
|
||||
position: fixed;
|
||||
display: none;
|
||||
background: #2B2B2B;
|
||||
color: #f0f0f0;
|
||||
border: 1px solid #444;
|
||||
border-top: 2px solid #4a90e2;
|
||||
border-radius: 6px;
|
||||
padding: 12px 18px;
|
||||
z-index: 9999;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
width: auto;
|
||||
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;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.painter-tooltip.scale-down {
|
||||
transform: scale(0.9);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.painter-tooltip.scale-down-more {
|
||||
transform: scale(0.8);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.painter-tooltip table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.painter-tooltip table td {
|
||||
padding: 4px 8px;
|
||||
vertical-align: middle;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.painter-tooltip table td:first-child {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.painter-tooltip table td:last-child {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.painter-tooltip table tr:nth-child(odd) td {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.painter-tooltip table tr:hover td {
|
||||
background-color: rgba(74, 144, 226, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.painter-tooltip {
|
||||
font-size: 11px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.painter-tooltip table td {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.painter-tooltip kbd {
|
||||
padding: 1px 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.painter-tooltip table td:first-child {
|
||||
width: 40%;
|
||||
}
|
||||
.painter-tooltip table td:last-child {
|
||||
width: 60%;
|
||||
}
|
||||
.painter-tooltip h4 {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.painter-tooltip {
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.painter-tooltip table td {
|
||||
padding: 1px 3px;
|
||||
}
|
||||
.painter-tooltip kbd {
|
||||
padding: 0px 3px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.painter-tooltip table td:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
.painter-tooltip table td:last-child {
|
||||
width: 65%;
|
||||
}
|
||||
.painter-tooltip h4 {
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.painter-tooltip h4 {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 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 {
|
||||
list-style: none;
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.painter-tooltip kbd {
|
||||
background-color: #444;
|
||||
border: 1px solid #555;
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: monospace;
|
||||
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 {
|
||||
/* Używamy box-shadow, aby stworzyć efekt zewnętrznej ramki,
|
||||
która nie wpłynie na rozmiar ani pozycję elementu. */
|
||||
box-shadow: 0 0 0 2px white;
|
||||
/* Możesz też zmienić kolor istniejącej ramki, ale box-shadow jest bardziej wyrazisty */
|
||||
/* border-color: white; */
|
||||
}
|
||||
|
||||
.painter-button.matting-button {
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.painter-button.matting-button.loading {
|
||||
padding-right: 36px; /* Make space for spinner */
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.painter-button.matting-button .matting-spinner {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: matting-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.painter-button.matting-button.loading .matting-spinner {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes matting-spin {
|
||||
to {
|
||||
transform: translateY(-50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
.painter-modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 111;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.painter-modal-content {
|
||||
width: 90vw;
|
||||
height: 90vh;
|
||||
background-color: #353535;
|
||||
border: 1px solid #222;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 5px 25px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.painterMainContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.painterCanvasContainer {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
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;
|
||||
}
|
||||
230
src/css/layers_panel.css
Normal file
230
src/css/layers_panel.css
Normal file
@@ -0,0 +1,230 @@
|
||||
/* Layers Panel Styles */
|
||||
.layers-panel {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layers-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.layers-panel-title {
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layers-panel-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layers-btn {
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.layers-btn:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
.layers-btn:active {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layers-btn:disabled {
|
||||
background: #2a2a2a;
|
||||
color: #666666;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.layers-btn:disabled:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layers-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 4px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.layer-row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.layer-row.selected {
|
||||
background: #2d5aa0 !important;
|
||||
box-shadow: inset 0 0 0 1px #4a7bc8;
|
||||
}
|
||||
|
||||
.layer-row.dragging {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.layer-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layer-thumbnail::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #555 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #555 75%);
|
||||
background-size: 8px 8px;
|
||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name.editing {
|
||||
background: #4a4a4a;
|
||||
border: 1px solid #6a6a6a;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.drag-insertion-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #4a7bc8;
|
||||
border-radius: 1px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Icon container styles */
|
||||
.layers-panel .icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.layers-panel .icon-container img {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
.layers-panel .icon-container.visibility-hidden {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.layers-panel .icon-container.visibility-hidden img {
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.layers-panel .icon-container.fallback-text {
|
||||
font-size: 10px;
|
||||
color: #888888;
|
||||
}
|
||||
192
src/db.ts
Normal file
192
src/db.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
const log = createModuleLogger('db');
|
||||
|
||||
const DB_NAME = 'CanvasNodeDB';
|
||||
const STATE_STORE_NAME = 'CanvasState';
|
||||
const IMAGE_STORE_NAME = 'CanvasImages';
|
||||
const DB_VERSION = 3;
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
|
||||
type DBRequestOperation = 'get' | 'put' | 'delete' | 'clear';
|
||||
|
||||
interface CanvasStateDB {
|
||||
id: string;
|
||||
state: any;
|
||||
}
|
||||
|
||||
interface CanvasImageDB {
|
||||
imageId: string;
|
||||
imageSrc: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów
|
||||
* @param {IDBObjectStore} store - Store IndexedDB
|
||||
* @param {DBRequestOperation} operation - Nazwa operacji (get, put, delete, clear)
|
||||
* @param {any} data - Dane dla operacji (opcjonalne)
|
||||
* @param {string} errorMessage - Wiadomość błędu
|
||||
* @returns {Promise<any>} Promise z wynikiem operacji
|
||||
*/
|
||||
function createDBRequest(store: IDBObjectStore, operation: DBRequestOperation, data: any, errorMessage: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let request: IDBRequest;
|
||||
switch (operation) {
|
||||
case 'get':
|
||||
request = store.get(data);
|
||||
break;
|
||||
case 'put':
|
||||
request = store.put(data);
|
||||
break;
|
||||
case 'delete':
|
||||
request = store.delete(data);
|
||||
break;
|
||||
case 'clear':
|
||||
request = store.clear();
|
||||
break;
|
||||
default:
|
||||
reject(new Error(`Unknown operation: ${operation}`));
|
||||
return;
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
log.error(errorMessage, (event.target as IDBRequest).error);
|
||||
reject(errorMessage);
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
resolve((event.target as IDBRequest).result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (db) {
|
||||
resolve(db);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Opening IndexedDB...");
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = (event) => {
|
||||
log.error("IndexedDB error:", (event.target as IDBOpenDBRequest).error);
|
||||
reject("Error opening IndexedDB.");
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
db = (event.target as IDBOpenDBRequest).result;
|
||||
log.info("IndexedDB opened successfully.");
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
log.info("Upgrading IndexedDB...");
|
||||
const dbInstance = (event.target as IDBOpenDBRequest).result;
|
||||
if (!dbInstance.objectStoreNames.contains(STATE_STORE_NAME)) {
|
||||
dbInstance.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'});
|
||||
log.info("Object store created:", STATE_STORE_NAME);
|
||||
}
|
||||
if (!dbInstance.objectStoreNames.contains(IMAGE_STORE_NAME)) {
|
||||
dbInstance.createObjectStore(IMAGE_STORE_NAME, {keyPath: 'imageId'});
|
||||
log.info("Object store created:", IMAGE_STORE_NAME);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCanvasState(id: string): Promise<any | null> {
|
||||
log.info(`Getting state for id: ${id}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
const result = await createDBRequest(store, 'get', id, "Error getting canvas state") as CanvasStateDB;
|
||||
log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found');
|
||||
return result ? result.state : null;
|
||||
}
|
||||
|
||||
export async function setCanvasState(id: string, state: any): Promise<void> {
|
||||
log.info(`Setting state for id: ${id}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'put', {id, state}, "Error setting canvas state");
|
||||
log.debug(`Set success for id: ${id}`);
|
||||
}
|
||||
|
||||
export async function removeCanvasState(id: string): Promise<void> {
|
||||
log.info(`Removing state for id: ${id}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'delete', id, "Error removing canvas state");
|
||||
log.debug(`Remove success for id: ${id}`);
|
||||
}
|
||||
|
||||
export async function saveImage(imageId: string, imageSrc: string | ImageBitmap): Promise<void> {
|
||||
log.info(`Saving image with id: ${imageId}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'put', {imageId, imageSrc}, "Error saving image");
|
||||
log.debug(`Image saved successfully for id: ${imageId}`);
|
||||
}
|
||||
|
||||
export async function getImage(imageId: string): Promise<string | ImageBitmap | null> {
|
||||
log.info(`Getting image with id: ${imageId}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
const result = await createDBRequest(store, 'get', imageId, "Error getting image") as CanvasImageDB;
|
||||
log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found');
|
||||
return result ? result.imageSrc : null;
|
||||
}
|
||||
|
||||
export async function removeImage(imageId: string): Promise<void> {
|
||||
log.info(`Removing image with id: ${imageId}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'delete', imageId, "Error removing image");
|
||||
log.debug(`Remove image success for id: ${imageId}`);
|
||||
}
|
||||
|
||||
export async function getAllImageIds(): Promise<string[]> {
|
||||
log.info("Getting all image IDs...");
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAllKeys();
|
||||
|
||||
request.onerror = (event) => {
|
||||
log.error("Error getting all image IDs:", (event.target as IDBRequest).error);
|
||||
reject("Error getting all image IDs");
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const imageIds = (event.target as IDBRequest).result;
|
||||
log.debug(`Found ${imageIds.length} image IDs in database`);
|
||||
resolve(imageIds);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearAllCanvasStates(): Promise<void> {
|
||||
log.info("Clearing all canvas states...");
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'clear', null, "Error clearing canvas states");
|
||||
log.info("All canvas states cleared successfully.");
|
||||
}
|
||||
374
src/logger.ts
Normal file
374
src/logger.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Logger - Centralny system logowania dla ComfyUI-LayerForge
|
||||
*
|
||||
* Funkcje:
|
||||
* - Różne poziomy logowania (DEBUG, INFO, WARN, ERROR)
|
||||
* - Możliwość włączania/wyłączania logów globalnie lub per moduł
|
||||
* - Kolorowe logi w konsoli
|
||||
* - Możliwość zapisywania logów do localStorage
|
||||
* - Możliwość eksportu logów
|
||||
*/
|
||||
|
||||
function padStart(str: string, targetLength: number, padString: string): string {
|
||||
targetLength = targetLength >> 0;
|
||||
padString = String(padString || ' ');
|
||||
if (str.length > targetLength) {
|
||||
return String(str);
|
||||
} else {
|
||||
targetLength = targetLength - str.length;
|
||||
if (targetLength > padString.length) {
|
||||
padString += padString.repeat(targetLength / padString.length);
|
||||
}
|
||||
return padString.slice(0, targetLength) + String(str);
|
||||
}
|
||||
}
|
||||
|
||||
export const LogLevel = {
|
||||
DEBUG: 0,
|
||||
INFO: 1,
|
||||
WARN: 2,
|
||||
ERROR: 3,
|
||||
NONE: 4
|
||||
} as const;
|
||||
|
||||
export type LogLevels = typeof LogLevel[keyof typeof LogLevel];
|
||||
|
||||
interface LoggerConfig {
|
||||
globalLevel: LogLevels;
|
||||
moduleSettings: { [key: string]: LogLevels };
|
||||
useColors: boolean;
|
||||
saveToStorage: boolean;
|
||||
maxStoredLogs: number;
|
||||
timestampFormat: string;
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
interface LogData {
|
||||
timestamp: string;
|
||||
module: string;
|
||||
level: LogLevels;
|
||||
levelName: string;
|
||||
args: any[];
|
||||
time: Date;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: LoggerConfig = {
|
||||
globalLevel: LogLevel.INFO,
|
||||
moduleSettings: {},
|
||||
useColors: true,
|
||||
saveToStorage: false,
|
||||
maxStoredLogs: 1000,
|
||||
timestampFormat: 'HH:mm:ss',
|
||||
storageKey: 'layerforge_logs'
|
||||
};
|
||||
|
||||
const COLORS: { [key: number]: string } = {
|
||||
[LogLevel.DEBUG]: '#9e9e9e',
|
||||
[LogLevel.INFO]: '#2196f3',
|
||||
[LogLevel.WARN]: '#ff9800',
|
||||
[LogLevel.ERROR]: '#f44336',
|
||||
};
|
||||
|
||||
const LEVEL_NAMES: { [key: number]: string } = {
|
||||
[LogLevel.DEBUG]: 'DEBUG',
|
||||
[LogLevel.INFO]: 'INFO',
|
||||
[LogLevel.WARN]: 'WARN',
|
||||
[LogLevel.ERROR]: 'ERROR',
|
||||
};
|
||||
|
||||
class Logger {
|
||||
private config: LoggerConfig;
|
||||
private enabled: boolean;
|
||||
private logs: LogData[];
|
||||
constructor() {
|
||||
this.config = {...DEFAULT_CONFIG};
|
||||
this.logs = [];
|
||||
this.enabled = true;
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfiguracja loggera
|
||||
* @param {Partial<LoggerConfig>} config - Obiekt konfiguracyjny
|
||||
*/
|
||||
configure(config: Partial<LoggerConfig>): this {
|
||||
this.config = {...this.config, ...config};
|
||||
this.saveConfig();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Włącz/wyłącz logger globalnie
|
||||
* @param {boolean} enabled - Czy logger ma być włączony
|
||||
*/
|
||||
setEnabled(enabled: boolean): this {
|
||||
this.enabled = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustaw globalny poziom logowania
|
||||
* @param {LogLevels} level - Poziom logowania
|
||||
*/
|
||||
setGlobalLevel(level: LogLevels): this {
|
||||
this.config.globalLevel = level;
|
||||
this.saveConfig();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustaw poziom logowania dla konkretnego modułu
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {LogLevels} level - Poziom logowania
|
||||
*/
|
||||
setModuleLevel(module: string, level: LogLevels): this {
|
||||
this.config.moduleSettings[module] = level;
|
||||
this.saveConfig();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdź, czy dany poziom logowania jest aktywny dla modułu
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {LogLevels} level - Poziom logowania do sprawdzenia
|
||||
* @returns {boolean} - Czy poziom jest aktywny
|
||||
*/
|
||||
isLevelEnabled(module: string, level: LogLevels): boolean {
|
||||
if (!this.enabled) return false;
|
||||
if (this.config.moduleSettings[module] !== undefined) {
|
||||
return level >= this.config.moduleSettings[module];
|
||||
}
|
||||
return level >= this.config.globalLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatuj znacznik czasu
|
||||
* @returns {string} - Sformatowany znacznik czasu
|
||||
*/
|
||||
formatTimestamp(): string {
|
||||
const now = new Date();
|
||||
const format = this.config.timestampFormat;
|
||||
return format
|
||||
.replace('HH', padStart(String(now.getHours()), 2, '0'))
|
||||
.replace('mm', padStart(String(now.getMinutes()), 2, '0'))
|
||||
.replace('ss', padStart(String(now.getSeconds()), 2, '0'))
|
||||
.replace('SSS', padStart(String(now.getMilliseconds()), 3, '0'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisz log
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {LogLevels} level - Poziom logowania
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
log(module: string, level: LogLevels, ...args: any[]): void {
|
||||
if (!this.isLevelEnabled(module, level)) return;
|
||||
|
||||
const timestamp = this.formatTimestamp();
|
||||
const levelName = LEVEL_NAMES[level];
|
||||
const logData: LogData = {
|
||||
timestamp,
|
||||
module,
|
||||
level,
|
||||
levelName,
|
||||
args,
|
||||
time: new Date()
|
||||
};
|
||||
if (this.config.saveToStorage) {
|
||||
this.logs.push(logData);
|
||||
if (this.logs.length > this.config.maxStoredLogs) {
|
||||
this.logs.shift();
|
||||
}
|
||||
this.saveLogs();
|
||||
}
|
||||
this.printToConsole(logData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wyświetl log w konsoli
|
||||
* @param {LogData} logData - Dane logu
|
||||
*/
|
||||
printToConsole(logData: LogData): void {
|
||||
const {timestamp, module, level, levelName, args} = logData;
|
||||
const prefix = `[${timestamp}] [${module}] [${levelName}]`;
|
||||
if (this.config.useColors && typeof console.log === 'function') {
|
||||
const color = COLORS[level] || '#000000';
|
||||
console.log(`%c${prefix}`, `color: ${color}; font-weight: bold;`, ...args);
|
||||
return;
|
||||
}
|
||||
console.log(prefix, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisz logi do localStorage
|
||||
*/
|
||||
saveLogs(): void {
|
||||
if (typeof localStorage !== 'undefined' && this.config.saveToStorage) {
|
||||
try {
|
||||
const simplifiedLogs = this.logs.map((log) => ({
|
||||
t: log.timestamp,
|
||||
m: log.module,
|
||||
l: log.level,
|
||||
a: log.args.map((arg: any) => {
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch (e) {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return arg;
|
||||
})
|
||||
}));
|
||||
|
||||
localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs));
|
||||
} catch (e) {
|
||||
console.error('Failed to save logs to localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Załaduj logi z localStorage
|
||||
*/
|
||||
loadLogs(): void {
|
||||
if (typeof localStorage !== 'undefined' && this.config.saveToStorage) {
|
||||
try {
|
||||
const storedLogs = localStorage.getItem(this.config.storageKey);
|
||||
if (storedLogs) {
|
||||
this.logs = JSON.parse(storedLogs);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load logs from localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisz konfigurację do localStorage
|
||||
*/
|
||||
saveConfig(): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config));
|
||||
} catch (e) {
|
||||
console.error('Failed to save logger config to localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Załaduj konfigurację z localStorage
|
||||
*/
|
||||
loadConfig(): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
const storedConfig = localStorage.getItem('layerforge_logger_config');
|
||||
if (storedConfig) {
|
||||
this.config = {...this.config, ...JSON.parse(storedConfig)};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load logger config from localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wyczyść wszystkie logi
|
||||
*/
|
||||
clearLogs(): this {
|
||||
this.logs = [];
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(this.config.storageKey);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eksportuj logi do pliku
|
||||
* @param {'json' | 'txt'} format - Format eksportu
|
||||
*/
|
||||
exportLogs(format: 'json' | 'txt' = 'json'): void {
|
||||
if (this.logs.length === 0) {
|
||||
console.warn('No logs to export');
|
||||
return;
|
||||
}
|
||||
|
||||
let content: string;
|
||||
let mimeType: string;
|
||||
let extension: string;
|
||||
|
||||
if (format === 'json') {
|
||||
content = JSON.stringify(this.logs, null, 2);
|
||||
mimeType = 'application/json';
|
||||
extension = 'json';
|
||||
} else {
|
||||
content = this.logs.map((log) => `[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`).join('\n');
|
||||
mimeType = 'text/plain';
|
||||
extension = 'txt';
|
||||
}
|
||||
const blob = new Blob([content], {type: mimeType});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `layerforge_logs_${new Date().toISOString().replace(/[:.]/g, '-')}.${extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie DEBUG
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
debug(module: string, ...args: any[]): void {
|
||||
this.log(module, LogLevel.DEBUG, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie INFO
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
info(module: string, ...args: any[]): void {
|
||||
this.log(module, LogLevel.INFO, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie WARN
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
warn(module: string, ...args: any[]): void {
|
||||
this.log(module, LogLevel.WARN, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie ERROR
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
error(module: string, ...args: any[]): void {
|
||||
this.log(module, LogLevel.ERROR, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
export const debug = (module: string, ...args: any[]) => logger.debug(module, ...args);
|
||||
export const info = (module: string, ...args: any[]) => logger.info(module, ...args);
|
||||
export const warn = (module: string, ...args: any[]) => logger.warn(module, ...args);
|
||||
export const error = (module: string, ...args: any[]) => logger.error(module, ...args);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
LayerForgeLogger: Logger;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.LayerForgeLogger = logger;
|
||||
}
|
||||
|
||||
export default logger;
|
||||
93
src/state-saver.worker.ts
Normal file
93
src/state-saver.worker.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
console.log('[StateWorker] Worker script loaded and running.');
|
||||
|
||||
const DB_NAME = 'CanvasNodeDB';
|
||||
const STATE_STORE_NAME = 'CanvasState';
|
||||
const DB_VERSION = 3;
|
||||
|
||||
let db: IDBDatabase | null;
|
||||
|
||||
function log(...args: any[]): void {
|
||||
console.log('[StateWorker]', ...args);
|
||||
}
|
||||
|
||||
function error(...args: any[]): void {
|
||||
console.error('[StateWorker]', ...args);
|
||||
}
|
||||
|
||||
function createDBRequest(store: IDBObjectStore, operation: 'put', data: any, errorMessage: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let request: IDBRequest;
|
||||
switch (operation) {
|
||||
case 'put':
|
||||
request = store.put(data);
|
||||
break;
|
||||
default:
|
||||
reject(new Error(`Unknown operation: ${operation}`));
|
||||
return;
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
error(errorMessage, (event.target as IDBRequest).error);
|
||||
reject(errorMessage);
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
resolve((event.target as IDBRequest).result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (db) {
|
||||
resolve(db);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = (event) => {
|
||||
error("IndexedDB error:", (event.target as IDBOpenDBRequest).error);
|
||||
reject("Error opening IndexedDB.");
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
db = (event.target as IDBOpenDBRequest).result;
|
||||
log("IndexedDB opened successfully in worker.");
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
log("Upgrading IndexedDB in worker...");
|
||||
const tempDb = (event.target as IDBOpenDBRequest).result;
|
||||
if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) {
|
||||
tempDb.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'});
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function setCanvasState(id: string, state: any): Promise<void> {
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
await createDBRequest(store, 'put', {id, state}, "Error setting canvas state");
|
||||
}
|
||||
|
||||
self.onmessage = async function(e: MessageEvent<{ state: any, nodeId: string }>): Promise<void> {
|
||||
log('Message received from main thread:', e.data ? 'data received' : 'no data');
|
||||
const { state, nodeId } = e.data;
|
||||
|
||||
if (!state || !nodeId) {
|
||||
error('Invalid data received from main thread');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log(`Saving state for node: ${nodeId}`);
|
||||
await setCanvasState(nodeId, state);
|
||||
log(`State saved successfully for node: ${nodeId}`);
|
||||
} catch (err) {
|
||||
error(`Failed to save state for node: ${nodeId}`, err);
|
||||
}
|
||||
};
|
||||
13
src/templates/clipspace_clipboard_tooltip.html
Normal file
13
src/templates/clipspace_clipboard_tooltip.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<h4>📋 ComfyUI Clipspace Mode</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>ComfyUI Clipspace</strong> as flattened image</td></tr>
|
||||
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
|
||||
<tr><td></td><td>1️⃣ Internal clipboard (copied layers)</td></tr>
|
||||
<tr><td></td><td>2️⃣ ComfyUI Clipspace (workflow images)</td></tr>
|
||||
<tr><td></td><td>3️⃣ System clipboard (fallback)</td></tr>
|
||||
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
|
||||
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
|
||||
</table>
|
||||
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
|
||||
💡 <strong>Bestt for:</strong> ComfyUI workflow integration and node-to-node image transfer
|
||||
</div>
|
||||
9
src/templates/mask_shortcuts.html
Normal file
9
src/templates/mask_shortcuts.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<h4>Mask Mode</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Click + Drag</kbd></td><td>Paint on the mask</td></tr>
|
||||
<tr><td><kbd>Middle Mouse Button + Drag</kbd></td><td>Pan canvas view</td></tr>
|
||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
|
||||
<tr><td><strong>Brush Controls</strong></td><td>Use sliders to control brush <strong>Size</strong>, <strong>Strength</strong>, and <strong>Hardness</strong></td></tr>
|
||||
<tr><td><strong>Clear Mask</strong></td><td>Remove the entire mask</td></tr>
|
||||
<tr><td><strong>Exit Mode</strong></td><td>Click the "Draw Mask" button again</td></tr>
|
||||
</table>
|
||||
42
src/templates/standard_shortcuts.html
Normal file
42
src/templates/standard_shortcuts.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<h4>Canvas Control</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Click + Drag</kbd></td><td>Pan canvas view</td></tr>
|
||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
|
||||
<tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr>
|
||||
<tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr>
|
||||
<tr><td><kbd>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>
|
||||
<table>
|
||||
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layer(s)</td></tr>
|
||||
<tr><td><kbd>Ctrl + V</kbd></td><td>Paste from clipboard (image or internal layers)</td></tr>
|
||||
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Layer Interaction</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
|
||||
<tr><td><kbd>Ctrl + Click</kbd></td><td>Add/Remove layer from selection</td></tr>
|
||||
<tr><td><kbd>Alt + Drag</kbd></td><td>Clone selected layer(s)</td></tr>
|
||||
<tr><td><kbd>Right Click</kbd></td><td>Show blend mode & opacity menu</td></tr>
|
||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Scale layer (snaps to grid)</td></tr>
|
||||
<tr><td><kbd>Ctrl + Mouse Wheel</kbd></td><td>Fine-scale layer</td></tr>
|
||||
<tr><td><kbd>Shift + Mouse Wheel</kbd></td><td>Rotate layer by 5° steps</td></tr>
|
||||
<tr><td><kbd>Shift + Ctrl + Mouse Wheel</kbd></td><td>Snap rotation to 5° increments</td></tr>
|
||||
<tr><td><kbd>Arrow Keys</kbd></td><td>Nudge layer by 1px</td></tr>
|
||||
<tr><td><kbd>Shift + Arrow Keys</kbd></td><td>Nudge layer by 10px</td></tr>
|
||||
<tr><td><kbd>[</kbd> or <kbd>]</kbd></td><td>Rotate by 1°</td></tr>
|
||||
<tr><td><kbd>Shift + [</kbd> or <kbd>]</kbd></td><td>Rotate by 10°</td></tr>
|
||||
<tr><td><kbd>Delete</kbd></td><td>Delete selected layer(s)</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Transform Handles (on selected layer)</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Drag Corner/Side</kbd></td><td>Resize layer</td></tr>
|
||||
<tr><td><kbd>Drag Rotation Handle</kbd></td><td>Rotate layer</td></tr>
|
||||
<tr><td><kbd>Hold Shift</kbd></td><td>Keep aspect ratio / Snap rotation to 15°</td></tr>
|
||||
<tr><td><kbd>Hold Ctrl</kbd></td><td>Snap to grid</td></tr>
|
||||
</table>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user