mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 13:12:10 -03:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
98
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
98
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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 useful info:
|
||||||
|
|
||||||
|
### How to gather the necessary information:
|
||||||
|
🌐 **Browser & Version:**
|
||||||
|
- Chrome: Click the three dots → Help → About Google Chrome
|
||||||
|
- Firefox: Click the three bars → Help → About Firefox
|
||||||
|
- Edge: Click the three dots → Help and feedback → About Microsoft Edge
|
||||||
|
|
||||||
|
🔗 **Where to find the latest versions of ComfyUI and LayerForge:**
|
||||||
|
- [ComfyUI Github](https://github.com/comfyanonymous/ComfyUI/releases)
|
||||||
|
- [LayerForge Github](https://github.com/Azornes/Comfyui-LayerForge/releases/tag/v1.2.4) or [LayerForge from manager Comfyui](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
|
||||||
|
|
||||||
|
Make sure you have the latest versions before reporting an issue.
|
||||||
|
- 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: console_logs
|
||||||
|
attributes:
|
||||||
|
label: Browser Console Logs
|
||||||
|
description: |
|
||||||
|
**How to capture logs:**
|
||||||
|
- **Open 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`
|
||||||
|
- **Clear console** before reproducing:
|
||||||
|
- Chrome/Edge: click “🚫 Clear console” or press `Ctrl+L` (Win/Linux) / `Cmd+K` (Mac)
|
||||||
|
- Firefox: `Ctrl+Shift+L` (newer) or `Ctrl+L` (older) (Win/Linux), Mac: `Cmd+K`
|
||||||
|
- Safari: click 🗑 icon or press `Cmd+K` / `Ctrl+L`
|
||||||
|
- Reproduce the issue and paste new logs here.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
**Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually.
|
||||||
|
To add media, simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files.
|
||||||
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
|
||||||
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 }}
|
||||||
38
.github/workflows/release.yml
vendored
38
.github/workflows/release.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Auto Release with Version Patch
|
name: Auto Release with Version Check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -19,22 +19,26 @@ jobs:
|
|||||||
base=$(grep '^version *= *"' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
|
base=$(grep '^version *= *"' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
|
||||||
echo "base_version=$base" >> $GITHUB_OUTPUT
|
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
|
id: unique_tag
|
||||||
run: |
|
run: |
|
||||||
BASE="v${{ steps.version.outputs.base_version }}"
|
echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT
|
||||||
TAG=$BASE
|
|
||||||
COUNT=0
|
|
||||||
|
|
||||||
# Fetch remote tags
|
- name: Get latest commit message
|
||||||
git fetch --tags
|
id: last_commit
|
||||||
|
run: |
|
||||||
while git rev-parse "$TAG" >/dev/null 2>&1; do
|
msg=$(git log -1 --pretty=%B)
|
||||||
COUNT=$((COUNT + 1))
|
msg=${msg//$'\n'/\\n}
|
||||||
TAG="$BASE.$COUNT"
|
echo "commit_msg=$msg" >> $GITHUB_OUTPUT
|
||||||
done
|
|
||||||
|
|
||||||
echo "final_tag=$TAG" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@v1
|
||||||
@@ -43,6 +47,10 @@ jobs:
|
|||||||
name: Release ${{ steps.unique_tag.outputs.final_tag }}
|
name: Release ${{ steps.unique_tag.outputs.final_tag }}
|
||||||
body: |
|
body: |
|
||||||
📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}`
|
📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}`
|
||||||
🔁 Auto-postfix to avoid duplicate tag: `${{ steps.unique_tag.outputs.final_tag }}`
|
|
||||||
|
📝 Last commit message:
|
||||||
|
```
|
||||||
|
${{ steps.last_commit.outputs.commit_msg }}
|
||||||
|
```
|
||||||
env:
|
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>
|
||||||
|
```
|
||||||
22
README.md
22
README.md
@@ -1,26 +1,24 @@
|
|||||||
<h1 align="center">LayerForge – Advanced Canvas Editor for ComfyUI 🎨</h1>
|
<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"><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">
|
<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 href="https://registry.comfy.org/publishers/azornes/nodes/layerforge" style="display:inline-flex; align-items:center; gap:6px;">
|
||||||
</a>
|
<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">
|
<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" />
|
<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>
|
</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="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">
|
<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>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Why LayerForge?
|
### Why LayerForge?
|
||||||
|
|
||||||
- **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without
|
- **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without
|
||||||
@@ -149,7 +147,7 @@ optional feature and requires a model.
|
|||||||
> - **Download from**:
|
> - **Download from**:
|
||||||
>
|
>
|
||||||
- [Hugging Face](https://huggingface.co/ZhengPeng7/BiRefNet/tree/main) (Recommended)
|
- [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/`.
|
> - **Installation Path**: Place the model file in `ComfyUI/models/BiRefNet/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
181
canvas_node.py
181
canvas_node.py
@@ -10,7 +10,12 @@ import threading
|
|||||||
import os
|
import os
|
||||||
from tqdm import tqdm
|
from tqdm import tqdm
|
||||||
from torchvision import transforms
|
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 torch.nn.functional as F
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
@@ -23,8 +28,9 @@ import os
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from python.logger import logger, LogLevel, debug, info, warn, error, exception
|
from python.logger import logger, LogLevel, debug, info, warn, error, exception
|
||||||
|
from python.config import LOG_LEVEL
|
||||||
|
|
||||||
logger.set_module_level('canvas_node', LogLevel.NONE)
|
logger.set_module_level('canvas_node', LogLevel[LOG_LEVEL])
|
||||||
|
|
||||||
logger.configure({
|
logger.configure({
|
||||||
'log_to_file': True,
|
'log_to_file': True,
|
||||||
@@ -168,6 +174,7 @@ class CanvasNode:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}),
|
"fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}),
|
||||||
|
"show_preview": ("BOOLEAN", {"default": False, "label_on": "Show Preview", "label_off": "Hide Preview"}),
|
||||||
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}),
|
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}),
|
||||||
"node_id": ("STRING", {"default": "0", "hidden": True}),
|
"node_id": ("STRING", {"default": "0", "hidden": True}),
|
||||||
},
|
},
|
||||||
@@ -231,7 +238,7 @@ class CanvasNode:
|
|||||||
|
|
||||||
_processing_lock = threading.Lock()
|
_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, trigger, node_id, prompt=None, unique_id=None):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
@@ -327,6 +334,24 @@ class CanvasNode:
|
|||||||
latest_image_path = max(image_files, key=os.path.getctime)
|
latest_image_path = max(image_files, key=os.path.getctime)
|
||||||
return latest_image_path
|
return latest_image_path
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_latest_images(cls, since_timestamp=0):
|
||||||
|
output_dir = folder_paths.get_output_directory()
|
||||||
|
files = []
|
||||||
|
for f_name in os.listdir(output_dir):
|
||||||
|
file_path = os.path.join(output_dir, f_name)
|
||||||
|
if os.path.isfile(file_path) and file_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(file_path)
|
||||||
|
if mtime > since_timestamp:
|
||||||
|
files.append((mtime, file_path))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
files.sort(key=lambda x: x[0])
|
||||||
|
|
||||||
|
return [f[1] for f in files]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_flow_status(cls, flow_id=None):
|
def get_flow_status(cls, flow_id=None):
|
||||||
|
|
||||||
@@ -448,6 +473,30 @@ class CanvasNode:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@PromptServer.instance.routes.get("/layerforge/get-latest-images/{since}")
|
||||||
|
async def get_latest_images_route(request):
|
||||||
|
try:
|
||||||
|
since_timestamp = float(request.match_info.get('since', 0))
|
||||||
|
# JS Timestamps are in milliseconds, Python's are in seconds
|
||||||
|
latest_image_paths = cls.get_latest_images(since_timestamp / 1000.0)
|
||||||
|
|
||||||
|
images_data = []
|
||||||
|
for image_path in latest_image_paths:
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
encoded_string = base64.b64encode(f.read()).decode('utf-8')
|
||||||
|
images_data.append(f"data:image/png;base64,{encoded_string}")
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'images': images_data
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"Error in get_latest_images_route: {str(e)}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
@PromptServer.instance.routes.get("/ycnode/get_latest_image")
|
@PromptServer.instance.routes.get("/ycnode/get_latest_image")
|
||||||
async def get_latest_image_route(request):
|
async def get_latest_image_route(request):
|
||||||
try:
|
try:
|
||||||
@@ -470,6 +519,70 @@ class CanvasNode:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, 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):
|
def store_image(self, image_data):
|
||||||
|
|
||||||
if isinstance(image_data, str) and image_data.startswith('data:image'):
|
if isinstance(image_data, str) and image_data.startswith('data:image'):
|
||||||
@@ -501,42 +614,38 @@ class BiRefNetMatting:
|
|||||||
def load_model(self, model_path):
|
def load_model(self, model_path):
|
||||||
try:
|
try:
|
||||||
if model_path not in self.model_cache:
|
if model_path not in self.model_cache:
|
||||||
|
|
||||||
full_model_path = os.path.join(self.base_path, "BiRefNet")
|
full_model_path = os.path.join(self.base_path, "BiRefNet")
|
||||||
|
|
||||||
log_info(f"Loading BiRefNet model from {full_model_path}...")
|
log_info(f"Loading BiRefNet model from {full_model_path}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
self.model = AutoModelForImageSegmentation.from_pretrained(
|
self.model = AutoModelForImageSegmentation.from_pretrained(
|
||||||
"ZhengPeng7/BiRefNet",
|
"ZhengPeng7/BiRefNet",
|
||||||
trust_remote_code=True,
|
trust_remote_code=True,
|
||||||
cache_dir=full_model_path
|
cache_dir=full_model_path
|
||||||
)
|
)
|
||||||
|
|
||||||
self.model.eval()
|
self.model.eval()
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
self.model = self.model.cuda()
|
self.model = self.model.cuda()
|
||||||
|
|
||||||
self.model_cache[model_path] = self.model
|
self.model_cache[model_path] = self.model
|
||||||
log_info("Model loaded successfully from Hugging Face")
|
log_info("Model loaded successfully from Hugging Face")
|
||||||
log_debug(f"Model type: {type(self.model)}")
|
|
||||||
log_debug(f"Model device: {next(self.model.parameters()).device}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_error(f"Failed to load model: {str(e)}")
|
log_error(f"Failed to load model from Hugging Face: {str(e)}")
|
||||||
raise
|
# Re-raise with a more informative message
|
||||||
|
raise RuntimeError(
|
||||||
|
"Failed to download or load the matting model. "
|
||||||
|
"This could be due to a network issue, file permissions, or a corrupted model cache. "
|
||||||
|
f"Please check your internet connection and the model cache path: {full_model_path}. "
|
||||||
|
f"Original error: {str(e)}"
|
||||||
|
) from e
|
||||||
else:
|
else:
|
||||||
self.model = self.model_cache[model_path]
|
self.model = self.model_cache[model_path]
|
||||||
log_debug("Using cached model")
|
log_debug("Using cached model")
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Catch the re-raised exception or any other error
|
||||||
log_error(f"Error loading model: {str(e)}")
|
log_error(f"Error loading model: {str(e)}")
|
||||||
log_exception("Model loading failed")
|
log_exception("Model loading failed")
|
||||||
return False
|
raise # Re-raise the exception to be caught by the execute method
|
||||||
|
|
||||||
def preprocess_image(self, image):
|
def preprocess_image(self, image):
|
||||||
|
|
||||||
@@ -566,11 +675,9 @@ class BiRefNetMatting:
|
|||||||
|
|
||||||
def execute(self, image, model_path, threshold=0.5, refinement=1):
|
def execute(self, image, model_path, threshold=0.5, refinement=1):
|
||||||
try:
|
try:
|
||||||
|
|
||||||
PromptServer.instance.send_sync("matting_status", {"status": "processing"})
|
PromptServer.instance.send_sync("matting_status", {"status": "processing"})
|
||||||
|
|
||||||
if not self.load_model(model_path):
|
self.load_model(model_path)
|
||||||
raise RuntimeError("Failed to load model")
|
|
||||||
|
|
||||||
if isinstance(image, torch.Tensor):
|
if isinstance(image, torch.Tensor):
|
||||||
original_size = image.shape[-2:] if image.dim() == 4 else image.shape[-2:]
|
original_size = image.shape[-2:] if image.dim() == 4 else image.shape[-2:]
|
||||||
@@ -647,25 +754,31 @@ _matting_lock = None
|
|||||||
async def matting(request):
|
async def matting(request):
|
||||||
global _matting_lock
|
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:
|
if _matting_lock is not None:
|
||||||
log_warn("Matting already in progress, rejecting request")
|
log_warn("Matting already in progress, rejecting request")
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"error": "Another matting operation is in progress",
|
"error": "Another matting operation is in progress",
|
||||||
"details": "Please wait for the current operation to complete"
|
"details": "Please wait for the current operation to complete"
|
||||||
}, status=429) # 429 Too Many Requests
|
}, status=429)
|
||||||
|
|
||||||
_matting_lock = True
|
_matting_lock = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
log_info("Received matting request")
|
log_info("Received matting request")
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
|
|
||||||
matting = BiRefNetMatting()
|
matting_instance = BiRefNetMatting()
|
||||||
|
|
||||||
image_tensor, original_alpha = convert_base64_to_tensor(data["image"])
|
image_tensor, original_alpha = convert_base64_to_tensor(data["image"])
|
||||||
log_debug(f"Input image shape: {image_tensor.shape}")
|
log_debug(f"Input image shape: {image_tensor.shape}")
|
||||||
|
|
||||||
matted_image, alpha_mask = matting.execute(
|
matted_image, alpha_mask = matting_instance.execute(
|
||||||
image_tensor,
|
image_tensor,
|
||||||
"BiRefNet/model.safetensors",
|
"BiRefNet/model.safetensors",
|
||||||
threshold=data.get("threshold", 0.5),
|
threshold=data.get("threshold", 0.5),
|
||||||
@@ -680,14 +793,26 @@ async def matting(request):
|
|||||||
"alpha_mask": result_mask
|
"alpha_mask": result_mask
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except RequestsConnectionError as e:
|
||||||
log_exception(f"Error in matting endpoint: {str(e)}")
|
log_error(f"Connection error during matting model download: {e}")
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"error": str(e),
|
"error": "Network Connection Error",
|
||||||
|
"details": "Failed to download the matting model from Hugging Face. Please check your internet connection."
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
log_exception(f"Error in matting endpoint: {e}")
|
||||||
|
# Check for offline error message from Hugging Face
|
||||||
|
if "Offline mode is enabled" in str(e) or "Can't load 'ZhengPeng7/BiRefNet' offline" in str(e):
|
||||||
|
return web.json_response({
|
||||||
|
"error": "Network Connection Error",
|
||||||
|
"details": "Failed to download the matting model from Hugging Face. Please check your internet connection and ensure you are not in offline mode."
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"error": "An unexpected error occurred",
|
||||||
"details": traceback.format_exc()
|
"details": traceback.format_exc()
|
||||||
}, status=500)
|
}, status=500)
|
||||||
finally:
|
finally:
|
||||||
|
|
||||||
_matting_lock = None
|
_matting_lock = None
|
||||||
log_debug("Matting lock released")
|
log_debug("Matting lock released")
|
||||||
|
|
||||||
|
|||||||
365
example_workflows/LayerForge_test_workflow.json
Normal file
365
example_workflows/LayerForge_test_workflow.json
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
{
|
||||||
|
"id": "c7ba7096-c52c-4978-8843-e87ce219b6a8",
|
||||||
|
"revision": 0,
|
||||||
|
"last_node_id": 705,
|
||||||
|
"last_link_id": 1497,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 368,
|
||||||
|
"type": "Mask To Image (mtb)",
|
||||||
|
"pos": [
|
||||||
|
-1913.9735107421875,
|
||||||
|
-3351.5126953125
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
210,
|
||||||
|
130
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 3,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "mask",
|
||||||
|
"type": "MASK",
|
||||||
|
"link": 1496
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "IMAGE",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": [
|
||||||
|
612
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"cnr_id": "comfy-mtb",
|
||||||
|
"ver": "7e36007933f42c29cca270ae55e0e6866e323633",
|
||||||
|
"Node name for S&R": "Mask To Image (mtb)",
|
||||||
|
"widget_ue_connectable": {}
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"#ff0000",
|
||||||
|
"#000000",
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 442,
|
||||||
|
"type": "JoinImageWithAlpha",
|
||||||
|
"pos": [
|
||||||
|
-1907.2977294921875,
|
||||||
|
-3180.562744140625
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
176.86483764648438,
|
||||||
|
46
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 4,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "image",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": 1494
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "alpha",
|
||||||
|
"type": "MASK",
|
||||||
|
"link": 1497
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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": 369,
|
||||||
|
"type": "PreviewImage",
|
||||||
|
"pos": [
|
||||||
|
-1699.1021728515625,
|
||||||
|
-3355.60498046875
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
660.91162109375,
|
||||||
|
400.2092590332031
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 5,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "images",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": 612
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"cnr_id": "comfy-core",
|
||||||
|
"ver": "0.3.34",
|
||||||
|
"Node name for S&R": "PreviewImage",
|
||||||
|
"widget_ue_connectable": {}
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 606,
|
||||||
|
"type": "PreviewImage",
|
||||||
|
"pos": [
|
||||||
|
-1911.126708984375,
|
||||||
|
-2916.072998046875
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
551.7399291992188,
|
||||||
|
546.8018798828125
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 2,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "images",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": 1495
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"cnr_id": "comfy-core",
|
||||||
|
"ver": "0.3.34",
|
||||||
|
"Node name for S&R": "PreviewImage",
|
||||||
|
"widget_ue_connectable": {}
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 603,
|
||||||
|
"type": "PreviewImage",
|
||||||
|
"pos": [
|
||||||
|
-1344.1650390625,
|
||||||
|
-2915.117919921875
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
601.4136962890625,
|
||||||
|
527.1531372070312
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 6,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "images",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": 1236
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"cnr_id": "comfy-core",
|
||||||
|
"ver": "0.3.34",
|
||||||
|
"Node name for S&R": "PreviewImage",
|
||||||
|
"widget_ue_connectable": {}
|
||||||
|
},
|
||||||
|
"widgets_values": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 680,
|
||||||
|
"type": "SaveImage",
|
||||||
|
"pos": [
|
||||||
|
-1025.9984130859375,
|
||||||
|
-3357.975341796875
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
278.8309020996094,
|
||||||
|
395.84002685546875
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 7,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [
|
||||||
|
{
|
||||||
|
"name": "images",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"link": 1465
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputs": [],
|
||||||
|
"properties": {
|
||||||
|
"cnr_id": "comfy-core",
|
||||||
|
"ver": "0.3.34",
|
||||||
|
"Node name for S&R": "SaveImage",
|
||||||
|
"widget_ue_connectable": {}
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"ComfyUI"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 701,
|
||||||
|
"type": "MarkdownNote",
|
||||||
|
"pos": [
|
||||||
|
-3330.08984375,
|
||||||
|
-3347.998291015625
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
347.055419921875,
|
||||||
|
217.8630828857422
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 0,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [],
|
||||||
|
"title": "Known Issue",
|
||||||
|
"properties": {
|
||||||
|
"widget_ue_connectable": {}
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
"### `node_id` not auto-filled → black output\n\nIn some cases, **ComfyUI doesn't auto-fill the `node_id`** when adding a node.\nAs a result, the node may produce a **completely black image** or not work at all.\n\n**Workaround:**\n\n* Search node ID in ComfyUI settings.\n* In NodesMap check \"Enable node ID display\"\n* Manually enter the correct `node_id` (match the ID shown in the UI).\n\n⚠️ This is a known issue and not yet fixed.\nPlease follow the steps above if your output is black or broken."
|
||||||
|
],
|
||||||
|
"color": "#432",
|
||||||
|
"bgcolor": "#653"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 697,
|
||||||
|
"type": "CanvasNode",
|
||||||
|
"pos": [
|
||||||
|
-2968.572998046875,
|
||||||
|
-3347.89306640625
|
||||||
|
],
|
||||||
|
"size": [
|
||||||
|
1044.9053955078125,
|
||||||
|
980.680908203125
|
||||||
|
],
|
||||||
|
"flags": {},
|
||||||
|
"order": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"inputs": [],
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"name": "image",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"links": [
|
||||||
|
1494,
|
||||||
|
1495
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mask",
|
||||||
|
"type": "MASK",
|
||||||
|
"links": [
|
||||||
|
1496,
|
||||||
|
1497
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"cnr_id": "Comfyui-Ycanvas",
|
||||||
|
"ver": "f6a491e83bab9481a2cac3367541a3b7803df9ab",
|
||||||
|
"Node name for S&R": "CanvasNode",
|
||||||
|
"widget_ue_connectable": {}
|
||||||
|
},
|
||||||
|
"widgets_values": [
|
||||||
|
true,
|
||||||
|
17,
|
||||||
|
"697",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
[
|
||||||
|
612,
|
||||||
|
368,
|
||||||
|
0,
|
||||||
|
369,
|
||||||
|
0,
|
||||||
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1236,
|
||||||
|
442,
|
||||||
|
0,
|
||||||
|
603,
|
||||||
|
0,
|
||||||
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1465,
|
||||||
|
442,
|
||||||
|
0,
|
||||||
|
680,
|
||||||
|
0,
|
||||||
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1494,
|
||||||
|
697,
|
||||||
|
0,
|
||||||
|
442,
|
||||||
|
0,
|
||||||
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1495,
|
||||||
|
697,
|
||||||
|
0,
|
||||||
|
606,
|
||||||
|
0,
|
||||||
|
"IMAGE"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1496,
|
||||||
|
697,
|
||||||
|
1,
|
||||||
|
368,
|
||||||
|
0,
|
||||||
|
"MASK"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1497,
|
||||||
|
697,
|
||||||
|
1,
|
||||||
|
442,
|
||||||
|
1,
|
||||||
|
"MASK"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"groups": [],
|
||||||
|
"config": {},
|
||||||
|
"extra": {
|
||||||
|
"ds": {
|
||||||
|
"scale": 0.7972024500000005,
|
||||||
|
"offset": [
|
||||||
|
3957.401300495613,
|
||||||
|
3455.1487103849176
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"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_workflow.png
Normal file
BIN
example_workflows/LayerForge_test_workflow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 335 KiB |
258
js/BatchPreviewManager.js
Normal file
258
js/BatchPreviewManager.js
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||||
|
|
||||||
|
const log = createModuleLogger('BatchPreviewManager');
|
||||||
|
|
||||||
|
export class BatchPreviewManager {
|
||||||
|
constructor(canvas, initialPosition = { x: 0, y: 0 }, generationArea = null) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.active = false;
|
||||||
|
this.layers = [];
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.element = null;
|
||||||
|
this.uiInitialized = false;
|
||||||
|
this.maskWasVisible = false;
|
||||||
|
|
||||||
|
// Position in canvas world coordinates
|
||||||
|
this.worldX = initialPosition.x;
|
||||||
|
this.worldY = initialPosition.y;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.generationArea = generationArea; // Store the generation area
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScreenPosition(viewport) {
|
||||||
|
if (!this.active || !this.element) return;
|
||||||
|
|
||||||
|
// Translate world coordinates to screen coordinates
|
||||||
|
const screenX = (this.worldX - viewport.x) * viewport.zoom;
|
||||||
|
const screenY = (this.worldY - viewport.y) * viewport.zoom;
|
||||||
|
|
||||||
|
// We can also scale the menu with zoom, but let's keep it constant for now for readability
|
||||||
|
const scale = 1; // viewport.zoom;
|
||||||
|
|
||||||
|
// Use transform for performance
|
||||||
|
this.element.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_createUI() {
|
||||||
|
if (this.uiInitialized) return;
|
||||||
|
|
||||||
|
this.element = document.createElement('div');
|
||||||
|
this.element.id = 'layerforge-batch-preview';
|
||||||
|
this.element.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: #333;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
z-index: 1001;
|
||||||
|
border: 1px solid #555;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.element.addEventListener('mousedown', (e) => {
|
||||||
|
if (e.target.tagName === 'BUTTON') return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
this.isDragging = true;
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent) => {
|
||||||
|
if (this.isDragging) {
|
||||||
|
// Convert screen pixel movement to world coordinate movement
|
||||||
|
const deltaX = moveEvent.movementX / this.canvas.viewport.zoom;
|
||||||
|
const deltaY = moveEvent.movementY / this.canvas.viewport.zoom;
|
||||||
|
|
||||||
|
this.worldX += deltaX;
|
||||||
|
this.worldY += deltaY;
|
||||||
|
|
||||||
|
// The render loop will handle updating the screen position, but we need to trigger it.
|
||||||
|
this.canvas.render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
this.isDragging = false;
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevButton = this._createButton('◀', 'Previous'); // Left arrow
|
||||||
|
const nextButton = this._createButton('▶', 'Next'); // Right arrow
|
||||||
|
const confirmButton = this._createButton('✔', 'Confirm'); // Checkmark
|
||||||
|
const cancelButton = this._createButton('✖', 'Cancel All'); // X mark
|
||||||
|
const closeButton = this._createButton('➲', 'Close'); // Door icon
|
||||||
|
|
||||||
|
this.counterElement = document.createElement('span');
|
||||||
|
this.counterElement.style.minWidth = '40px';
|
||||||
|
this.counterElement.style.textAlign = 'center';
|
||||||
|
this.counterElement.style.fontWeight = 'bold';
|
||||||
|
|
||||||
|
prevButton.onclick = () => this.navigate(-1);
|
||||||
|
nextButton.onclick = () => this.navigate(1);
|
||||||
|
confirmButton.onclick = () => this.confirm();
|
||||||
|
cancelButton.onclick = () => this.cancelAndRemoveAll();
|
||||||
|
closeButton.onclick = () => this.hide();
|
||||||
|
|
||||||
|
this.element.append(prevButton, this.counterElement, nextButton, confirmButton, cancelButton, closeButton);
|
||||||
|
if (this.canvas.canvas.parentNode) {
|
||||||
|
this.canvas.canvas.parentNode.appendChild(this.element);
|
||||||
|
} else {
|
||||||
|
log.error("Could not find parent node to attach batch preview UI.");
|
||||||
|
}
|
||||||
|
this.uiInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_createButton(innerHTML, title) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.innerHTML = innerHTML;
|
||||||
|
button.title = title;
|
||||||
|
button.style.cssText = `
|
||||||
|
background: #555;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #777;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
button.onmouseover = () => button.style.background = '#666';
|
||||||
|
button.onmouseout = () => button.style.background = '#555';
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
show(layers) {
|
||||||
|
if (!layers || layers.length <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._createUI();
|
||||||
|
|
||||||
|
// Auto-hide mask logic
|
||||||
|
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
||||||
|
if (this.maskWasVisible) {
|
||||||
|
this.canvas.maskTool.toggleOverlayVisibility();
|
||||||
|
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.classList.remove('primary');
|
||||||
|
toggleBtn.textContent = "Hide Mask";
|
||||||
|
}
|
||||||
|
this.canvas.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Showing batch preview for ${layers.length} layers.`);
|
||||||
|
this.layers = layers;
|
||||||
|
this.currentIndex = 0;
|
||||||
|
|
||||||
|
// Make the element visible BEFORE calculating its size
|
||||||
|
this.element.style.display = 'flex';
|
||||||
|
this.active = true;
|
||||||
|
|
||||||
|
// Now that it's visible, we can get its dimensions and adjust the position.
|
||||||
|
const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom;
|
||||||
|
const paddingInWorld = 20 / this.canvas.viewport.zoom;
|
||||||
|
|
||||||
|
this.worldX -= menuWidthInWorld / 2; // Center horizontally
|
||||||
|
this.worldY += paddingInWorld; // Add padding below the output area
|
||||||
|
|
||||||
|
this._update();
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
log.info('Hiding batch preview.');
|
||||||
|
if (this.element) {
|
||||||
|
this.element.remove();
|
||||||
|
}
|
||||||
|
this.active = false;
|
||||||
|
|
||||||
|
const index = this.canvas.batchPreviewManagers.indexOf(this);
|
||||||
|
if (index > -1) {
|
||||||
|
this.canvas.batchPreviewManagers.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger a final render to ensure the generation area outline is removed
|
||||||
|
this.canvas.render();
|
||||||
|
|
||||||
|
// Restore mask visibility if it was hidden by this manager
|
||||||
|
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
||||||
|
this.canvas.maskTool.toggleOverlayVisibility();
|
||||||
|
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.classList.add('primary');
|
||||||
|
toggleBtn.textContent = "Show Mask";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.maskWasVisible = false; // Reset state
|
||||||
|
|
||||||
|
// Make all layers visible again upon closing
|
||||||
|
this.canvas.layers.forEach(l => l.visible = true);
|
||||||
|
this.canvas.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
navigate(direction) {
|
||||||
|
this.currentIndex += direction;
|
||||||
|
if (this.currentIndex < 0) {
|
||||||
|
this.currentIndex = this.layers.length - 1;
|
||||||
|
} else if (this.currentIndex >= this.layers.length) {
|
||||||
|
this.currentIndex = 0;
|
||||||
|
}
|
||||||
|
this._update();
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
const layerToKeep = this.layers[this.currentIndex];
|
||||||
|
log.info(`Confirming selection: Keeping layer ${layerToKeep.id}.`);
|
||||||
|
|
||||||
|
const layersToDelete = this.layers.filter(l => l.id !== layerToKeep.id);
|
||||||
|
const layerIdsToDelete = layersToDelete.map(l => l.id);
|
||||||
|
|
||||||
|
this.canvas.removeLayersByIds(layerIdsToDelete);
|
||||||
|
log.info(`Deleted ${layersToDelete.length} other layers.`);
|
||||||
|
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAndRemoveAll() {
|
||||||
|
log.info('Cancel clicked. Removing all new layers.');
|
||||||
|
|
||||||
|
const layerIdsToDelete = this.layers.map(l => l.id);
|
||||||
|
this.canvas.removeLayersByIds(layerIdsToDelete);
|
||||||
|
log.info(`Deleted all ${layerIdsToDelete.length} new layers.`);
|
||||||
|
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
_update() {
|
||||||
|
this.counterElement.textContent = `${this.currentIndex + 1} / ${this.layers.length}`;
|
||||||
|
this._focusOnLayer(this.layers[this.currentIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
_focusOnLayer(layer) {
|
||||||
|
if (!layer) return;
|
||||||
|
log.debug(`Focusing on layer ${layer.id}`);
|
||||||
|
|
||||||
|
// Move the selected layer to the top of the layer stack
|
||||||
|
this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 });
|
||||||
|
|
||||||
|
this.canvas.updateSelection([layer]);
|
||||||
|
|
||||||
|
// Render is called by moveLayers, but we call it again to be safe
|
||||||
|
this.canvas.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
737
js/Canvas.js
737
js/Canvas.js
@@ -1,27 +1,50 @@
|
|||||||
|
import {app, ComfyApp} from "../../scripts/app.js";
|
||||||
|
import {api} from "../../scripts/api.js";
|
||||||
import {removeImage} from "./db.js";
|
import {removeImage} from "./db.js";
|
||||||
import {MaskTool} from "./MaskTool.js";
|
import {MaskTool} from "./MaskTool.js";
|
||||||
import {CanvasState} from "./CanvasState.js";
|
import {CanvasState} from "./CanvasState.js";
|
||||||
import {CanvasInteractions} from "./CanvasInteractions.js";
|
import {CanvasInteractions} from "./CanvasInteractions.js";
|
||||||
import {CanvasLayers} from "./CanvasLayers.js";
|
import {CanvasLayers} from "./CanvasLayers.js";
|
||||||
|
import {CanvasLayersPanel} from "./CanvasLayersPanel.js";
|
||||||
import {CanvasRenderer} from "./CanvasRenderer.js";
|
import {CanvasRenderer} from "./CanvasRenderer.js";
|
||||||
import {CanvasIO} from "./CanvasIO.js";
|
import {CanvasIO} from "./CanvasIO.js";
|
||||||
import {ImageReferenceManager} from "./ImageReferenceManager.js";
|
import {ImageReferenceManager} from "./ImageReferenceManager.js";
|
||||||
|
import {BatchPreviewManager} from "./BatchPreviewManager.js";
|
||||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||||
|
import { debounce } from "./utils/CommonUtils.js";
|
||||||
|
import {CanvasMask} from "./CanvasMask.js";
|
||||||
|
import {CanvasSelection} from "./CanvasSelection.js";
|
||||||
|
|
||||||
|
const useChainCallback = (original, next) => {
|
||||||
|
if (original === undefined || original === null) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return function(...args) {
|
||||||
|
const originalReturn = original.apply(this, args);
|
||||||
|
const nextReturn = next.apply(this, args);
|
||||||
|
return nextReturn === undefined ? originalReturn : nextReturn;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const log = createModuleLogger('Canvas');
|
const log = createModuleLogger('Canvas');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
export class Canvas {
|
||||||
constructor(node, widget, callbacks = {}) {
|
constructor(node, widget, callbacks = {}) {
|
||||||
this.node = node;
|
this.node = node;
|
||||||
this.widget = widget;
|
this.widget = widget;
|
||||||
this.canvas = document.createElement('canvas');
|
this.canvas = document.createElement('canvas');
|
||||||
this.ctx = this.canvas.getContext('2d');
|
this.ctx = this.canvas.getContext('2d', {willReadFrequently: true});
|
||||||
this.width = 512;
|
this.width = 512;
|
||||||
this.height = 512;
|
this.height = 512;
|
||||||
this.layers = [];
|
this.layers = [];
|
||||||
this.selectedLayer = null;
|
|
||||||
this.selectedLayers = [];
|
|
||||||
this.onSelectionChange = null;
|
|
||||||
this.onStateChange = callbacks.onStateChange || null;
|
this.onStateChange = callbacks.onStateChange || null;
|
||||||
this.lastMousePosition = {x: 0, y: 0};
|
this.lastMousePosition = {x: 0, y: 0};
|
||||||
|
|
||||||
@@ -38,91 +61,407 @@ export class Canvas {
|
|||||||
|
|
||||||
this.dataInitialized = false;
|
this.dataInitialized = false;
|
||||||
this.pendingDataCheck = null;
|
this.pendingDataCheck = null;
|
||||||
|
this.imageCache = new Map();
|
||||||
|
|
||||||
|
this._initializeModules(callbacks);
|
||||||
|
|
||||||
|
this._setupCanvas();
|
||||||
|
|
||||||
|
this.interaction = this.canvasInteractions.interaction;
|
||||||
|
|
||||||
|
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.setPreviewVisibility(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(callbacks) {
|
||||||
|
log.debug('Initializing Canvas modules...');
|
||||||
|
|
||||||
|
// Stwórz opóźnioną wersję funkcji zapisu stanu
|
||||||
|
this.requestSaveState = debounce(this.saveState.bind(this), 500);
|
||||||
|
|
||||||
|
this._addAutoRefreshToggle();
|
||||||
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
|
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
|
||||||
this.initCanvas();
|
this.canvasMask = new CanvasMask(this);
|
||||||
this.canvasState = new CanvasState(this);
|
this.canvasState = new CanvasState(this);
|
||||||
|
this.canvasSelection = new CanvasSelection(this);
|
||||||
this.canvasInteractions = new CanvasInteractions(this);
|
this.canvasInteractions = new CanvasInteractions(this);
|
||||||
this.canvasLayers = new CanvasLayers(this);
|
this.canvasLayers = new CanvasLayers(this);
|
||||||
|
this.canvasLayersPanel = new CanvasLayersPanel(this);
|
||||||
this.canvasRenderer = new CanvasRenderer(this);
|
this.canvasRenderer = new CanvasRenderer(this);
|
||||||
this.canvasIO = new CanvasIO(this);
|
this.canvasIO = new CanvasIO(this);
|
||||||
this.imageReferenceManager = new ImageReferenceManager(this);
|
this.imageReferenceManager = new ImageReferenceManager(this);
|
||||||
this.interaction = this.canvasInteractions.interaction;
|
this.batchPreviewManagers = [];
|
||||||
|
this.pendingBatchContext = null;
|
||||||
|
|
||||||
this.setupEventListeners();
|
log.debug('Canvas modules initialized successfully');
|
||||||
this.initNodeData();
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konfiguruje podstawowe właściwości canvas
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_setupCanvas() {
|
||||||
|
this.initCanvas();
|
||||||
|
this.canvasInteractions.setupEventListeners();
|
||||||
|
this.canvasIO.initNodeData();
|
||||||
|
|
||||||
this.layers = this.layers.map(layer => ({
|
this.layers = this.layers.map(layer => ({
|
||||||
...layer,
|
...layer,
|
||||||
opacity: 1
|
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() {
|
async loadInitialState() {
|
||||||
log.info("Loading initial state for node:", this.node.id);
|
log.info("Loading initial state for node:", this.node.id);
|
||||||
const loaded = await this.loadStateFromDB();
|
const loaded = await this.canvasState.loadStateFromDB();
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
log.info("No saved state found, initializing from node data.");
|
log.info("No saved state found, initializing from node data.");
|
||||||
await this.initNodeData();
|
await this.canvasIO.initNodeData();
|
||||||
}
|
}
|
||||||
this.saveState();
|
this.saveState();
|
||||||
this.render();
|
this.render();
|
||||||
}
|
|
||||||
|
|
||||||
_notifyStateChange() {
|
// Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu
|
||||||
if (this.onStateChange) {
|
if (this.canvasLayersPanel) {
|
||||||
this.onStateChange();
|
this.canvasLayersPanel.onLayersChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapisuje obecny stan
|
||||||
|
* @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii
|
||||||
|
*/
|
||||||
saveState(replaceLast = false) {
|
saveState(replaceLast = false) {
|
||||||
|
log.debug('Saving canvas state', {replaceLast, layersCount: this.layers.length});
|
||||||
this.canvasState.saveState(replaceLast);
|
this.canvasState.saveState(replaceLast);
|
||||||
this.incrementOperationCount();
|
this.incrementOperationCount();
|
||||||
this._notifyStateChange();
|
this._notifyStateChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cofnij ostatnią operację
|
||||||
|
*/
|
||||||
undo() {
|
undo() {
|
||||||
|
log.info('Performing undo operation');
|
||||||
|
const historyInfo = this.canvasState.getHistoryInfo();
|
||||||
|
log.debug('History state before undo:', historyInfo);
|
||||||
|
|
||||||
this.canvasState.undo();
|
this.canvasState.undo();
|
||||||
this.incrementOperationCount();
|
this.incrementOperationCount();
|
||||||
this._notifyStateChange();
|
this._notifyStateChange();
|
||||||
|
|
||||||
|
// Powiadom panel warstw o zmianie stanu warstw
|
||||||
|
if (this.canvasLayersPanel) {
|
||||||
|
this.canvasLayersPanel.onLayersChanged();
|
||||||
|
this.canvasLayersPanel.onSelectionChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('Undo completed, layers count:', this.layers.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ponów cofniętą operację
|
||||||
|
*/
|
||||||
redo() {
|
redo() {
|
||||||
|
log.info('Performing redo operation');
|
||||||
|
const historyInfo = this.canvasState.getHistoryInfo();
|
||||||
|
log.debug('History state before redo:', historyInfo);
|
||||||
|
|
||||||
this.canvasState.redo();
|
this.canvasState.redo();
|
||||||
this.incrementOperationCount();
|
this.incrementOperationCount();
|
||||||
this._notifyStateChange();
|
this._notifyStateChange();
|
||||||
}
|
|
||||||
|
|
||||||
updateSelectionAfterHistory() {
|
// Powiadom panel warstw o zmianie stanu warstw
|
||||||
const newSelectedLayers = [];
|
if (this.canvasLayersPanel) {
|
||||||
if (this.selectedLayers) {
|
this.canvasLayersPanel.onLayersChanged();
|
||||||
this.selectedLayers.forEach(sl => {
|
this.canvasLayersPanel.onSelectionChanged();
|
||||||
const found = this.layers.find(l => l.id === sl.id);
|
|
||||||
if (found) newSelectedLayers.push(found);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
this.updateSelection(newSelectedLayers);
|
|
||||||
|
log.debug('Redo completed, layers count:', this.layers.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateHistoryButtons() {
|
/**
|
||||||
if (this.onHistoryChange) {
|
* Renderuje canvas
|
||||||
const historyInfo = this.canvasState.getHistoryInfo();
|
*/
|
||||||
this.onHistoryChange({
|
render() {
|
||||||
canUndo: historyInfo.canUndo,
|
this.canvasRenderer.render();
|
||||||
canRedo: historyInfo.canRedo
|
}
|
||||||
});
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
|
||||||
|
*/
|
||||||
|
duplicateSelectedLayers() {
|
||||||
|
return this.canvasSelection.duplicateSelectedLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
|
||||||
|
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
|
||||||
|
* @param {Array} newSelection - Nowa lista zaznaczonych warstw
|
||||||
|
*/
|
||||||
|
updateSelection(newSelection) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eksportuje spłaszczony canvas jako blob
|
||||||
|
*/
|
||||||
|
async getFlattenedCanvasAsBlob() {
|
||||||
|
return this.canvasLayers.getFlattenedCanvasAsBlob();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eksportuje spłaszczony canvas z maską jako kanałem alpha
|
||||||
|
*/
|
||||||
|
async getFlattenedCanvasWithMaskAsBlob() {
|
||||||
|
return this.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importuje najnowszy obraz
|
||||||
|
*/
|
||||||
|
async importLatestImage() {
|
||||||
|
return this.canvasIO.importLatestImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
_addAutoRefreshToggle() {
|
||||||
|
let autoRefreshEnabled = false;
|
||||||
|
let lastExecutionStartTime = 0;
|
||||||
|
|
||||||
|
const handleExecutionStart = () => {
|
||||||
|
if (autoRefreshEnabled) {
|
||||||
|
lastExecutionStartTime = Date.now();
|
||||||
|
// Store a snapshot of the context for the upcoming batch
|
||||||
|
this.pendingBatchContext = {
|
||||||
|
// For the menu position
|
||||||
|
spawnPosition: {
|
||||||
|
x: this.width / 2,
|
||||||
|
y: this.height
|
||||||
|
},
|
||||||
|
// For the image placement
|
||||||
|
outputArea: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: this.width,
|
||||||
|
height: this.height
|
||||||
|
}
|
||||||
|
};
|
||||||
|
log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext);
|
||||||
|
this.render(); // Trigger render to show the pending outline immediately
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecutionSuccess = async () => {
|
||||||
|
if (autoRefreshEnabled) {
|
||||||
|
log.info('Auto-refresh triggered, importing latest images.');
|
||||||
|
|
||||||
|
if (!this.pendingBatchContext) {
|
||||||
|
log.warn("execution_start did not fire, cannot process batch. Awaiting next execution.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the captured output area for image import
|
||||||
|
const newLayers = await this.canvasIO.importLatestImages(
|
||||||
|
lastExecutionStartTime,
|
||||||
|
this.pendingBatchContext.outputArea
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newLayers && newLayers.length > 1) {
|
||||||
|
const newManager = new BatchPreviewManager(
|
||||||
|
this,
|
||||||
|
this.pendingBatchContext.spawnPosition,
|
||||||
|
this.pendingBatchContext.outputArea
|
||||||
|
);
|
||||||
|
this.batchPreviewManagers.push(newManager);
|
||||||
|
newManager.show(newLayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume the context
|
||||||
|
this.pendingBatchContext = null;
|
||||||
|
// Final render to clear the outline if it was the last one
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.node.addWidget(
|
||||||
|
'toggle',
|
||||||
|
'Auto-refresh after generation',
|
||||||
|
false,
|
||||||
|
(value) => {
|
||||||
|
autoRefreshEnabled = value;
|
||||||
|
log.debug('Auto-refresh toggled:', value);
|
||||||
|
}, {
|
||||||
|
serialize: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
api.addEventListener('execution_start', handleExecutionStart);
|
||||||
|
api.addEventListener('execution_success', handleExecutionSuccess);
|
||||||
|
|
||||||
|
this.node.onRemoved = useChainCallback(this.node.onRemoved, () => {
|
||||||
|
log.info('Node removed, cleaning up auto-refresh listeners.');
|
||||||
|
api.removeEventListener('execution_start', handleExecutionStart);
|
||||||
|
api.removeEventListener('execution_success', handleExecutionSuccess);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uruchamia edytor masek
|
||||||
|
* @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.canvasMask.startMaskEditor(predefinedMask, sendCleanImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inicjalizuje podstawowe właściwości canvas
|
||||||
|
*/
|
||||||
initCanvas() {
|
initCanvas() {
|
||||||
this.canvas.width = this.width;
|
this.canvas.width = this.width;
|
||||||
this.canvas.height = this.height;
|
this.canvas.height = this.height;
|
||||||
@@ -131,104 +470,14 @@ export class Canvas {
|
|||||||
this.canvas.style.backgroundColor = '#606060';
|
this.canvas.style.backgroundColor = '#606060';
|
||||||
this.canvas.style.width = '100%';
|
this.canvas.style.width = '100%';
|
||||||
this.canvas.style.height = '100%';
|
this.canvas.style.height = '100%';
|
||||||
|
|
||||||
|
|
||||||
this.canvas.tabIndex = 0;
|
this.canvas.tabIndex = 0;
|
||||||
this.canvas.style.outline = 'none';
|
this.canvas.style.outline = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
/**
|
||||||
this.canvasInteractions.setupEventListeners();
|
* Pobiera współrzędne myszy w układzie świata
|
||||||
}
|
* @param {MouseEvent} e - Zdarzenie myszy
|
||||||
|
*/
|
||||||
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) {
|
getMouseWorldCoordinates(e) {
|
||||||
const rect = this.canvas.getBoundingClientRect();
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
|
||||||
@@ -247,6 +496,10 @@ export class Canvas {
|
|||||||
return {x: worldX, y: worldY};
|
return {x: worldX, y: worldY};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pobiera współrzędne myszy w układzie widoku
|
||||||
|
* @param {MouseEvent} e - Zdarzenie myszy
|
||||||
|
*/
|
||||||
getMouseViewCoordinates(e) {
|
getMouseViewCoordinates(e) {
|
||||||
const rect = this.canvas.getBoundingClientRect();
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
const mouseX_DOM = e.clientX - rect.left;
|
const mouseX_DOM = e.clientX - rect.left;
|
||||||
@@ -258,160 +511,31 @@ export class Canvas {
|
|||||||
const mouseX_Canvas = mouseX_DOM * scaleX;
|
const mouseX_Canvas = mouseX_DOM * scaleX;
|
||||||
const mouseY_Canvas = mouseY_DOM * scaleY;
|
const mouseY_Canvas = mouseY_DOM * scaleY;
|
||||||
|
|
||||||
return { x: mouseX_Canvas, y: mouseY_Canvas };
|
return {x: mouseX_Canvas, y: mouseY_Canvas};
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
* 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() {
|
incrementOperationCount() {
|
||||||
if (this.imageReferenceManager) {
|
if (this.imageReferenceManager) {
|
||||||
@@ -420,40 +544,7 @@ export class Canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ręczne uruchomienie garbage collection
|
* Czyści zasoby canvas
|
||||||
*/
|
|
||||||
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)
|
|
||||||
*/
|
*/
|
||||||
destroy() {
|
destroy() {
|
||||||
if (this.imageReferenceManager) {
|
if (this.imageReferenceManager) {
|
||||||
@@ -461,4 +552,14 @@ export class Canvas {
|
|||||||
}
|
}
|
||||||
log.info("Canvas destroyed");
|
log.info("Canvas destroyed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Powiadamia o zmianie stanu
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_notifyStateChange() {
|
||||||
|
if (this.onStateChange) {
|
||||||
|
this.onStateChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export class CanvasIO {
|
|||||||
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
|
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
await this.canvas.saveStateToDB(true);
|
await this.canvas.canvasState.saveStateToDB(true);
|
||||||
const nodeId = this.canvas.node.id;
|
const nodeId = this.canvas.node.id;
|
||||||
const delay = (nodeId % 10) * 50;
|
const delay = (nodeId % 10) * 50;
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
@@ -102,7 +102,7 @@ export class CanvasIO {
|
|||||||
const tempMaskCanvas = document.createElement('canvas');
|
const tempMaskCanvas = document.createElement('canvas');
|
||||||
tempMaskCanvas.width = this.canvas.width;
|
tempMaskCanvas.width = this.canvas.width;
|
||||||
tempMaskCanvas.height = this.canvas.height;
|
tempMaskCanvas.height = this.canvas.height;
|
||||||
const tempMaskCtx = tempMaskCanvas.getContext('2d');
|
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ export class CanvasIO {
|
|||||||
const tempMaskCanvas = document.createElement('canvas');
|
const tempMaskCanvas = document.createElement('canvas');
|
||||||
tempMaskCanvas.width = this.canvas.width;
|
tempMaskCanvas.width = this.canvas.width;
|
||||||
tempMaskCanvas.height = this.canvas.height;
|
tempMaskCanvas.height = this.canvas.height;
|
||||||
const tempMaskCtx = tempMaskCanvas.getContext('2d');
|
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ export class CanvasIO {
|
|||||||
this.canvas.height / inputImage.height * 0.8
|
this.canvas.height / inputImage.height * 0.8
|
||||||
);
|
);
|
||||||
|
|
||||||
const layer = await this.canvas.addLayerWithImage(image, {
|
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
|
||||||
x: (this.canvas.width - inputImage.width * scale) / 2,
|
x: (this.canvas.width - inputImage.width * scale) / 2,
|
||||||
y: (this.canvas.height - inputImage.height * scale) / 2,
|
y: (this.canvas.height - inputImage.height * scale) / 2,
|
||||||
width: inputImage.width * scale,
|
width: inputImage.width * scale,
|
||||||
@@ -403,7 +403,7 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
canvas.width = tensor.width;
|
canvas.width = tensor.width;
|
||||||
canvas.height = tensor.height;
|
canvas.height = tensor.height;
|
||||||
|
|
||||||
@@ -611,7 +611,7 @@ export class CanvasIO {
|
|||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = imageData.width;
|
canvas.width = imageData.width;
|
||||||
canvas.height = imageData.height;
|
canvas.height = imageData.height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
ctx.putImageData(imageData, 0, 0);
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -684,7 +684,7 @@ export class CanvasIO {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = img.width;
|
tempCanvas.width = img.width;
|
||||||
tempCanvas.height = img.height;
|
tempCanvas.height = img.height;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
tempCtx.drawImage(img, 0, 0);
|
tempCtx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
@@ -693,7 +693,7 @@ export class CanvasIO {
|
|||||||
const maskCanvas = document.createElement('canvas');
|
const maskCanvas = document.createElement('canvas');
|
||||||
maskCanvas.width = img.width;
|
maskCanvas.width = img.width;
|
||||||
maskCanvas.height = img.height;
|
maskCanvas.height = img.height;
|
||||||
const maskCtx = maskCanvas.getContext('2d');
|
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
maskCtx.drawImage(mask, 0, 0);
|
maskCtx.drawImage(mask, 0, 0);
|
||||||
const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
|
const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
|
||||||
|
|
||||||
@@ -744,12 +744,7 @@ export class CanvasIO {
|
|||||||
img.src = result.image_data;
|
img.src = result.image_data;
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.canvas.addLayerWithImage(img, {
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit');
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: this.canvas.width,
|
|
||||||
height: this.canvas.height,
|
|
||||||
});
|
|
||||||
log.info("Latest image imported and placed on canvas successfully.");
|
log.info("Latest image imported and placed on canvas successfully.");
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
@@ -761,4 +756,41 @@ export class CanvasIO {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async importLatestImages(sinceTimestamp, targetArea = null) {
|
||||||
|
try {
|
||||||
|
log.info(`Fetching latest images since ${sinceTimestamp}...`);
|
||||||
|
const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.images && result.images.length > 0) {
|
||||||
|
log.info(`Received ${result.images.length} new images, adding to canvas.`);
|
||||||
|
const newLayers = [];
|
||||||
|
|
||||||
|
for (const imageData of result.images) {
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
img.onload = resolve;
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = imageData;
|
||||||
|
});
|
||||||
|
const newLayer = await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit', targetArea);
|
||||||
|
newLayers.push(newLayer);
|
||||||
|
}
|
||||||
|
log.info("All new images imported and placed on canvas successfully.");
|
||||||
|
return newLayers;
|
||||||
|
|
||||||
|
} else if (result.success) {
|
||||||
|
log.info("No new images found since last generation.");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error(result.error || "Failed to fetch latest images.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Error importing latest images:", error);
|
||||||
|
alert(`Failed to import latest images: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export class CanvasInteractions {
|
|||||||
hasClonedInDrag: false,
|
hasClonedInDrag: false,
|
||||||
lastClickTime: 0,
|
lastClickTime: 0,
|
||||||
transformingLayer: null,
|
transformingLayer: null,
|
||||||
|
keyMovementInProgress: false, // Flaga do śledzenia ruchu klawiszami
|
||||||
};
|
};
|
||||||
this.originalLayerPositions = new Map();
|
this.originalLayerPositions = new Map();
|
||||||
this.interaction.canvasResizeRect = null;
|
this.interaction.canvasResizeRect = null;
|
||||||
@@ -34,6 +35,8 @@ export class CanvasInteractions {
|
|||||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
|
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
|
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
|
||||||
|
|
||||||
|
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
||||||
|
|
||||||
this.canvas.canvas.addEventListener('mouseenter', (e) => {
|
this.canvas.canvas.addEventListener('mouseenter', (e) => {
|
||||||
this.canvas.isMouseOver = true;
|
this.canvas.isMouseOver = true;
|
||||||
this.handleMouseEnter(e);
|
this.handleMouseEnter(e);
|
||||||
@@ -42,6 +45,13 @@ export class CanvasInteractions {
|
|||||||
this.canvas.isMouseOver = false;
|
this.canvas.isMouseOver = false;
|
||||||
this.handleMouseLeave(e);
|
this.handleMouseLeave(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
|
||||||
|
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
|
||||||
|
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
|
||||||
|
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
|
||||||
|
|
||||||
|
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
resetInteractionState() {
|
resetInteractionState() {
|
||||||
@@ -60,74 +70,78 @@ export class CanvasInteractions {
|
|||||||
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||||
|
|
||||||
if (this.canvas.maskTool.isActive) {
|
if (this.interaction.mode === 'drawingMask') {
|
||||||
if (e.button === 1) {
|
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
|
||||||
this.startPanning(e);
|
|
||||||
} else {
|
|
||||||
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
|
|
||||||
}
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTime = Date.now();
|
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||||
|
|
||||||
|
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||||
if (e.shiftKey && e.ctrlKey) {
|
if (e.shiftKey && e.ctrlKey) {
|
||||||
this.startCanvasMove(worldCoords);
|
this.startCanvasMove(worldCoords);
|
||||||
this.canvas.render();
|
return;
|
||||||
|
}
|
||||||
|
if (e.shiftKey) {
|
||||||
|
this.startCanvasResize(worldCoords);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Inne przyciski myszy
|
||||||
|
if (e.button === 2) { // Prawy przycisk myszy
|
||||||
|
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
||||||
|
if (clickedLayerResult && this.canvas.canvasSelection.selectedLayers.includes(clickedLayerResult.layer)) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x, viewCoords.y);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.button !== 0) { // Środkowy przycisk
|
||||||
|
this.startPanning(e);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentTime - this.interaction.lastClickTime < 300) {
|
// 3. Interakcje z elementami na płótnie (lewy przycisk)
|
||||||
this.canvas.updateSelection([]);
|
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||||
this.canvas.selectedLayer = null;
|
|
||||||
this.resetInteractionState();
|
|
||||||
this.canvas.render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.interaction.lastClickTime = currentTime;
|
|
||||||
|
|
||||||
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
|
||||||
if (transformTarget) {
|
if (transformTarget) {
|
||||||
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
|
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clickedLayerResult = this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
||||||
if (clickedLayerResult) {
|
if (clickedLayerResult) {
|
||||||
if (e.shiftKey && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
|
this.prepareForDrag(clickedLayerResult.layer, worldCoords);
|
||||||
this.canvas.showBlendModeMenu(e.clientX, e.clientY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.startLayerDrag(clickedLayerResult.layer, worldCoords);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.shiftKey) {
|
|
||||||
this.startCanvasResize(worldCoords);
|
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||||
} else {
|
this.startPanningOrClearSelection(e);
|
||||||
this.startPanning(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.canvas.render();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseMove(e) {
|
handleMouseMove(e) {
|
||||||
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||||
this.canvas.lastMousePosition = worldCoords;
|
this.canvas.lastMousePosition = worldCoords; // Zawsze aktualizuj ostatnią pozycję myszy
|
||||||
|
|
||||||
if (this.canvas.maskTool.isActive) {
|
// Sprawdź, czy rozpocząć przeciąganie
|
||||||
if (this.interaction.mode === 'panning') {
|
if (this.interaction.mode === 'potential-drag') {
|
||||||
this.panViewport(e);
|
const dx = worldCoords.x - this.interaction.dragStart.x;
|
||||||
return;
|
const dy = worldCoords.y - this.interaction.dragStart.y;
|
||||||
|
if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli
|
||||||
|
this.interaction.mode = 'dragging';
|
||||||
|
this.originalLayerPositions.clear();
|
||||||
|
this.canvas.canvasSelection.selectedLayers.forEach(l => {
|
||||||
|
this.originalLayerPositions.set(l, {x: l.x, y: l.y});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
|
|
||||||
if (this.canvas.maskTool.isDrawing) {
|
|
||||||
this.canvas.render();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (this.interaction.mode) {
|
switch (this.interaction.mode) {
|
||||||
|
case 'drawingMask':
|
||||||
|
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
|
||||||
|
this.canvas.render();
|
||||||
|
break;
|
||||||
case 'panning':
|
case 'panning':
|
||||||
this.panViewport(e);
|
this.panViewport(e);
|
||||||
break;
|
break;
|
||||||
@@ -154,30 +168,30 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
handleMouseUp(e) {
|
handleMouseUp(e) {
|
||||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||||
if (this.canvas.maskTool.isActive) {
|
if (this.interaction.mode === 'drawingMask') {
|
||||||
if (this.interaction.mode === 'panning') {
|
this.canvas.maskTool.handleMouseUp(viewCoords);
|
||||||
this.resetInteractionState();
|
|
||||||
} else {
|
|
||||||
this.canvas.maskTool.handleMouseUp(viewCoords);
|
|
||||||
}
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const interactionEnded = this.interaction.mode !== 'none' && this.interaction.mode !== 'panning';
|
|
||||||
|
|
||||||
if (this.interaction.mode === 'resizingCanvas') {
|
if (this.interaction.mode === 'resizingCanvas') {
|
||||||
this.finalizeCanvasResize();
|
this.finalizeCanvasResize();
|
||||||
} else if (this.interaction.mode === 'movingCanvas') {
|
}
|
||||||
|
if (this.interaction.mode === 'movingCanvas') {
|
||||||
this.finalizeCanvasMove();
|
this.finalizeCanvasMove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
|
||||||
|
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
|
||||||
|
const duplicatedInDrag = this.interaction.hasClonedInDrag;
|
||||||
|
|
||||||
|
if (stateChangingInteraction || duplicatedInDrag) {
|
||||||
|
this.canvas.saveState();
|
||||||
|
this.canvas.canvasState.saveStateToDB(true);
|
||||||
|
}
|
||||||
|
|
||||||
this.resetInteractionState();
|
this.resetInteractionState();
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
|
|
||||||
if (interactionEnded) {
|
|
||||||
this.canvas.saveState();
|
|
||||||
this.canvas.saveStateToDB(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseLeave(e) {
|
handleMouseLeave(e) {
|
||||||
@@ -194,6 +208,11 @@ export class CanvasInteractions {
|
|||||||
this.resetInteractionState();
|
this.resetInteractionState();
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||||
|
this.canvas.canvasLayers.internalClipboard = [];
|
||||||
|
log.info("Internal clipboard cleared - mouse left canvas");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseEnter(e) {
|
handleMouseEnter(e) {
|
||||||
@@ -202,6 +221,11 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleContextMenu(e) {
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
handleWheel(e) {
|
handleWheel(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (this.canvas.maskTool.isActive) {
|
if (this.canvas.maskTool.isActive) {
|
||||||
@@ -218,10 +242,22 @@ export class CanvasInteractions {
|
|||||||
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
|
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
|
||||||
} else if (this.canvas.selectedLayer) {
|
} else if (this.canvas.selectedLayer) {
|
||||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||||
|
const direction = e.deltaY < 0 ? 1 : -1; // 1 = up/right, -1 = down/left
|
||||||
|
|
||||||
this.canvas.selectedLayers.forEach(layer => {
|
this.canvas.canvasSelection.selectedLayers.forEach(layer => {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
layer.rotation += rotationStep;
|
// Nowy skrót: Shift + Ctrl + Kółko do przyciągania do absolutnych wartości
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
const snapAngle = 5;
|
||||||
|
if (direction > 0) { // Obrót w górę/prawo
|
||||||
|
layer.rotation = Math.ceil((layer.rotation + 0.1) / snapAngle) * snapAngle;
|
||||||
|
} else { // Obrót w dół/lewo
|
||||||
|
layer.rotation = Math.floor((layer.rotation - 0.1) / snapAngle) * snapAngle;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Stara funkcjonalność: Shift + Kółko obraca o stały krok
|
||||||
|
layer.rotation += rotationStep;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const oldWidth = layer.width;
|
const oldWidth = layer.width;
|
||||||
const oldHeight = layer.height;
|
const oldHeight = layer.height;
|
||||||
@@ -280,115 +316,78 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
if (!this.canvas.maskTool.isActive) {
|
if (!this.canvas.maskTool.isActive) {
|
||||||
this.canvas.saveState(true);
|
this.canvas.requestSaveState(true); // Użyj opóźnionego zapisu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKeyDown(e) {
|
handleKeyDown(e) {
|
||||||
if (this.canvas.maskTool.isActive) {
|
|
||||||
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
|
|
||||||
if (e.key === 'Alt') {
|
|
||||||
this.interaction.isAltPressed = true;
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.ctrlKey) {
|
|
||||||
if (e.key.toLowerCase() === 'z') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (e.shiftKey) {
|
|
||||||
this.canvas.redo();
|
|
||||||
} else {
|
|
||||||
this.canvas.undo();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key.toLowerCase() === 'y') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.canvas.redo();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
|
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
|
||||||
if (e.key === 'Alt') {
|
if (e.key === 'Alt') {
|
||||||
this.interaction.isAltPressed = true;
|
this.interaction.isAltPressed = true;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.ctrlKey) {
|
// Globalne skróty (Undo/Redo/Copy/Paste)
|
||||||
if (e.key.toLowerCase() === 'z') {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault();
|
let handled = true;
|
||||||
e.stopPropagation();
|
switch (e.key.toLowerCase()) {
|
||||||
if (e.shiftKey) {
|
case 'z':
|
||||||
|
if (e.shiftKey) {
|
||||||
|
this.canvas.redo();
|
||||||
|
} else {
|
||||||
|
this.canvas.undo();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'y':
|
||||||
this.canvas.redo();
|
this.canvas.redo();
|
||||||
} else {
|
break;
|
||||||
this.canvas.undo();
|
case 'c':
|
||||||
}
|
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||||
return;
|
this.canvas.canvasLayers.copySelectedLayers();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
handled = false;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (e.key.toLowerCase() === 'y') {
|
if (handled) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.canvas.redo();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key.toLowerCase() === 'c') {
|
|
||||||
if (this.canvas.selectedLayers.length > 0) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.canvas.copySelectedLayers();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key.toLowerCase() === 'v') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.canvas.handlePaste('mouse');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.canvas.selectedLayer) {
|
// Skróty kontekstowe (zależne od zaznaczenia)
|
||||||
if (e.key === 'Delete') {
|
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.canvas.saveState();
|
|
||||||
this.canvas.layers = this.canvas.layers.filter(l => !this.canvas.selectedLayers.includes(l));
|
|
||||||
this.canvas.updateSelection([]);
|
|
||||||
this.canvas.render();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const step = e.shiftKey ? 10 : 1;
|
const step = e.shiftKey ? 10 : 1;
|
||||||
let needsRender = false;
|
let needsRender = false;
|
||||||
switch (e.code) {
|
|
||||||
case 'ArrowLeft':
|
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
||||||
case 'ArrowRight':
|
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
||||||
case 'ArrowUp':
|
if (movementKeys.includes(e.code)) {
|
||||||
case 'ArrowDown':
|
e.preventDefault();
|
||||||
case 'BracketLeft':
|
e.stopPropagation();
|
||||||
case 'BracketRight':
|
this.interaction.keyMovementInProgress = true;
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (e.code === 'ArrowLeft') this.canvas.selectedLayers.forEach(l => l.x -= step);
|
if (e.code === 'ArrowLeft') this.canvas.canvasSelection.selectedLayers.forEach(l => l.x -= step);
|
||||||
if (e.code === 'ArrowRight') this.canvas.selectedLayers.forEach(l => l.x += step);
|
if (e.code === 'ArrowRight') this.canvas.canvasSelection.selectedLayers.forEach(l => l.x += step);
|
||||||
if (e.code === 'ArrowUp') this.canvas.selectedLayers.forEach(l => l.y -= step);
|
if (e.code === 'ArrowUp') this.canvas.canvasSelection.selectedLayers.forEach(l => l.y -= step);
|
||||||
if (e.code === 'ArrowDown') this.canvas.selectedLayers.forEach(l => l.y += step);
|
if (e.code === 'ArrowDown') this.canvas.canvasSelection.selectedLayers.forEach(l => l.y += step);
|
||||||
if (e.code === 'BracketLeft') this.canvas.selectedLayers.forEach(l => l.rotation -= step);
|
if (e.code === 'BracketLeft') this.canvas.canvasSelection.selectedLayers.forEach(l => l.rotation -= step);
|
||||||
if (e.code === 'BracketRight') this.canvas.selectedLayers.forEach(l => l.rotation += step);
|
if (e.code === 'BracketRight') this.canvas.canvasSelection.selectedLayers.forEach(l => l.rotation += step);
|
||||||
|
|
||||||
needsRender = true;
|
needsRender = true;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.canvas.canvasSelection.removeSelectedLayers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (needsRender) {
|
if (needsRender) {
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.saveState();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,10 +395,16 @@ export class CanvasInteractions {
|
|||||||
handleKeyUp(e) {
|
handleKeyUp(e) {
|
||||||
if (e.key === 'Control') this.interaction.isCtrlPressed = false;
|
if (e.key === 'Control') this.interaction.isCtrlPressed = false;
|
||||||
if (e.key === 'Alt') this.interaction.isAltPressed = false;
|
if (e.key === 'Alt') this.interaction.isAltPressed = false;
|
||||||
|
|
||||||
|
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
||||||
|
if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) {
|
||||||
|
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
|
||||||
|
this.interaction.keyMovementInProgress = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCursor(worldCoords) {
|
updateCursor(worldCoords) {
|
||||||
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||||
|
|
||||||
if (transformTarget) {
|
if (transformTarget) {
|
||||||
const handleName = transformTarget.handle;
|
const handleName = transformTarget.handle;
|
||||||
@@ -409,7 +414,7 @@ export class CanvasInteractions {
|
|||||||
'rot': 'grab'
|
'rot': 'grab'
|
||||||
};
|
};
|
||||||
this.canvas.canvas.style.cursor = cursorMap[handleName];
|
this.canvas.canvas.style.cursor = cursorMap[handleName];
|
||||||
} else if (this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y)) {
|
} else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) {
|
||||||
this.canvas.canvas.style.cursor = 'move';
|
this.canvas.canvas.style.cursor = 'move';
|
||||||
} else {
|
} else {
|
||||||
this.canvas.canvas.style.cursor = 'default';
|
this.canvas.canvas.style.cursor = 'default';
|
||||||
@@ -432,7 +437,7 @@ export class CanvasInteractions {
|
|||||||
} else {
|
} else {
|
||||||
this.interaction.mode = 'resizing';
|
this.interaction.mode = 'resizing';
|
||||||
this.interaction.resizeHandle = handle;
|
this.interaction.resizeHandle = handle;
|
||||||
const handles = this.canvas.getHandles(layer);
|
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||||
const oppositeHandleKey = {
|
const oppositeHandleKey = {
|
||||||
'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
|
'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
|
||||||
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
|
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
|
||||||
@@ -442,31 +447,34 @@ export class CanvasInteractions {
|
|||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
startLayerDrag(layer, worldCoords) {
|
prepareForDrag(layer, worldCoords) {
|
||||||
this.interaction.mode = 'dragging';
|
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
|
||||||
this.interaction.dragStart = {...worldCoords};
|
|
||||||
|
|
||||||
let currentSelection = [...this.canvas.selectedLayers];
|
|
||||||
|
|
||||||
if (this.interaction.isCtrlPressed) {
|
if (this.interaction.isCtrlPressed) {
|
||||||
const index = currentSelection.indexOf(layer);
|
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
currentSelection.push(layer);
|
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||||
} else {
|
} else {
|
||||||
currentSelection.splice(index, 1);
|
const newSelection = this.canvas.canvasSelection.selectedLayers.filter(l => l !== layer);
|
||||||
|
this.canvas.canvasSelection.updateSelection(newSelection);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!currentSelection.includes(layer)) {
|
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||||
currentSelection = [layer];
|
this.canvas.canvasSelection.updateSelection([layer]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.interaction.mode = 'potential-drag';
|
||||||
|
this.interaction.dragStart = {...worldCoords};
|
||||||
|
}
|
||||||
|
|
||||||
this.canvas.updateSelection(currentSelection);
|
startPanningOrClearSelection(e) {
|
||||||
|
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
|
||||||
this.originalLayerPositions.clear();
|
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
|
||||||
this.canvas.selectedLayers.forEach(l => {
|
if (!this.interaction.isCtrlPressed) {
|
||||||
this.originalLayerPositions.set(l, {x: l.x, y: l.y});
|
this.canvas.canvasSelection.updateSelection([]);
|
||||||
});
|
}
|
||||||
|
this.interaction.mode = 'panning';
|
||||||
|
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
||||||
}
|
}
|
||||||
|
|
||||||
startCanvasResize(worldCoords) {
|
startCanvasResize(worldCoords) {
|
||||||
@@ -521,15 +529,39 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
this.canvas.maskTool.updatePosition(-finalX, -finalY);
|
this.canvas.maskTool.updatePosition(-finalX, -finalY);
|
||||||
|
|
||||||
|
// If a batch generation is in progress, update the captured context as well
|
||||||
|
if (this.canvas.pendingBatchContext) {
|
||||||
|
this.canvas.pendingBatchContext.outputArea.x -= finalX;
|
||||||
|
this.canvas.pendingBatchContext.outputArea.y -= finalY;
|
||||||
|
|
||||||
|
// Also update the menu spawn position to keep it relative
|
||||||
|
this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
|
||||||
|
this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
|
||||||
|
log.debug("Updated pending batch context during canvas move:", this.canvas.pendingBatchContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also move any active batch preview menus
|
||||||
|
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||||
|
this.canvas.batchPreviewManagers.forEach(manager => {
|
||||||
|
manager.worldX -= finalX;
|
||||||
|
manager.worldY -= finalY;
|
||||||
|
if (manager.generationArea) {
|
||||||
|
manager.generationArea.x -= finalX;
|
||||||
|
manager.generationArea.y -= finalY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
this.canvas.viewport.x -= finalX;
|
this.canvas.viewport.x -= finalX;
|
||||||
this.canvas.viewport.y -= finalY;
|
this.canvas.viewport.y -= finalY;
|
||||||
}
|
}
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
startPanning(e) {
|
startPanning(e) {
|
||||||
if (!this.interaction.isCtrlPressed) {
|
if (!this.interaction.isCtrlPressed) {
|
||||||
this.canvas.updateSelection([]);
|
this.canvas.canvasSelection.updateSelection([]);
|
||||||
}
|
}
|
||||||
this.interaction.mode = 'panning';
|
this.interaction.mode = 'panning';
|
||||||
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
||||||
@@ -545,20 +577,13 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dragLayers(worldCoords) {
|
dragLayers(worldCoords) {
|
||||||
if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.selectedLayers.length > 0) {
|
if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||||
const newLayers = [];
|
// Scentralizowana logika duplikowania
|
||||||
this.canvas.selectedLayers.forEach(layer => {
|
const newLayers = this.canvas.canvasSelection.duplicateSelectedLayers();
|
||||||
const newLayer = {
|
|
||||||
...layer,
|
// Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw
|
||||||
zIndex: this.canvas.layers.length,
|
|
||||||
};
|
|
||||||
this.canvas.layers.push(newLayer);
|
|
||||||
newLayers.push(newLayer);
|
|
||||||
});
|
|
||||||
this.canvas.updateSelection(newLayers);
|
|
||||||
this.canvas.selectedLayer = newLayers.length > 0 ? newLayers[newLayers.length - 1] : null;
|
|
||||||
this.originalLayerPositions.clear();
|
this.originalLayerPositions.clear();
|
||||||
this.canvas.selectedLayers.forEach(l => {
|
newLayers.forEach(l => {
|
||||||
this.originalLayerPositions.set(l, {x: l.x, y: l.y});
|
this.originalLayerPositions.set(l, {x: l.x, y: l.y});
|
||||||
});
|
});
|
||||||
this.interaction.hasClonedInDrag = true;
|
this.interaction.hasClonedInDrag = true;
|
||||||
@@ -567,11 +592,11 @@ export class CanvasInteractions {
|
|||||||
const totalDy = worldCoords.y - this.interaction.dragStart.y;
|
const totalDy = worldCoords.y - this.interaction.dragStart.y;
|
||||||
let finalDx = totalDx, finalDy = totalDy;
|
let finalDx = totalDx, finalDy = totalDy;
|
||||||
|
|
||||||
if (this.interaction.isCtrlPressed && this.canvas.selectedLayer) {
|
if (this.interaction.isCtrlPressed && this.canvas.canvasSelection.selectedLayer) {
|
||||||
const originalPos = this.originalLayerPositions.get(this.canvas.selectedLayer);
|
const originalPos = this.originalLayerPositions.get(this.canvas.canvasSelection.selectedLayer);
|
||||||
if (originalPos) {
|
if (originalPos) {
|
||||||
const tempLayerForSnap = {
|
const tempLayerForSnap = {
|
||||||
...this.canvas.selectedLayer,
|
...this.canvas.canvasSelection.selectedLayer,
|
||||||
x: originalPos.x + totalDx,
|
x: originalPos.x + totalDx,
|
||||||
y: originalPos.y + totalDy
|
y: originalPos.y + totalDy
|
||||||
};
|
};
|
||||||
@@ -581,7 +606,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.canvas.selectedLayers.forEach(layer => {
|
this.canvas.canvasSelection.selectedLayers.forEach(layer => {
|
||||||
const originalPos = this.originalLayerPositions.get(layer);
|
const originalPos = this.originalLayerPositions.get(layer);
|
||||||
if (originalPos) {
|
if (originalPos) {
|
||||||
layer.x = originalPos.x + finalDx;
|
layer.x = originalPos.x + finalDx;
|
||||||
@@ -696,20 +721,169 @@ export class CanvasInteractions {
|
|||||||
if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) {
|
if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) {
|
||||||
const newWidth = Math.round(this.interaction.canvasResizeRect.width);
|
const newWidth = Math.round(this.interaction.canvasResizeRect.width);
|
||||||
const newHeight = Math.round(this.interaction.canvasResizeRect.height);
|
const newHeight = Math.round(this.interaction.canvasResizeRect.height);
|
||||||
const rectX = this.interaction.canvasResizeRect.x;
|
const finalX = this.interaction.canvasResizeRect.x;
|
||||||
const rectY = this.interaction.canvasResizeRect.y;
|
const finalY = this.interaction.canvasResizeRect.y;
|
||||||
|
|
||||||
this.canvas.updateOutputAreaSize(newWidth, newHeight);
|
this.canvas.updateOutputAreaSize(newWidth, newHeight);
|
||||||
|
|
||||||
this.canvas.layers.forEach(layer => {
|
this.canvas.layers.forEach(layer => {
|
||||||
layer.x -= rectX;
|
layer.x -= finalX;
|
||||||
layer.y -= rectY;
|
layer.y -= finalY;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.canvas.maskTool.updatePosition(-rectX, -rectY);
|
this.canvas.maskTool.updatePosition(-finalX, -finalY);
|
||||||
|
|
||||||
this.canvas.viewport.x -= rectX;
|
// If a batch generation is in progress, update the captured context as well
|
||||||
this.canvas.viewport.y -= rectY;
|
if (this.canvas.pendingBatchContext) {
|
||||||
|
this.canvas.pendingBatchContext.outputArea.x -= finalX;
|
||||||
|
this.canvas.pendingBatchContext.outputArea.y -= finalY;
|
||||||
|
|
||||||
|
// Also update the menu spawn position to keep it relative
|
||||||
|
this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
|
||||||
|
this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
|
||||||
|
log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also move any active batch preview menus
|
||||||
|
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||||
|
this.canvas.batchPreviewManagers.forEach(manager => {
|
||||||
|
manager.worldX -= finalX;
|
||||||
|
manager.worldY -= finalY;
|
||||||
|
if (manager.generationArea) {
|
||||||
|
manager.generationArea.x -= finalX;
|
||||||
|
manager.generationArea.y -= finalY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.viewport.x -= finalX;
|
||||||
|
this.canvas.viewport.y -= finalY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDragEnter(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||||
|
this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)';
|
||||||
|
this.canvas.canvas.style.border = '2px dashed #2d5aa0';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDragLeave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||||
|
|
||||||
|
if (!this.canvas.canvas.contains(e.relatedTarget)) {
|
||||||
|
this.canvas.canvas.style.backgroundColor = '';
|
||||||
|
this.canvas.canvas.style.border = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow
|
||||||
|
|
||||||
|
log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading");
|
||||||
|
|
||||||
|
this.canvas.canvas.style.backgroundColor = '';
|
||||||
|
this.canvas.canvas.style.border = '';
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||||
|
|
||||||
|
log.info(`Dropped ${files.length} file(s) onto canvas at position (${worldCoords.x}, ${worldCoords.y})`);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
try {
|
||||||
|
await this.loadDroppedImageFile(file, worldCoords);
|
||||||
|
log.info(`Successfully loaded dropped image: ${file.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Failed to load dropped image ${file.name}:`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn(`Skipped non-image file: ${file.name} (${file.type})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDroppedImageFile(file, worldCoords) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
|
||||||
|
const fitOnAddWidget = this.canvas.node.widgets.find(w => w.name === "fit_on_add");
|
||||||
|
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
|
||||||
|
|
||||||
|
await this.canvas.addLayer(img, {}, addMode);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
log.error(`Failed to load dropped image: ${file.name}`);
|
||||||
|
};
|
||||||
|
img.src = e.target.result;
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
log.error(`Failed to read dropped file: ${file.name}`);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePasteEvent(e) {
|
||||||
|
|
||||||
|
const shouldHandle = this.canvas.isMouseOver ||
|
||||||
|
this.canvas.canvas.contains(document.activeElement) ||
|
||||||
|
document.activeElement === this.canvas.canvas ||
|
||||||
|
document.activeElement === document.body;
|
||||||
|
|
||||||
|
if (!shouldHandle) {
|
||||||
|
log.debug("Paste event ignored - not focused on canvas");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Paste event detected, checking clipboard preference");
|
||||||
|
|
||||||
|
const preference = this.canvas.canvasLayers.clipboardPreference;
|
||||||
|
|
||||||
|
if (preference === 'clipspace') {
|
||||||
|
|
||||||
|
log.info("Clipboard preference is clipspace, delegating to ClipboardManager");
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipboardData = e.clipboardData;
|
||||||
|
if (clipboardData && clipboardData.items) {
|
||||||
|
for (const item of clipboardData.items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
log.info("Found direct image data in paste event");
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (event) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse');
|
||||||
|
};
|
||||||
|
img.src = event.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
676
js/CanvasLayersPanel.js
Normal file
676
js/CanvasLayersPanel.js
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||||
|
|
||||||
|
const log = createModuleLogger('CanvasLayersPanel');
|
||||||
|
|
||||||
|
export class CanvasLayersPanel {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.container = null;
|
||||||
|
this.layersContainer = null;
|
||||||
|
this.draggedElements = [];
|
||||||
|
this.dragInsertionLine = null;
|
||||||
|
this.isMultiSelecting = false;
|
||||||
|
this.lastSelectedIndex = -1;
|
||||||
|
|
||||||
|
// Binding metod dla event handlerów
|
||||||
|
this.handleLayerClick = this.handleLayerClick.bind(this);
|
||||||
|
this.handleDragStart = this.handleDragStart.bind(this);
|
||||||
|
this.handleDragOver = this.handleDragOver.bind(this);
|
||||||
|
this.handleDragEnd = this.handleDragEnd.bind(this);
|
||||||
|
this.handleDrop = this.handleDrop.bind(this);
|
||||||
|
|
||||||
|
log.info('CanvasLayersPanel initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tworzy strukturê HTML panelu warstw
|
||||||
|
*/
|
||||||
|
createPanelStructure() {
|
||||||
|
// Główny kontener panelu
|
||||||
|
this.container = document.createElement('div');
|
||||||
|
this.container.className = 'layers-panel';
|
||||||
|
this.container.tabIndex = 0; // Umożliwia fokus na panelu
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="layers-panel-header">
|
||||||
|
<span class="layers-panel-title">Layers</span>
|
||||||
|
<div class="layers-panel-controls">
|
||||||
|
<button class="layers-btn" id="delete-layer-btn" title="Delete layer">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layers-container" id="layers-container">
|
||||||
|
<!-- Lista warstw będzie renderowana tutaj -->
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.layersContainer = this.container.querySelector('#layers-container');
|
||||||
|
|
||||||
|
// Dodanie stylów CSS
|
||||||
|
this.injectStyles();
|
||||||
|
|
||||||
|
// Setup event listeners dla przycisków
|
||||||
|
this.setupControlButtons();
|
||||||
|
|
||||||
|
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||||
|
this.container.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this.deleteSelectedLayers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug('Panel structure created');
|
||||||
|
return this.container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dodaje style CSS do panelu
|
||||||
|
*/
|
||||||
|
injectStyles() {
|
||||||
|
const styleId = 'layers-panel-styles';
|
||||||
|
if (document.getElementById(styleId)) {
|
||||||
|
return; // Style już istnieją
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = styleId;
|
||||||
|
style.textContent = `
|
||||||
|
.layers-panel {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #3a3a3a;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-title {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-panel-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn {
|
||||||
|
background: #3a3a3a;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:hover {
|
||||||
|
background: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-btn:active {
|
||||||
|
background: #5a5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row.selected {
|
||||||
|
background: #2d5aa0 !important;
|
||||||
|
box-shadow: inset 0 0 0 1px #4a7bc8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-row.dragging {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.layer-thumbnail {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(45deg, #555 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #555 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #555 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #555 75%);
|
||||||
|
background-size: 8px 8px;
|
||||||
|
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-thumbnail canvas {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name.editing {
|
||||||
|
background: #4a4a4a;
|
||||||
|
border: 1px solid #6a6a6a;
|
||||||
|
outline: none;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-name input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-insertion-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: #4a7bc8;
|
||||||
|
border-radius: 1px;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-track {
|
||||||
|
background: #2a2a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-thumb {
|
||||||
|
background: #4a4a4a;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #5a5a5a;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.appendChild(style);
|
||||||
|
log.debug('Styles injected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konfiguruje event listenery dla przycisków kontrolnych
|
||||||
|
*/
|
||||||
|
setupControlButtons() {
|
||||||
|
const deleteBtn = this.container.querySelector('#delete-layer-btn');
|
||||||
|
|
||||||
|
deleteBtn?.addEventListener('click', () => {
|
||||||
|
log.info('Delete layer button clicked');
|
||||||
|
this.deleteSelectedLayers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderuje listę warstw
|
||||||
|
*/
|
||||||
|
renderLayers() {
|
||||||
|
if (!this.layersContainer) {
|
||||||
|
log.warn('Layers container not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wyczyść istniejącą zawartość
|
||||||
|
this.layersContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// Usuń linię wstawiania jeśli istnieje
|
||||||
|
this.removeDragInsertionLine();
|
||||||
|
|
||||||
|
// Sortuj warstwy według zIndex (od najwyższej do najniższej)
|
||||||
|
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||||
|
|
||||||
|
sortedLayers.forEach((layer, index) => {
|
||||||
|
const layerElement = this.createLayerElement(layer, index);
|
||||||
|
this.layersContainer.appendChild(layerElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug(`Rendered ${sortedLayers.length} layers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tworzy element HTML dla pojedynczej warstwy
|
||||||
|
*/
|
||||||
|
createLayerElement(layer, index) {
|
||||||
|
const layerRow = document.createElement('div');
|
||||||
|
layerRow.className = 'layer-row';
|
||||||
|
layerRow.draggable = true;
|
||||||
|
layerRow.dataset.layerIndex = index;
|
||||||
|
|
||||||
|
// Sprawdź czy warstwa jest zaznaczona
|
||||||
|
const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
|
||||||
|
if (isSelected) {
|
||||||
|
layerRow.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ustawienie domyślnych właściwości jeśli nie istnieją
|
||||||
|
if (!layer.name) {
|
||||||
|
layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer);
|
||||||
|
} else {
|
||||||
|
// Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu)
|
||||||
|
layer.name = this.ensureUniqueName(layer.name, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
layerRow.innerHTML = `
|
||||||
|
<div class="layer-thumbnail" data-layer-index="${index}"></div>
|
||||||
|
<span class="layer-name" data-layer-index="${index}">${layer.name}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Wygeneruj miniaturkę
|
||||||
|
this.generateThumbnail(layer, layerRow.querySelector('.layer-thumbnail'));
|
||||||
|
|
||||||
|
// Event listenery
|
||||||
|
this.setupLayerEventListeners(layerRow, layer, index);
|
||||||
|
|
||||||
|
return layerRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generuje miniaturkę warstwy
|
||||||
|
*/
|
||||||
|
generateThumbnail(layer, thumbnailContainer) {
|
||||||
|
if (!layer.image) {
|
||||||
|
thumbnailContainer.style.background = '#4a4a4a';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
canvas.width = 48;
|
||||||
|
canvas.height = 48;
|
||||||
|
|
||||||
|
// Oblicz skalę zachowując proporcje
|
||||||
|
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
|
||||||
|
const scaledWidth = layer.image.width * scale;
|
||||||
|
const scaledHeight = layer.image.height * scale;
|
||||||
|
|
||||||
|
// Wycentruj obraz
|
||||||
|
const x = (48 - scaledWidth) / 2;
|
||||||
|
const y = (48 - scaledHeight) / 2;
|
||||||
|
|
||||||
|
// Narysuj obraz z wyższą jakością
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight);
|
||||||
|
|
||||||
|
thumbnailContainer.appendChild(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konfiguruje event listenery dla elementu warstwy
|
||||||
|
*/
|
||||||
|
setupLayerEventListeners(layerRow, layer, index) {
|
||||||
|
// Mousedown handler - zaznaczanie w momencie wciśnięcia przycisku
|
||||||
|
layerRow.addEventListener('mousedown', (e) => {
|
||||||
|
// Ignoruj, jeśli edytujemy nazwę
|
||||||
|
const nameElement = layerRow.querySelector('.layer-name');
|
||||||
|
if (nameElement && nameElement.classList.contains('editing')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.handleLayerClick(e, layer, index);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Double click handler - edycja nazwy
|
||||||
|
layerRow.addEventListener('dblclick', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const nameElement = layerRow.querySelector('.layer-name');
|
||||||
|
this.startEditingLayerName(nameElement, layer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag handlers
|
||||||
|
layerRow.addEventListener('dragstart', (e) => this.handleDragStart(e, layer, index));
|
||||||
|
layerRow.addEventListener('dragover', this.handleDragOver);
|
||||||
|
layerRow.addEventListener('dragend', this.handleDragEnd);
|
||||||
|
layerRow.addEventListener('drop', (e) => this.handleDrop(e, index));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obsługuje kliknięcie na warstwę, aktualizując stan bez pełnego renderowania.
|
||||||
|
*/
|
||||||
|
handleLayerClick(e, layer, index) {
|
||||||
|
const isCtrlPressed = e.ctrlKey || e.metaKey;
|
||||||
|
const isShiftPressed = e.shiftKey;
|
||||||
|
|
||||||
|
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
|
||||||
|
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
|
||||||
|
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||||
|
|
||||||
|
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||||
|
this.updateSelectionAppearance();
|
||||||
|
|
||||||
|
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozpoczyna edycję nazwy warstwy
|
||||||
|
*/
|
||||||
|
startEditingLayerName(nameElement, layer) {
|
||||||
|
const currentName = layer.name;
|
||||||
|
nameElement.classList.add('editing');
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.value = currentName;
|
||||||
|
input.style.width = '100%';
|
||||||
|
|
||||||
|
nameElement.innerHTML = '';
|
||||||
|
nameElement.appendChild(input);
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
const finishEditing = () => {
|
||||||
|
let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`;
|
||||||
|
newName = this.ensureUniqueName(newName, layer);
|
||||||
|
layer.name = newName;
|
||||||
|
nameElement.classList.remove('editing');
|
||||||
|
nameElement.textContent = newName;
|
||||||
|
|
||||||
|
this.canvas.saveState();
|
||||||
|
log.info(`Layer renamed to: ${newName}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener('blur', finishEditing);
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
finishEditing();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
nameElement.classList.remove('editing');
|
||||||
|
nameElement.textContent = currentName;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapewnia unikalność nazwy warstwy
|
||||||
|
*/
|
||||||
|
ensureUniqueName(proposedName, currentLayer) {
|
||||||
|
const existingNames = this.canvas.layers
|
||||||
|
.filter(layer => layer !== currentLayer)
|
||||||
|
.map(layer => layer.name);
|
||||||
|
|
||||||
|
if (!existingNames.includes(proposedName)) {
|
||||||
|
return proposedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sprawdź czy nazwa już ma numerację w nawiasach
|
||||||
|
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
|
||||||
|
let baseName, startNumber;
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
baseName = match[1].trim();
|
||||||
|
startNumber = parseInt(match[2]) + 1;
|
||||||
|
} else {
|
||||||
|
baseName = proposedName;
|
||||||
|
startNumber = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Znajdź pierwszą dostępną numerację
|
||||||
|
let counter = startNumber;
|
||||||
|
let uniqueName;
|
||||||
|
|
||||||
|
do {
|
||||||
|
uniqueName = `${baseName} (${counter})`;
|
||||||
|
counter++;
|
||||||
|
} while (existingNames.includes(uniqueName));
|
||||||
|
|
||||||
|
return uniqueName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usuwa zaznaczone warstwy
|
||||||
|
*/
|
||||||
|
deleteSelectedLayers() {
|
||||||
|
if (this.canvas.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rozpoczyna przeciąganie warstwy
|
||||||
|
*/
|
||||||
|
handleDragStart(e, layer, index) {
|
||||||
|
// Sprawdź czy jakakolwiek warstwa jest w trybie edycji
|
||||||
|
const editingElement = this.layersContainer.querySelector('.layer-name.editing');
|
||||||
|
if (editingElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją
|
||||||
|
if (!this.canvas.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', ''); // Wymagane przez standard
|
||||||
|
|
||||||
|
// Dodaj klasę dragging do przeciąganych elementów
|
||||||
|
this.layersContainer.querySelectorAll('.layer-row').forEach((row, idx) => {
|
||||||
|
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||||
|
if (this.draggedElements.includes(sortedLayers[idx])) {
|
||||||
|
row.classList.add('dragging');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug(`Started dragging ${this.draggedElements.length} layers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obsługuje przeciąganie nad warstwą
|
||||||
|
*/
|
||||||
|
handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
|
||||||
|
const layerRow = e.currentTarget;
|
||||||
|
const rect = layerRow.getBoundingClientRect();
|
||||||
|
const midpoint = rect.top + rect.height / 2;
|
||||||
|
const isUpperHalf = e.clientY < midpoint;
|
||||||
|
|
||||||
|
this.showDragInsertionLine(layerRow, isUpperHalf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pokazuje linię wskaźnika wstawiania
|
||||||
|
*/
|
||||||
|
showDragInsertionLine(targetRow, isUpperHalf) {
|
||||||
|
this.removeDragInsertionLine();
|
||||||
|
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'drag-insertion-line';
|
||||||
|
|
||||||
|
if (isUpperHalf) {
|
||||||
|
line.style.top = '-1px';
|
||||||
|
} else {
|
||||||
|
line.style.bottom = '-1px';
|
||||||
|
}
|
||||||
|
|
||||||
|
targetRow.style.position = 'relative';
|
||||||
|
targetRow.appendChild(line);
|
||||||
|
this.dragInsertionLine = line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usuwa linię wskaźnika wstawiania
|
||||||
|
*/
|
||||||
|
removeDragInsertionLine() {
|
||||||
|
if (this.dragInsertionLine) {
|
||||||
|
this.dragInsertionLine.remove();
|
||||||
|
this.dragInsertionLine = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obsługuje upuszczenie warstwy
|
||||||
|
*/
|
||||||
|
handleDrop(e, targetIndex) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.removeDragInsertionLine();
|
||||||
|
|
||||||
|
if (this.draggedElements.length === 0) return;
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const midpoint = rect.top + rect.height / 2;
|
||||||
|
const isUpperHalf = e.clientY < midpoint;
|
||||||
|
|
||||||
|
// Oblicz docelowy indeks
|
||||||
|
let insertIndex = targetIndex;
|
||||||
|
if (!isUpperHalf) {
|
||||||
|
insertIndex = targetIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Użyj nowej, centralnej funkcji do przesuwania warstw
|
||||||
|
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
|
||||||
|
|
||||||
|
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kończy przeciąganie
|
||||||
|
*/
|
||||||
|
handleDragEnd(e) {
|
||||||
|
this.removeDragInsertionLine();
|
||||||
|
|
||||||
|
// Usuń klasę dragging ze wszystkich elementów
|
||||||
|
this.layersContainer.querySelectorAll('.layer-row').forEach(row => {
|
||||||
|
row.classList.remove('dragging');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.draggedElements = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualizuje panel gdy zmienią się warstwy
|
||||||
|
*/
|
||||||
|
onLayersChanged() {
|
||||||
|
this.renderLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualizuje wygląd zaznaczenia w panelu bez pełnego renderowania.
|
||||||
|
*/
|
||||||
|
updateSelectionAppearance() {
|
||||||
|
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||||
|
const layerRows = this.layersContainer.querySelectorAll('.layer-row');
|
||||||
|
|
||||||
|
layerRows.forEach((row, index) => {
|
||||||
|
const layer = sortedLayers[index];
|
||||||
|
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||||
|
row.classList.add('selected');
|
||||||
|
} else {
|
||||||
|
row.classList.remove('selected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualizuje panel gdy zmienią się warstwy (np. dodanie, usunięcie, zmiana kolejności)
|
||||||
|
* To jest jedyne miejsce, gdzie powinniśmy w pełni renderować panel.
|
||||||
|
*/
|
||||||
|
onLayersChanged() {
|
||||||
|
this.renderLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
||||||
|
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
||||||
|
*/
|
||||||
|
onSelectionChanged() {
|
||||||
|
this.updateSelectionAppearance();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Niszczy panel i czyści event listenery
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
if (this.container && this.container.parentNode) {
|
||||||
|
this.container.parentNode.removeChild(this.container);
|
||||||
|
}
|
||||||
|
this.container = null;
|
||||||
|
this.layersContainer = null;
|
||||||
|
this.draggedElements = [];
|
||||||
|
this.removeDragInsertionLine();
|
||||||
|
|
||||||
|
log.info('CanvasLayersPanel destroyed');
|
||||||
|
}
|
||||||
|
}
|
||||||
542
js/CanvasMask.js
Normal file
542
js/CanvasMask.js
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
import { app, ComfyApp } from "../../scripts/app.js";
|
||||||
|
import { api } from "../../scripts/api.js";
|
||||||
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
|
import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js";
|
||||||
|
|
||||||
|
const log = createModuleLogger('CanvasMask');
|
||||||
|
|
||||||
|
export class CanvasMask {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.node = canvas.node;
|
||||||
|
this.maskTool = canvas.maskTool;
|
||||||
|
|
||||||
|
this.savedMaskState = null;
|
||||||
|
this.maskEditorCancelled = false;
|
||||||
|
this.pendingMask = null;
|
||||||
|
this.editorWasShowing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uruchamia edytor masek
|
||||||
|
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora
|
||||||
|
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
|
||||||
|
*/
|
||||||
|
async startMaskEditor(predefinedMask = null, sendCleanImage = true) {
|
||||||
|
log.info('Starting mask editor', {
|
||||||
|
hasPredefinedMask: !!predefinedMask,
|
||||||
|
sendCleanImage,
|
||||||
|
layersCount: this.canvas.layers.length
|
||||||
|
});
|
||||||
|
|
||||||
|
this.savedMaskState = await this.saveMaskState();
|
||||||
|
this.maskEditorCancelled = false;
|
||||||
|
|
||||||
|
if (!predefinedMask && this.maskTool && this.maskTool.maskCanvas) {
|
||||||
|
try {
|
||||||
|
log.debug('Creating mask from current mask tool');
|
||||||
|
predefinedMask = await this.createMaskFromCurrentMask();
|
||||||
|
log.debug('Mask created from current mask tool successfully');
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("Could not create mask from current mask:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingMask = predefinedMask;
|
||||||
|
|
||||||
|
let blob;
|
||||||
|
if (sendCleanImage) {
|
||||||
|
log.debug('Getting flattened canvas as blob (clean image)');
|
||||||
|
blob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||||
|
} else {
|
||||||
|
log.debug('Getting flattened canvas for mask editor (with mask)');
|
||||||
|
blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blob) {
|
||||||
|
log.warn("Canvas is empty, cannot open mask editor.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('Canvas blob created successfully, size:', blob.size);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
const filename = `layerforge-mask-edit-${+new Date()}.png`;
|
||||||
|
formData.append("image", blob, filename);
|
||||||
|
formData.append("overwrite", "true");
|
||||||
|
formData.append("type", "temp");
|
||||||
|
|
||||||
|
log.debug('Uploading image to server:', filename);
|
||||||
|
|
||||||
|
const response = await api.fetchApi("/upload/image", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to upload image: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
log.debug('Image uploaded successfully:', data);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
|
||||||
|
await new Promise((res, rej) => {
|
||||||
|
img.onload = res;
|
||||||
|
img.onerror = rej;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.node.imgs = [img];
|
||||||
|
|
||||||
|
log.info('Opening ComfyUI mask editor');
|
||||||
|
ComfyApp.copyToClipspace(this.node);
|
||||||
|
ComfyApp.clipspace_return_node = this.node;
|
||||||
|
ComfyApp.open_maskeditor();
|
||||||
|
|
||||||
|
this.editorWasShowing = false;
|
||||||
|
this.waitWhileMaskEditing();
|
||||||
|
|
||||||
|
this.setupCancelListener();
|
||||||
|
|
||||||
|
if (predefinedMask) {
|
||||||
|
log.debug('Will apply predefined mask when editor is ready');
|
||||||
|
this.waitForMaskEditorAndApplyMask();
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Error preparing image for mask editor:", error);
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Czeka na otwarcie mask editora i automatycznie nakłada predefiniowaną maskę
|
||||||
|
*/
|
||||||
|
waitForMaskEditorAndApplyMask() {
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 100; // Zwiększone do 10 sekund oczekiwania
|
||||||
|
|
||||||
|
const checkEditor = () => {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
if (mask_editor_showing(app)) {
|
||||||
|
|
||||||
|
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
|
||||||
|
let editorReady = false;
|
||||||
|
|
||||||
|
if (useNewEditor) {
|
||||||
|
|
||||||
|
const MaskEditorDialog = window.MaskEditorDialog;
|
||||||
|
if (MaskEditorDialog && MaskEditorDialog.instance) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messageBroker = MaskEditorDialog.instance.getMessageBroker();
|
||||||
|
if (messageBroker) {
|
||||||
|
editorReady = true;
|
||||||
|
log.info("New mask editor detected as ready via MessageBroker");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
editorReady = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editorReady) {
|
||||||
|
const maskEditorElement = document.getElementById('maskEditor');
|
||||||
|
if (maskEditorElement && maskEditorElement.style.display !== 'none') {
|
||||||
|
|
||||||
|
const canvas = maskEditorElement.querySelector('canvas');
|
||||||
|
if (canvas) {
|
||||||
|
editorReady = true;
|
||||||
|
log.info("New mask editor detected as ready via DOM element");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
const maskCanvas = document.getElementById('maskCanvas');
|
||||||
|
editorReady = maskCanvas && maskCanvas.getContext && maskCanvas.width > 0;
|
||||||
|
if (editorReady) {
|
||||||
|
log.info("Old mask editor detected as ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editorReady) {
|
||||||
|
|
||||||
|
log.info("Applying mask to editor after", attempts * 100, "ms wait");
|
||||||
|
setTimeout(() => {
|
||||||
|
this.applyMaskToEditor(this.pendingMask);
|
||||||
|
this.pendingMask = null;
|
||||||
|
}, 300);
|
||||||
|
} else if (attempts < maxAttempts) {
|
||||||
|
|
||||||
|
if (attempts % 10 === 0) {
|
||||||
|
log.info("Waiting for mask editor to be ready... attempt", attempts, "/", maxAttempts);
|
||||||
|
}
|
||||||
|
setTimeout(checkEditor, 100);
|
||||||
|
} else {
|
||||||
|
log.warn("Mask editor timeout - editor not ready after", maxAttempts * 100, "ms");
|
||||||
|
|
||||||
|
log.info("Attempting to apply mask anyway...");
|
||||||
|
setTimeout(() => {
|
||||||
|
this.applyMaskToEditor(this.pendingMask);
|
||||||
|
this.pendingMask = null;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
} else if (attempts < maxAttempts) {
|
||||||
|
|
||||||
|
setTimeout(checkEditor, 100);
|
||||||
|
} else {
|
||||||
|
log.warn("Mask editor timeout - editor not showing after", maxAttempts * 100, "ms");
|
||||||
|
this.pendingMask = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nakłada maskę na otwarty mask editor
|
||||||
|
* @param {Image|HTMLCanvasElement} maskData - Dane maski do nałożenia
|
||||||
|
*/
|
||||||
|
async applyMaskToEditor(maskData) {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
|
||||||
|
|
||||||
|
if (useNewEditor) {
|
||||||
|
|
||||||
|
const MaskEditorDialog = window.MaskEditorDialog;
|
||||||
|
if (MaskEditorDialog && MaskEditorDialog.instance) {
|
||||||
|
|
||||||
|
await this.applyMaskToNewEditor(maskData);
|
||||||
|
} else {
|
||||||
|
log.warn("New editor setting enabled but instance not found, trying old editor");
|
||||||
|
await this.applyMaskToOldEditor(maskData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
await this.applyMaskToOldEditor(maskData);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Predefined mask applied to mask editor successfully");
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to apply predefined mask to editor:", error);
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("Trying alternative mask application method...");
|
||||||
|
await this.applyMaskToOldEditor(maskData);
|
||||||
|
log.info("Alternative method succeeded");
|
||||||
|
} catch (fallbackError) {
|
||||||
|
log.error("Alternative method also failed:", fallbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nakłada maskę na nowy mask editor (przez MessageBroker)
|
||||||
|
* @param {Image|HTMLCanvasElement} maskData - Dane maski
|
||||||
|
*/
|
||||||
|
async applyMaskToNewEditor(maskData) {
|
||||||
|
|
||||||
|
const MaskEditorDialog = window.MaskEditorDialog;
|
||||||
|
if (!MaskEditorDialog || !MaskEditorDialog.instance) {
|
||||||
|
throw new Error("New mask editor instance not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = MaskEditorDialog.instance;
|
||||||
|
const messageBroker = editor.getMessageBroker();
|
||||||
|
|
||||||
|
const maskCanvas = await messageBroker.pull('maskCanvas');
|
||||||
|
const maskCtx = await messageBroker.pull('maskCtx');
|
||||||
|
const maskColor = await messageBroker.pull('getMaskColor');
|
||||||
|
|
||||||
|
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
|
||||||
|
|
||||||
|
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||||
|
maskCtx.drawImage(processedMask, 0, 0);
|
||||||
|
|
||||||
|
messageBroker.publish('saveState');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nakłada maskę na stary mask editor
|
||||||
|
* @param {Image|HTMLCanvasElement} maskData - Dane maski
|
||||||
|
*/
|
||||||
|
async applyMaskToOldEditor(maskData) {
|
||||||
|
|
||||||
|
const maskCanvas = document.getElementById('maskCanvas');
|
||||||
|
if (!maskCanvas) {
|
||||||
|
throw new Error("Old mask editor canvas not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const maskCtx = maskCanvas.getContext('2d', {willReadFrequently: true});
|
||||||
|
|
||||||
|
const maskColor = {r: 255, g: 255, b: 255};
|
||||||
|
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
|
||||||
|
|
||||||
|
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||||
|
maskCtx.drawImage(processedMask, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przetwarza maskę do odpowiedniego formatu dla editora
|
||||||
|
* @param {Image|HTMLCanvasElement} maskData - Oryginalne dane maski
|
||||||
|
* @param {number} targetWidth - Docelowa szerokość
|
||||||
|
* @param {number} targetHeight - Docelowa wysokość
|
||||||
|
* @param {Object} maskColor - Kolor maski {r, g, b}
|
||||||
|
* @returns {HTMLCanvasElement} Przetworzona maska
|
||||||
|
*/async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) {
|
||||||
|
// Współrzędne przesunięcia (pan) widoku edytora
|
||||||
|
const panX = this.maskTool.x;
|
||||||
|
const panY = this.maskTool.y;
|
||||||
|
|
||||||
|
log.info("Processing mask for editor:", {
|
||||||
|
sourceSize: {width: maskData.width, height: maskData.height},
|
||||||
|
targetSize: {width: targetWidth, height: targetHeight},
|
||||||
|
viewportPan: {x: panX, y: panY}
|
||||||
|
});
|
||||||
|
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
tempCanvas.width = targetWidth;
|
||||||
|
tempCanvas.height = targetHeight;
|
||||||
|
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
|
||||||
|
|
||||||
|
const sourceX = -panX;
|
||||||
|
const sourceY = -panY;
|
||||||
|
|
||||||
|
tempCtx.drawImage(
|
||||||
|
maskData, // Źródło: pełna maska z "output area"
|
||||||
|
sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000)
|
||||||
|
sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000)
|
||||||
|
targetWidth, // sWidth: Szerokość wycinanego fragmentu
|
||||||
|
targetHeight, // sHeight: Wysokość wycinanego fragmentu
|
||||||
|
0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0)
|
||||||
|
0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0)
|
||||||
|
targetWidth, // dWidth: Szerokość wklejanego obrazu
|
||||||
|
targetHeight // dHeight: Wysokość wklejanego obrazu
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("Mask viewport cropped correctly.", {
|
||||||
|
source: "maskData",
|
||||||
|
cropArea: {x: sourceX, y: sourceY, width: targetWidth, height: targetHeight}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reszta kodu (zmiana koloru) pozostaje bez zmian
|
||||||
|
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
|
||||||
|
const data = imageData.data;
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const alpha = data[i + 3];
|
||||||
|
if (alpha > 0) {
|
||||||
|
data[i] = maskColor.r;
|
||||||
|
data[i + 1] = maskColor.g;
|
||||||
|
data[i + 2] = maskColor.b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tempCtx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
|
log.info("Mask processing completed - color applied.");
|
||||||
|
return tempCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tworzy obiekt Image z obecnej maski canvas
|
||||||
|
* @returns {Promise<Image>} Promise zwracający obiekt Image z maską
|
||||||
|
*/
|
||||||
|
async createMaskFromCurrentMask() {
|
||||||
|
if (!this.maskTool || !this.maskTool.maskCanvas) {
|
||||||
|
throw new Error("No mask canvas available");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const maskImage = new Image();
|
||||||
|
maskImage.onload = () => resolve(maskImage);
|
||||||
|
maskImage.onerror = reject;
|
||||||
|
maskImage.src = this.maskTool.maskCanvas.toDataURL();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
waitWhileMaskEditing() {
|
||||||
|
if (mask_editor_showing(app)) {
|
||||||
|
this.editorWasShowing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mask_editor_showing(app) && this.editorWasShowing) {
|
||||||
|
this.editorWasShowing = false;
|
||||||
|
setTimeout(() => this.handleMaskEditorClose(), 100);
|
||||||
|
} else {
|
||||||
|
setTimeout(this.waitWhileMaskEditing.bind(this), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zapisuje obecny stan maski przed otwarciem editora
|
||||||
|
* @returns {Object} Zapisany stan maski
|
||||||
|
*/
|
||||||
|
async saveMaskState() {
|
||||||
|
if (!this.maskTool || !this.maskTool.maskCanvas) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maskCanvas = this.maskTool.maskCanvas;
|
||||||
|
const savedCanvas = document.createElement('canvas');
|
||||||
|
savedCanvas.width = maskCanvas.width;
|
||||||
|
savedCanvas.height = maskCanvas.height;
|
||||||
|
const savedCtx = savedCanvas.getContext('2d', {willReadFrequently: true});
|
||||||
|
savedCtx.drawImage(maskCanvas, 0, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
maskData: savedCanvas,
|
||||||
|
maskPosition: {
|
||||||
|
x: this.maskTool.x,
|
||||||
|
y: this.maskTool.y
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Przywraca zapisany stan maski
|
||||||
|
* @param {Object} savedState - Zapisany stan maski
|
||||||
|
*/
|
||||||
|
async restoreMaskState(savedState) {
|
||||||
|
if (!savedState || !this.maskTool) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedState.maskData) {
|
||||||
|
const maskCtx = this.maskTool.maskCtx;
|
||||||
|
maskCtx.clearRect(0, 0, this.maskTool.maskCanvas.width, this.maskTool.maskCanvas.height);
|
||||||
|
maskCtx.drawImage(savedState.maskData, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedState.maskPosition) {
|
||||||
|
this.maskTool.x = savedState.maskPosition.x;
|
||||||
|
this.maskTool.y = savedState.maskPosition.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.render();
|
||||||
|
log.info("Mask state restored after cancel");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konfiguruje nasłuchiwanie na przycisk Cancel w mask editorze
|
||||||
|
*/
|
||||||
|
setupCancelListener() {
|
||||||
|
mask_editor_listen_for_cancel(app, () => {
|
||||||
|
log.info("Mask editor cancel button clicked");
|
||||||
|
this.maskEditorCancelled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprawdza czy mask editor został anulowany i obsługuje to odpowiednio
|
||||||
|
*/
|
||||||
|
async handleMaskEditorClose() {
|
||||||
|
log.info("Handling mask editor close");
|
||||||
|
log.debug("Node object after mask editor close:", this.node);
|
||||||
|
|
||||||
|
if (this.maskEditorCancelled) {
|
||||||
|
log.info("Mask editor was cancelled - restoring original mask state");
|
||||||
|
|
||||||
|
if (this.savedMaskState) {
|
||||||
|
await this.restoreMaskState(this.savedMaskState);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.maskEditorCancelled = false;
|
||||||
|
this.savedMaskState = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.node.imgs || !this.node.imgs.length === 0 || !this.node.imgs[0].src) {
|
||||||
|
log.warn("Mask editor was closed without a result.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Processing mask editor result, image source:", this.node.imgs[0].src.substring(0, 100) + '...');
|
||||||
|
|
||||||
|
const resultImage = new Image();
|
||||||
|
resultImage.src = this.node.imgs[0].src;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
resultImage.onload = resolve;
|
||||||
|
resultImage.onerror = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug("Result image loaded successfully", {
|
||||||
|
width: resultImage.width,
|
||||||
|
height: resultImage.height
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to load image from mask editor.", error);
|
||||||
|
this.node.imgs = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Creating temporary canvas for mask processing");
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
tempCanvas.width = this.canvas.width;
|
||||||
|
tempCanvas.height = this.canvas.height;
|
||||||
|
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
|
||||||
|
|
||||||
|
tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
log.debug("Processing image data to create mask");
|
||||||
|
const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
const data = imageData.data;
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const originalAlpha = data[i + 3];
|
||||||
|
data[i] = 255;
|
||||||
|
data[i + 1] = 255;
|
||||||
|
data[i + 2] = 255;
|
||||||
|
data[i + 3] = 255 - originalAlpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
tempCtx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
|
log.debug("Converting processed mask to image");
|
||||||
|
const maskAsImage = new Image();
|
||||||
|
maskAsImage.src = tempCanvas.toDataURL();
|
||||||
|
await new Promise(resolve => maskAsImage.onload = resolve);
|
||||||
|
|
||||||
|
const maskCtx = this.maskTool.maskCtx;
|
||||||
|
const destX = -this.maskTool.x;
|
||||||
|
const destY = -this.maskTool.y;
|
||||||
|
|
||||||
|
log.debug("Applying mask to canvas", {destX, destY});
|
||||||
|
|
||||||
|
maskCtx.globalCompositeOperation = 'source-over';
|
||||||
|
maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
maskCtx.drawImage(maskAsImage, destX, destY);
|
||||||
|
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
|
||||||
|
log.debug("Creating new preview image");
|
||||||
|
const new_preview = new Image();
|
||||||
|
|
||||||
|
const blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
if (blob) {
|
||||||
|
new_preview.src = URL.createObjectURL(blob);
|
||||||
|
await new Promise(r => new_preview.onload = r);
|
||||||
|
this.node.imgs = [new_preview];
|
||||||
|
log.debug("New preview image created successfully");
|
||||||
|
} else {
|
||||||
|
this.node.imgs = [];
|
||||||
|
log.warn("Failed to create preview blob");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas.render();
|
||||||
|
|
||||||
|
this.savedMaskState = null;
|
||||||
|
log.info("Mask editor result processed successfully");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,15 +75,16 @@ export class CanvasRenderer {
|
|||||||
);
|
);
|
||||||
if (layer.mask) {
|
if (layer.mask) {
|
||||||
}
|
}
|
||||||
if (this.canvas.selectedLayers.includes(layer)) {
|
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||||
this.drawSelectionFrame(ctx, layer);
|
this.drawSelectionFrame(ctx, layer);
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.drawCanvasOutline(ctx);
|
this.drawCanvasOutline(ctx);
|
||||||
|
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
||||||
const maskImage = this.canvas.maskTool.getMask();
|
const maskImage = this.canvas.maskTool.getMask();
|
||||||
if (maskImage) {
|
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||||
|
|
||||||
ctx.save();
|
ctx.save();
|
||||||
|
|
||||||
@@ -112,6 +113,13 @@ export class CanvasRenderer {
|
|||||||
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
||||||
}
|
}
|
||||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
||||||
|
|
||||||
|
// Update Batch Preview UI positions
|
||||||
|
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||||
|
this.canvas.batchPreviewManagers.forEach(manager => {
|
||||||
|
manager.updateScreenPosition(this.canvas.viewport);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderInteractionElements(ctx) {
|
renderInteractionElements(ctx) {
|
||||||
@@ -182,8 +190,8 @@ export class CanvasRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderLayerInfo(ctx) {
|
renderLayerInfo(ctx) {
|
||||||
if (this.canvas.selectedLayer) {
|
if (this.canvas.canvasSelection.selectedLayer) {
|
||||||
this.canvas.selectedLayers.forEach(layer => {
|
this.canvas.canvasSelection.selectedLayers.forEach(layer => {
|
||||||
if (!layer.image) return;
|
if (!layer.image) return;
|
||||||
|
|
||||||
const layerIndex = this.canvas.layers.indexOf(layer);
|
const layerIndex = this.canvas.layers.indexOf(layer);
|
||||||
@@ -301,7 +309,7 @@ export class CanvasRenderer {
|
|||||||
ctx.moveTo(0, -layer.height / 2);
|
ctx.moveTo(0, -layer.height / 2);
|
||||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
const handles = this.canvas.getHandles(layer);
|
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.strokeStyle = '#000000';
|
ctx.strokeStyle = '#000000';
|
||||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||||
@@ -321,4 +329,36 @@ export class CanvasRenderer {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
drawPendingGenerationAreas(ctx) {
|
||||||
|
const areasToDraw = [];
|
||||||
|
|
||||||
|
// 1. Get areas from active managers
|
||||||
|
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||||
|
this.canvas.batchPreviewManagers.forEach(manager => {
|
||||||
|
if (manager.generationArea) {
|
||||||
|
areasToDraw.push(manager.generationArea);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get the area from the pending context (if it exists)
|
||||||
|
if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) {
|
||||||
|
areasToDraw.push(this.canvas.pendingBatchContext.outputArea);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (areasToDraw.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Draw all collected areas
|
||||||
|
areasToDraw.forEach(area => {
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = 'rgba(0, 150, 255, 0.9)'; // Blue color
|
||||||
|
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
|
||||||
|
ctx.setLineDash([12 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]);
|
||||||
|
ctx.strokeRect(area.x, area.y, area.width, area.height);
|
||||||
|
ctx.restore();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
166
js/CanvasSelection.js
Normal file
166
js/CanvasSelection.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||||
|
|
||||||
|
const log = createModuleLogger('CanvasSelection');
|
||||||
|
|
||||||
|
export class CanvasSelection {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.selectedLayers = [];
|
||||||
|
this.selectedLayer = null;
|
||||||
|
this.onSelectionChange = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
|
||||||
|
*/
|
||||||
|
duplicateSelectedLayers() {
|
||||||
|
if (this.selectedLayers.length === 0) return [];
|
||||||
|
|
||||||
|
const newLayers = [];
|
||||||
|
const sortedLayers = [...this.selectedLayers].sort((a,b) => a.zIndex - b.zIndex);
|
||||||
|
|
||||||
|
sortedLayers.forEach(layer => {
|
||||||
|
const newLayer = {
|
||||||
|
...layer,
|
||||||
|
id: `layer_${+new Date()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu
|
||||||
|
};
|
||||||
|
this.canvas.layers.push(newLayer);
|
||||||
|
newLayers.push(newLayer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego)
|
||||||
|
this.updateSelection(newLayers);
|
||||||
|
|
||||||
|
// Powiadom panel o zmianie struktury, aby się przerysował
|
||||||
|
if (this.canvas.canvasLayersPanel) {
|
||||||
|
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Duplicated ${newLayers.length} layers (in-memory).`);
|
||||||
|
return newLayers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
|
||||||
|
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
|
||||||
|
* @param {Array} newSelection - Nowa lista zaznaczonych warstw
|
||||||
|
*/
|
||||||
|
updateSelection(newSelection) {
|
||||||
|
const previousSelection = this.selectedLayers.length;
|
||||||
|
this.selectedLayers = newSelection || [];
|
||||||
|
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
|
||||||
|
|
||||||
|
// Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli
|
||||||
|
const hasChanged = previousSelection !== this.selectedLayers.length ||
|
||||||
|
this.selectedLayers.some((layer, i) => this.selectedLayers[i] !== (newSelection || [])[i]);
|
||||||
|
|
||||||
|
if (!hasChanged && previousSelection > 0) {
|
||||||
|
// return; // Zablokowane na razie, może powodować problemy
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('Selection updated', {
|
||||||
|
previousCount: previousSelection,
|
||||||
|
newCount: this.selectedLayers.length,
|
||||||
|
selectedLayerIds: this.selectedLayers.map(l => l.id || 'unknown')
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji
|
||||||
|
this.canvas.render();
|
||||||
|
|
||||||
|
// 2. Powiadom inne części aplikacji (jeśli są)
|
||||||
|
if (this.onSelectionChange) {
|
||||||
|
this.onSelectionChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Powiadom panel warstw, aby zaktualizował swój wygląd
|
||||||
|
if (this.canvas.canvasLayersPanel) {
|
||||||
|
this.canvas.canvasLayersPanel.onSelectionChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
|
||||||
|
*/
|
||||||
|
updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) {
|
||||||
|
let newSelection = [...this.selectedLayers];
|
||||||
|
let selectionChanged = false;
|
||||||
|
|
||||||
|
if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) {
|
||||||
|
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||||
|
const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
|
||||||
|
const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
|
||||||
|
|
||||||
|
newSelection = [];
|
||||||
|
for (let i = startIndex; i <= endIndex; i++) {
|
||||||
|
if (sortedLayers[i]) {
|
||||||
|
newSelection.push(sortedLayers[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectionChanged = true;
|
||||||
|
} else if (isCtrlPressed) {
|
||||||
|
const layerIndex = newSelection.indexOf(layer);
|
||||||
|
if (layerIndex === -1) {
|
||||||
|
newSelection.push(layer);
|
||||||
|
} else {
|
||||||
|
newSelection.splice(layerIndex, 1);
|
||||||
|
}
|
||||||
|
this.canvas.canvasLayersPanel.lastSelectedIndex = index;
|
||||||
|
selectionChanged = true;
|
||||||
|
} else {
|
||||||
|
// Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia,
|
||||||
|
// wyczyść zaznaczenie i zaznacz tylko ją.
|
||||||
|
if (!this.selectedLayers.includes(layer)) {
|
||||||
|
newSelection = [layer];
|
||||||
|
selectionChanged = true;
|
||||||
|
}
|
||||||
|
// Jeśli kliknięta warstwa JEST już zaznaczona (potencjalnie z innymi),
|
||||||
|
// NIE rób nic, aby umożliwić przeciąganie całej grupy.
|
||||||
|
this.canvas.canvasLayersPanel.lastSelectedIndex = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło
|
||||||
|
if (selectionChanged) {
|
||||||
|
this.updateSelection(newSelection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSelectedLayers() {
|
||||||
|
if (this.selectedLayers.length > 0) {
|
||||||
|
log.info('Removing selected layers', {
|
||||||
|
layersToRemove: this.selectedLayers.length,
|
||||||
|
totalLayers: this.canvas.layers.length
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canvas.saveState();
|
||||||
|
this.canvas.layers = this.canvas.layers.filter(l => !this.selectedLayers.includes(l));
|
||||||
|
|
||||||
|
this.updateSelection([]);
|
||||||
|
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
|
||||||
|
if (this.canvas.canvasLayersPanel) {
|
||||||
|
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length);
|
||||||
|
} else {
|
||||||
|
log.debug('No layers selected for removal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualizuje zaznaczenie po operacji historii
|
||||||
|
*/
|
||||||
|
updateSelectionAfterHistory() {
|
||||||
|
const newSelectedLayers = [];
|
||||||
|
if (this.selectedLayers) {
|
||||||
|
this.selectedLayers.forEach(sl => {
|
||||||
|
const found = this.canvas.layers.find(l => l.id === sl.id);
|
||||||
|
if (found) newSelectedLayers.push(found);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.updateSelection(newSelectedLayers);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,25 @@ export class CanvasState {
|
|||||||
this.saveTimeout = null;
|
this.saveTimeout = null;
|
||||||
this.lastSavedStateSignature = null;
|
this.lastSavedStateSignature = null;
|
||||||
this._loadInProgress = null;
|
this._loadInProgress = null;
|
||||||
|
|
||||||
|
// Inicjalizacja Web Workera w sposób odporny na problemy ze ścieżkami
|
||||||
|
try {
|
||||||
|
// new URL(..., import.meta.url) tworzy absolutną ścieżkę do workera
|
||||||
|
this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' });
|
||||||
|
log.info("State saver worker initialized successfully.");
|
||||||
|
|
||||||
|
this.stateSaverWorker.onmessage = (e) => {
|
||||||
|
log.info("Message from state saver worker:", e.data);
|
||||||
|
};
|
||||||
|
this.stateSaverWorker.onerror = (e) => {
|
||||||
|
log.error("Error in state saver worker:", e.message, e.filename, e.lineno);
|
||||||
|
// Zapobiegaj dalszym próbom, jeśli worker nie działa
|
||||||
|
this.stateSaverWorker = null;
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Failed to initialize state saver worker:", e);
|
||||||
|
this.stateSaverWorker = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -182,46 +201,36 @@ export class CanvasState {
|
|||||||
img.src = imageSrc;
|
img.src = imageSrc;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveStateToDB(immediate = false) {
|
async saveStateToDB() {
|
||||||
log.info("Preparing to save state to IndexedDB for node:", this.canvas.node.id);
|
|
||||||
if (!this.canvas.node.id) {
|
if (!this.canvas.node.id) {
|
||||||
log.error("Node ID is not available for saving state to DB.");
|
log.error("Node ID is not available for saving state to DB.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentStateSignature = getStateSignature(this.canvas.layers);
|
log.info("Preparing state to be sent to worker...");
|
||||||
if (this.lastSavedStateSignature === currentStateSignature) {
|
const state = {
|
||||||
log.debug("State unchanged, skipping save to IndexedDB.");
|
layers: await this._prepareLayers(),
|
||||||
|
viewport: this.canvas.viewport,
|
||||||
|
width: this.canvas.width,
|
||||||
|
height: this.canvas.height,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.layers = state.layers.filter(layer => layer !== null);
|
||||||
|
if (state.layers.length === 0) {
|
||||||
|
log.warn("No valid layers to save, skipping.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.saveTimeout) {
|
if (this.stateSaverWorker) {
|
||||||
clearTimeout(this.saveTimeout);
|
log.info("Posting state to worker for background saving.");
|
||||||
}
|
this.stateSaverWorker.postMessage({
|
||||||
|
nodeId: this.canvas.node.id,
|
||||||
const saveFunction = withErrorHandling(async () => {
|
state: state
|
||||||
const state = {
|
});
|
||||||
layers: await this._prepareLayers(),
|
this.canvas.render();
|
||||||
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 {
|
} else {
|
||||||
this.saveTimeout = setTimeout(saveFunction, 1000);
|
log.warn("State saver worker not available. Saving on main thread.");
|
||||||
|
await setCanvasState(this.canvas.node.id, state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,14 +272,15 @@ export class CanvasState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentState = cloneLayers(this.canvas.layers);
|
const currentState = cloneLayers(this.canvas.layers);
|
||||||
|
const currentStateSignature = getStateSignature(currentState);
|
||||||
|
|
||||||
if (this.layersUndoStack.length > 0) {
|
if (this.layersUndoStack.length > 0) {
|
||||||
const lastState = this.layersUndoStack[this.layersUndoStack.length - 1];
|
const lastState = this.layersUndoStack[this.layersUndoStack.length - 1];
|
||||||
if (getStateSignature(currentState) === getStateSignature(lastState)) {
|
if (getStateSignature(lastState) === currentStateSignature) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.layersUndoStack.push(currentState);
|
this.layersUndoStack.push(currentState);
|
||||||
|
|
||||||
if (this.layersUndoStack.length > this.historyLimit) {
|
if (this.layersUndoStack.length > this.historyLimit) {
|
||||||
@@ -278,7 +288,11 @@ export class CanvasState {
|
|||||||
}
|
}
|
||||||
this.layersRedoStack = [];
|
this.layersRedoStack = [];
|
||||||
this.canvas.updateHistoryButtons();
|
this.canvas.updateHistoryButtons();
|
||||||
this._debouncedSave = this._debouncedSave || debounce(() => this.saveStateToDB(), 500);
|
|
||||||
|
// Użyj debouncingu, aby zapobiec zbyt częstym zapisom
|
||||||
|
if (!this._debouncedSave) {
|
||||||
|
this._debouncedSave = debounce(() => this.saveStateToDB(), 1000);
|
||||||
|
}
|
||||||
this._debouncedSave();
|
this._debouncedSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +306,7 @@ export class CanvasState {
|
|||||||
const clonedCanvas = document.createElement('canvas');
|
const clonedCanvas = document.createElement('canvas');
|
||||||
clonedCanvas.width = maskCanvas.width;
|
clonedCanvas.width = maskCanvas.width;
|
||||||
clonedCanvas.height = maskCanvas.height;
|
clonedCanvas.height = maskCanvas.height;
|
||||||
const clonedCtx = clonedCanvas.getContext('2d');
|
const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
clonedCtx.drawImage(maskCanvas, 0, 0);
|
clonedCtx.drawImage(maskCanvas, 0, 0);
|
||||||
|
|
||||||
this.maskUndoStack.push(clonedCanvas);
|
this.maskUndoStack.push(clonedCanvas);
|
||||||
@@ -352,7 +366,7 @@ export class CanvasState {
|
|||||||
if (this.maskUndoStack.length > 0) {
|
if (this.maskUndoStack.length > 0) {
|
||||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
const maskCanvas = this.canvas.maskTool.getMask();
|
||||||
const maskCtx = maskCanvas.getContext('2d');
|
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||||
maskCtx.drawImage(prevState, 0, 0);
|
maskCtx.drawImage(prevState, 0, 0);
|
||||||
|
|
||||||
@@ -368,7 +382,7 @@ export class CanvasState {
|
|||||||
const nextState = this.maskRedoStack.pop();
|
const nextState = this.maskRedoStack.pop();
|
||||||
this.maskUndoStack.push(nextState);
|
this.maskUndoStack.push(nextState);
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
const maskCanvas = this.canvas.maskTool.getMask();
|
||||||
const maskCtx = maskCanvas.getContext('2d');
|
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||||
maskCtx.drawImage(nextState, 0, 0);
|
maskCtx.drawImage(nextState, 0, 0);
|
||||||
|
|
||||||
|
|||||||
520
js/CanvasView.js
520
js/CanvasView.js
@@ -96,6 +96,33 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.painter-clipboard-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background-color: rgba(0,0,0,0.15);
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.painter-clipboard-group::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 20px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(74, 108, 212, 0.6), transparent);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.painter-clipboard-group .painter-button {
|
||||||
|
margin: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.painter-separator {
|
.painter-separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -214,12 +241,13 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.painter-tooltip table td:first-child {
|
.painter-tooltip table td:first-child {
|
||||||
width: 45%;
|
width: auto;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
min-width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.painter-tooltip table td:last-child {
|
.painter-tooltip table td:last-child {
|
||||||
width: 55%;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.painter-tooltip table tr:nth-child(odd) td {
|
.painter-tooltip table tr:nth-child(odd) td {
|
||||||
@@ -368,7 +396,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
z-index: 9998;
|
z-index: 111;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -385,6 +413,8 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
@@ -399,7 +429,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
|
<tr><td><kbd>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 + 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 + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr>
|
||||||
<tr><td><kbd>Double Click (background)</kbd></td><td>Deselect all layers</td></tr>
|
<tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<h4>Clipboard & I/O</h4>
|
<h4>Clipboard & I/O</h4>
|
||||||
@@ -414,10 +444,11 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
|
<tr><td><kbd>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>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>Alt + Drag</kbd></td><td>Clone selected layer(s)</td></tr>
|
||||||
<tr><td><kbd>Shift + Click</kbd></td><td>Show blend mode & opacity menu</td></tr>
|
<tr><td><kbd>Right Click</kbd></td><td>Show blend mode & opacity menu</td></tr>
|
||||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Scale layer (snaps to grid)</td></tr>
|
<tr><td><kbd>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>Ctrl + Mouse Wheel</kbd></td><td>Fine-scale layer</td></tr>
|
||||||
<tr><td><kbd>Shift + Mouse Wheel</kbd></td><td>Rotate layer by 5°</td></tr>
|
<tr><td><kbd>Shift + Mouse Wheel</kbd></td><td>Rotate layer by 5° steps</td></tr>
|
||||||
|
<tr><td><kbd>Shift + Ctrl + Mouse Wheel</kbd></td><td>Snap rotation to 5° increments</td></tr>
|
||||||
<tr><td><kbd>Arrow Keys</kbd></td><td>Nudge layer by 1px</td></tr>
|
<tr><td><kbd>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>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>[</kbd> or <kbd>]</kbd></td><td>Rotate by 1°</td></tr>
|
||||||
@@ -447,6 +478,41 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
document.body.appendChild(helpTooltip);
|
document.body.appendChild(helpTooltip);
|
||||||
|
|
||||||
|
// Helper function for tooltip positioning
|
||||||
|
const showTooltip = (buttonElement, content) => {
|
||||||
|
helpTooltip.innerHTML = content;
|
||||||
|
helpTooltip.style.visibility = 'hidden';
|
||||||
|
helpTooltip.style.display = 'block';
|
||||||
|
|
||||||
|
const buttonRect = buttonElement.getBoundingClientRect();
|
||||||
|
const tooltipRect = helpTooltip.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let left = buttonRect.left;
|
||||||
|
let top = buttonRect.bottom + 5;
|
||||||
|
|
||||||
|
if (left + tooltipRect.width > viewportWidth) {
|
||||||
|
left = viewportWidth - tooltipRect.width - 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top + tooltipRect.height > viewportHeight) {
|
||||||
|
top = buttonRect.top - tooltipRect.height - 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left < 10) left = 10;
|
||||||
|
if (top < 10) top = 10;
|
||||||
|
|
||||||
|
helpTooltip.style.left = `${left}px`;
|
||||||
|
helpTooltip.style.top = `${top}px`;
|
||||||
|
helpTooltip.style.visibility = 'visible';
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
helpTooltip.style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
const controlPanel = $el("div.painterControlPanel", {}, [
|
const controlPanel = $el("div.painterControlPanel", {}, [
|
||||||
$el("div.controls.painter-controls", {
|
$el("div.controls.painter-controls", {
|
||||||
style: {
|
style: {
|
||||||
@@ -478,50 +544,10 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
},
|
},
|
||||||
onmouseenter: (e) => {
|
onmouseenter: (e) => {
|
||||||
if (canvas.maskTool.isActive) {
|
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
|
||||||
helpTooltip.innerHTML = maskShortcuts;
|
showTooltip(e.target, content);
|
||||||
} else {
|
|
||||||
helpTooltip.innerHTML = standardShortcuts;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Najpierw wyświetlamy tooltip z visibility: hidden aby obliczyć jego wymiary
|
|
||||||
helpTooltip.style.visibility = 'hidden';
|
|
||||||
helpTooltip.style.display = 'block';
|
|
||||||
|
|
||||||
const buttonRect = e.target.getBoundingClientRect();
|
|
||||||
const tooltipRect = helpTooltip.getBoundingClientRect();
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
// Obliczamy pozycję
|
|
||||||
let left = buttonRect.left;
|
|
||||||
let top = buttonRect.bottom + 5;
|
|
||||||
|
|
||||||
// Sprawdzamy czy tooltip wychodzi poza prawy brzeg ekranu
|
|
||||||
if (left + tooltipRect.width > viewportWidth) {
|
|
||||||
left = viewportWidth - tooltipRect.width - 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprawdzamy czy tooltip wychodzi poza dolny brzeg ekranu
|
|
||||||
if (top + tooltipRect.height > viewportHeight) {
|
|
||||||
// Wyświetlamy nad przyciskiem zamiast pod
|
|
||||||
top = buttonRect.top - tooltipRect.height - 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upewniamy się, że tooltip nie wychodzi poza lewy brzeg
|
|
||||||
if (left < 10) left = 10;
|
|
||||||
|
|
||||||
// Upewniamy się, że tooltip nie wychodzi poza górny brzeg
|
|
||||||
if (top < 10) top = 10;
|
|
||||||
|
|
||||||
// Ustawiamy finalną pozycję i pokazujemy tooltip
|
|
||||||
helpTooltip.style.left = `${left}px`;
|
|
||||||
helpTooltip.style.top = `${top}px`;
|
|
||||||
helpTooltip.style.visibility = 'visible';
|
|
||||||
},
|
},
|
||||||
onmouseleave: () => {
|
onmouseleave: hideTooltip
|
||||||
helpTooltip.style.display = 'none';
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.primary", {
|
$el("button.painter-button.primary", {
|
||||||
textContent: "Add Image",
|
textContent: "Add Image",
|
||||||
@@ -539,7 +565,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
canvas.addLayer(img, addMode);
|
canvas.addLayer(img, {}, addMode);
|
||||||
};
|
};
|
||||||
img.src = event.target.result;
|
img.src = event.target.result;
|
||||||
};
|
};
|
||||||
@@ -552,17 +578,89 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
$el("button.painter-button.primary", {
|
$el("button.painter-button.primary", {
|
||||||
textContent: "Import Input",
|
textContent: "Import Input",
|
||||||
title: "Import image from another node",
|
title: "Import image from another node",
|
||||||
onclick: () => canvas.importLatestImage()
|
onclick: () => canvas.canvasIO.importLatestImage()
|
||||||
}),
|
|
||||||
$el("button.painter-button.primary", {
|
|
||||||
textContent: "Paste Image",
|
|
||||||
title: "Paste image from clipboard",
|
|
||||||
onclick: () => {
|
|
||||||
const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add");
|
|
||||||
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
|
|
||||||
canvas.handlePaste(addMode);
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
|
$el("div.painter-clipboard-group", {}, [
|
||||||
|
$el("button.painter-button.primary", {
|
||||||
|
textContent: "Paste Image",
|
||||||
|
title: "Paste image from clipboard",
|
||||||
|
onclick: () => {
|
||||||
|
|
||||||
|
const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add");
|
||||||
|
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
|
||||||
|
canvas.canvasLayers.handlePaste(addMode);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
$el("button.painter-button", {
|
||||||
|
id: `clipboard-toggle-${node.id}`,
|
||||||
|
textContent: "📋 System",
|
||||||
|
title: "Toggle clipboard source: System Clipboard",
|
||||||
|
style: {
|
||||||
|
minWidth: "100px",
|
||||||
|
fontSize: "11px",
|
||||||
|
backgroundColor: "#4a4a4a"
|
||||||
|
},
|
||||||
|
onclick: (e) => {
|
||||||
|
const button = e.target;
|
||||||
|
if (canvas.canvasLayers.clipboardPreference === 'system') {
|
||||||
|
canvas.canvasLayers.clipboardPreference = 'clipspace';
|
||||||
|
button.textContent = "📋 Clipspace";
|
||||||
|
button.title = "Toggle clipboard source: ComfyUI Clipspace";
|
||||||
|
button.style.backgroundColor = "#4a6cd4";
|
||||||
|
} else {
|
||||||
|
canvas.canvasLayers.clipboardPreference = 'system';
|
||||||
|
button.textContent = "📋 System";
|
||||||
|
button.title = "Toggle clipboard source: System Clipboard";
|
||||||
|
button.style.backgroundColor = "#4a4a4a";
|
||||||
|
}
|
||||||
|
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
|
||||||
|
},
|
||||||
|
onmouseenter: (e) => {
|
||||||
|
const currentPreference = canvas.canvasLayers.clipboardPreference;
|
||||||
|
let tooltipContent = '';
|
||||||
|
|
||||||
|
if (currentPreference === 'system') {
|
||||||
|
tooltipContent = `
|
||||||
|
<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>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
tooltipContent = `
|
||||||
|
<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>Best for:</strong> ComfyUI workflow integration and node-to-node image transfer
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showTooltip(e.target, tooltipContent);
|
||||||
|
},
|
||||||
|
onmouseleave: hideTooltip
|
||||||
|
})
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
$el("div.painter-separator"),
|
$el("div.painter-separator"),
|
||||||
@@ -644,7 +742,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
const height = parseInt(document.getElementById('canvas-height').value) || canvas.height;
|
const height = parseInt(document.getElementById('canvas-height').value) || canvas.height;
|
||||||
canvas.updateOutputAreaSize(width, height);
|
canvas.updateOutputAreaSize(width, height);
|
||||||
document.body.removeChild(dialog);
|
document.body.removeChild(dialog);
|
||||||
// updateOutput is triggered by saveState in updateOutputAreaSize
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('cancel-size').onclick = () => {
|
document.getElementById('cancel-size').onclick = () => {
|
||||||
@@ -660,12 +758,17 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Layer Up",
|
textContent: "Layer Up",
|
||||||
title: "Move selected layer(s) up",
|
title: "Move selected layer(s) up",
|
||||||
onclick: () => canvas.moveLayerUp()
|
onclick: () => canvas.canvasLayers.moveLayerUp()
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Layer Down",
|
textContent: "Layer Down",
|
||||||
title: "Move selected layer(s) down",
|
title: "Move selected layer(s) down",
|
||||||
onclick: () => canvas.moveLayerDown()
|
onclick: () => canvas.canvasLayers.moveLayerDown()
|
||||||
|
}),
|
||||||
|
$el("button.painter-button.requires-selection", {
|
||||||
|
textContent: "Fuse",
|
||||||
|
title: "Flatten and merge selected layers into a single layer",
|
||||||
|
onclick: () => canvas.canvasLayers.fuseLayers()
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
@@ -674,27 +777,27 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Rotate +90°",
|
textContent: "Rotate +90°",
|
||||||
title: "Rotate selected layer(s) by +90 degrees",
|
title: "Rotate selected layer(s) by +90 degrees",
|
||||||
onclick: () => canvas.rotateLayer(90)
|
onclick: () => canvas.canvasLayers.rotateLayer(90)
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Scale +5%",
|
textContent: "Scale +5%",
|
||||||
title: "Increase size of selected layer(s) by 5%",
|
title: "Increase size of selected layer(s) by 5%",
|
||||||
onclick: () => canvas.resizeLayer(1.05)
|
onclick: () => canvas.canvasLayers.resizeLayer(1.05)
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Scale -5%",
|
textContent: "Scale -5%",
|
||||||
title: "Decrease size of selected layer(s) by 5%",
|
title: "Decrease size of selected layer(s) by 5%",
|
||||||
onclick: () => canvas.resizeLayer(0.95)
|
onclick: () => canvas.canvasLayers.resizeLayer(0.95)
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Mirror H",
|
textContent: "Mirror H",
|
||||||
title: "Mirror selected layer(s) horizontally",
|
title: "Mirror selected layer(s) horizontally",
|
||||||
onclick: () => canvas.mirrorHorizontal()
|
onclick: () => canvas.canvasLayers.mirrorHorizontal()
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Mirror V",
|
textContent: "Mirror V",
|
||||||
title: "Mirror selected layer(s) vertically",
|
title: "Mirror selected layer(s) vertically",
|
||||||
onclick: () => canvas.mirrorVertical()
|
onclick: () => canvas.canvasLayers.mirrorVertical()
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
@@ -712,32 +815,38 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
button.classList.add('loading');
|
button.classList.add('loading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (canvas.selectedLayers.length !== 1) throw new Error("Please select exactly one image layer for matting.");
|
if (canvas.canvasSelection.selectedLayers.length !== 1) throw new Error("Please select exactly one image layer for matting.");
|
||||||
|
|
||||||
const selectedLayer = canvas.selectedLayers[0];
|
const selectedLayer = canvas.canvasSelection.selectedLayers[0];
|
||||||
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
|
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
|
||||||
const imageData = await canvas.getLayerImageData(selectedLayer);
|
const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer);
|
||||||
const response = await fetch("/matting", {
|
const response = await fetch("/matting", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: JSON.stringify({image: imageData})
|
body: JSON.stringify({image: imageData})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`Server error: ${response.status} - ${response.statusText}`);
|
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
|
||||||
|
if (result && result.error) {
|
||||||
|
errorMsg = `Error: ${result.error}\n\nDetails: ${result.details}`;
|
||||||
|
}
|
||||||
|
throw new Error(errorMsg);
|
||||||
|
}
|
||||||
const mattedImage = new Image();
|
const mattedImage = new Image();
|
||||||
mattedImage.src = result.matted_image;
|
mattedImage.src = result.matted_image;
|
||||||
await mattedImage.decode();
|
await mattedImage.decode();
|
||||||
const newLayer = {...selectedLayer, image: mattedImage};
|
const newLayer = {...selectedLayer, image: mattedImage};
|
||||||
delete newLayer.imageId;
|
delete newLayer.imageId;
|
||||||
canvas.layers[selectedLayerIndex] = newLayer;
|
canvas.layers[selectedLayerIndex] = newLayer;
|
||||||
canvas.updateSelection([newLayer]);
|
canvas.canvasSelection.updateSelection([newLayer]);
|
||||||
canvas.render();
|
canvas.render();
|
||||||
canvas.saveState();
|
canvas.saveState();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Matting error:", error);
|
log.error("Matting error:", error);
|
||||||
alert(`Error during matting process: ${error.message}`);
|
alert(`Matting process failed:\n\n${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
button.classList.remove('loading');
|
button.classList.remove('loading');
|
||||||
button.removeChild(spinner);
|
button.removeChild(spinner);
|
||||||
@@ -761,6 +870,31 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
]),
|
]),
|
||||||
$el("div.painter-separator"),
|
$el("div.painter-separator"),
|
||||||
$el("div.painter-button-group", {id: "mask-controls"}, [
|
$el("div.painter-button-group", {id: "mask-controls"}, [
|
||||||
|
$el("button.painter-button.primary", {
|
||||||
|
id: `toggle-mask-btn-${node.id}`,
|
||||||
|
textContent: "Show Mask",
|
||||||
|
title: "Toggle mask overlay visibility",
|
||||||
|
onclick: (e) => {
|
||||||
|
const button = e.target;
|
||||||
|
canvas.maskTool.toggleOverlayVisibility();
|
||||||
|
canvas.render();
|
||||||
|
|
||||||
|
if (canvas.maskTool.isOverlayVisible) {
|
||||||
|
button.classList.add('primary');
|
||||||
|
button.textContent = "Show Mask";
|
||||||
|
} else {
|
||||||
|
button.classList.remove('primary');
|
||||||
|
button.textContent = "Hide Mask";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
$el("button.painter-button", {
|
||||||
|
textContent: "Edit Mask",
|
||||||
|
title: "Open the current canvas view in the mask editor",
|
||||||
|
onclick: () => {
|
||||||
|
canvas.startMaskEditor();
|
||||||
|
}
|
||||||
|
}),
|
||||||
$el("button.painter-button", {
|
$el("button.painter-button", {
|
||||||
id: "mask-mode-btn",
|
id: "mask-mode-btn",
|
||||||
textContent: "Draw Mask",
|
textContent: "Draw Mask",
|
||||||
@@ -838,15 +972,15 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"},
|
style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"},
|
||||||
onclick: async () => {
|
onclick: async () => {
|
||||||
try {
|
try {
|
||||||
const stats = canvas.getGarbageCollectionStats();
|
const stats = canvas.imageReferenceManager.getStats();
|
||||||
log.info("GC Stats before cleanup:", stats);
|
log.info("GC Stats before cleanup:", stats);
|
||||||
|
|
||||||
await canvas.runGarbageCollection();
|
await canvas.imageReferenceManager.manualGarbageCollection();
|
||||||
|
|
||||||
const newStats = canvas.getGarbageCollectionStats();
|
const newStats = canvas.imageReferenceManager.getStats();
|
||||||
log.info("GC Stats after cleanup:", newStats);
|
log.info("GC Stats after cleanup:", newStats);
|
||||||
|
|
||||||
alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${newStats.operationCount}/${newStats.operationThreshold}`);
|
alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${canvas.imageReferenceManager.operationCount}/${canvas.imageReferenceManager.operationThreshold}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error("Failed to run garbage collection:", e);
|
log.error("Failed to run garbage collection:", e);
|
||||||
alert("Error running garbage collection. Check the console for details.");
|
alert("Error running garbage collection. Check the console for details.");
|
||||||
@@ -876,10 +1010,15 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
|
|
||||||
|
|
||||||
const updateButtonStates = () => {
|
const updateButtonStates = () => {
|
||||||
const selectionCount = canvas.selectedLayers.length;
|
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
||||||
const hasSelection = selectionCount > 0;
|
const hasSelection = selectionCount > 0;
|
||||||
controlPanel.querySelectorAll('.requires-selection').forEach(btn => {
|
controlPanel.querySelectorAll('.requires-selection').forEach(btn => {
|
||||||
btn.disabled = !hasSelection;
|
// Special handling for Fuse button - requires at least 2 layers
|
||||||
|
if (btn.textContent === 'Fuse') {
|
||||||
|
btn.disabled = selectionCount < 2;
|
||||||
|
} else {
|
||||||
|
btn.disabled = !hasSelection;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const mattingBtn = controlPanel.querySelector('.matting-button');
|
const mattingBtn = controlPanel.querySelector('.matting-button');
|
||||||
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
|
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
|
||||||
@@ -887,7 +1026,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
canvas.onSelectionChange = updateButtonStates;
|
canvas.canvasSelection.onSelectionChange = updateButtonStates;
|
||||||
|
|
||||||
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);
|
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);
|
||||||
const redoButton = controlPanel.querySelector(`#redo-button-${node.id}`);
|
const redoButton = controlPanel.querySelector(`#redo-button-${node.id}`);
|
||||||
@@ -901,32 +1040,62 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
canvas.updateHistoryButtons();
|
canvas.updateHistoryButtons();
|
||||||
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
|
||||||
const controlsHeight = entries[0].target.offsetHeight;
|
|
||||||
canvasContainer.style.top = (controlsHeight + 10) + "px";
|
|
||||||
});
|
|
||||||
|
|
||||||
resizeObserver.observe(controlPanel.querySelector('.controls'));
|
|
||||||
|
|
||||||
const triggerWidget = node.widgets.find(w => w.name === "trigger");
|
const triggerWidget = node.widgets.find(w => w.name === "trigger");
|
||||||
|
|
||||||
const updateOutput = () => {
|
const updateOutput = async () => {
|
||||||
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
|
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
|
||||||
// app.graph.runStep(); // Potentially not needed if we just want to mark dirty
|
|
||||||
|
try {
|
||||||
|
const new_preview = new Image();
|
||||||
|
const blob = await canvas.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
if (blob) {
|
||||||
|
new_preview.src = URL.createObjectURL(blob);
|
||||||
|
await new Promise(r => new_preview.onload = r);
|
||||||
|
node.imgs = [new_preview];
|
||||||
|
} else {
|
||||||
|
node.imgs = [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating node preview:", error);
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Tworzenie panelu warstw
|
||||||
|
const layersPanel = canvas.canvasLayersPanel.createPanelStructure();
|
||||||
|
|
||||||
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
|
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
|
||||||
style: {
|
style: {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "60px",
|
top: "60px", // Wartość początkowa, zostanie nadpisana przez ResizeObserver
|
||||||
left: "10px",
|
left: "10px",
|
||||||
right: "10px",
|
right: "270px",
|
||||||
bottom: "10px",
|
bottom: "10px",
|
||||||
|
|
||||||
overflow: "hidden"
|
overflow: "hidden"
|
||||||
}
|
}
|
||||||
}, [canvas.canvas]);
|
}, [canvas.canvas]);
|
||||||
|
|
||||||
|
// Kontener dla panelu warstw
|
||||||
|
const layersPanelContainer = $el("div.painterLayersPanelContainer", {
|
||||||
|
style: {
|
||||||
|
position: "absolute",
|
||||||
|
top: "60px", // Wartość początkowa, zostanie nadpisana przez ResizeObserver
|
||||||
|
right: "10px",
|
||||||
|
width: "250px",
|
||||||
|
bottom: "10px",
|
||||||
|
overflow: "hidden"
|
||||||
|
}
|
||||||
|
}, [layersPanel]);
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
const controlsHeight = entries[0].target.offsetHeight;
|
||||||
|
const newTop = (controlsHeight + 10) + "px";
|
||||||
|
canvasContainer.style.top = newTop;
|
||||||
|
layersPanelContainer.style.top = newTop;
|
||||||
|
});
|
||||||
|
|
||||||
|
resizeObserver.observe(controlPanel.querySelector('.controls'));
|
||||||
|
|
||||||
canvas.canvas.addEventListener('focus', () => {
|
canvas.canvas.addEventListener('focus', () => {
|
||||||
canvasContainer.classList.add('has-focus');
|
canvasContainer.classList.add('has-focus');
|
||||||
});
|
});
|
||||||
@@ -947,71 +1116,9 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%"
|
height: "100%"
|
||||||
}
|
}
|
||||||
}, [controlPanel, canvasContainer]);
|
}, [controlPanel, canvasContainer, layersPanelContainer]);
|
||||||
const handleFileLoad = async (file) => {
|
|
||||||
log.info("File dropped:", file.name);
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
log.info("Dropped file is not an image.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async (event) => {
|
|
||||||
log.debug("FileReader finished loading dropped file as data:URL.");
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = async () => {
|
|
||||||
log.debug("Image object loaded from dropped data:URL.");
|
|
||||||
const scale = Math.min(
|
|
||||||
canvas.width / img.width,
|
|
||||||
canvas.height / img.height
|
|
||||||
);
|
|
||||||
|
|
||||||
const layer = {
|
|
||||||
image: img,
|
|
||||||
x: (canvas.width - img.width * scale) / 2,
|
|
||||||
y: (canvas.height - img.height * scale) / 2,
|
|
||||||
width: img.width * scale,
|
|
||||||
height: img.height * scale,
|
|
||||||
rotation: 0,
|
|
||||||
zIndex: canvas.layers.length,
|
|
||||||
blendMode: 'normal',
|
|
||||||
opacity: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
canvas.layers.push(layer);
|
|
||||||
canvas.updateSelection([layer]);
|
|
||||||
canvas.render();
|
|
||||||
canvas.saveState();
|
|
||||||
log.info("Dropped layer added and state saved.");
|
|
||||||
};
|
|
||||||
img.src = event.target.result;
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
mainContainer.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
canvasContainer.classList.add('drag-over');
|
|
||||||
});
|
|
||||||
|
|
||||||
mainContainer.addEventListener('dragleave', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
canvasContainer.classList.remove('drag-over');
|
|
||||||
});
|
|
||||||
|
|
||||||
mainContainer.addEventListener('drop', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
canvasContainer.classList.remove('drag-over');
|
|
||||||
|
|
||||||
if (e.dataTransfer.files) {
|
|
||||||
for (const file of e.dataTransfer.files) {
|
|
||||||
await handleFileLoad(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer);
|
const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer);
|
||||||
|
|
||||||
@@ -1072,14 +1179,38 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
if (!window.canvasExecutionStates) {
|
if (!window.canvasExecutionStates) {
|
||||||
window.canvasExecutionStates = new Map();
|
window.canvasExecutionStates = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
node.canvasWidget = canvas;
|
node.canvasWidget = canvas;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
canvas.loadInitialState();
|
canvas.loadInitialState();
|
||||||
|
// Renderuj panel warstw po załadowaniu stanu
|
||||||
|
if (canvas.canvasLayersPanel) {
|
||||||
|
canvas.canvasLayersPanel.renderLayers();
|
||||||
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
const showPreviewWidget = node.widgets.find(w => w.name === "show_preview");
|
||||||
|
if (showPreviewWidget) {
|
||||||
|
const originalCallback = showPreviewWidget.callback;
|
||||||
|
|
||||||
|
showPreviewWidget.callback = function (value) {
|
||||||
|
if (originalCallback) {
|
||||||
|
originalCallback.call(this, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canvas && canvas.setPreviewVisibility) {
|
||||||
|
canvas.setPreviewVisibility(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.graph && node.graph.canvas) {
|
||||||
|
node.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canvas: canvas,
|
canvas: canvas,
|
||||||
panel: controlPanel
|
panel: controlPanel
|
||||||
@@ -1154,7 +1285,6 @@ app.registerExtension({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iterate through every widget attached to this node
|
|
||||||
this.widgets.forEach(w => {
|
this.widgets.forEach(w => {
|
||||||
log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`);
|
log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`);
|
||||||
});
|
});
|
||||||
@@ -1206,7 +1336,32 @@ app.registerExtension({
|
|||||||
originalGetExtraMenuOptions?.apply(this, arguments);
|
originalGetExtraMenuOptions?.apply(this, arguments);
|
||||||
|
|
||||||
const self = this;
|
const self = this;
|
||||||
|
|
||||||
|
const maskEditorIndex = options.findIndex(option =>
|
||||||
|
option && option.content === "Open in MaskEditor"
|
||||||
|
);
|
||||||
|
if (maskEditorIndex !== -1) {
|
||||||
|
options.splice(maskEditorIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
const newOptions = [
|
const newOptions = [
|
||||||
|
{
|
||||||
|
content: "Open in MaskEditor",
|
||||||
|
callback: async () => {
|
||||||
|
try {
|
||||||
|
log.info("Opening LayerForge canvas in MaskEditor");
|
||||||
|
if (self.canvasWidget && self.canvasWidget.startMaskEditor) {
|
||||||
|
await self.canvasWidget.startMaskEditor();
|
||||||
|
} else {
|
||||||
|
log.error("Canvas widget not available");
|
||||||
|
alert("Canvas not ready. Please try again.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Error opening MaskEditor:", e);
|
||||||
|
alert(`Failed to open MaskEditor: ${e.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
content: "Open Image",
|
content: "Open Image",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
@@ -1220,6 +1375,19 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
content: "Open Image with Mask Alpha",
|
||||||
|
callback: async () => {
|
||||||
|
try {
|
||||||
|
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Error opening image with mask:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
content: "Copy Image",
|
content: "Copy Image",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
@@ -1234,6 +1402,20 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
content: "Copy Image with Mask Alpha",
|
||||||
|
callback: async () => {
|
||||||
|
try {
|
||||||
|
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
const item = new ClipboardItem({'image/png': blob});
|
||||||
|
await navigator.clipboard.write([item]);
|
||||||
|
log.info("Image with mask alpha copied to clipboard.");
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Error copying image with mask:", e);
|
||||||
|
alert("Failed to copy image with mask to clipboard.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
content: "Save Image",
|
content: "Save Image",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
@@ -1252,6 +1434,24 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
content: "Save Image with Mask Alpha",
|
||||||
|
callback: async () => {
|
||||||
|
try {
|
||||||
|
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'canvas_output_with_mask.png';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Error saving image with mask:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
if (options.length > 0) {
|
if (options.length > 0) {
|
||||||
options.unshift({content: "___", disabled: true});
|
options.unshift({content: "___", disabled: true});
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ export class MaskTool {
|
|||||||
this.mainCanvas = canvasInstance.canvas;
|
this.mainCanvas = canvasInstance.canvas;
|
||||||
this.onStateChange = callbacks.onStateChange || null;
|
this.onStateChange = callbacks.onStateChange || null;
|
||||||
this.maskCanvas = document.createElement('canvas');
|
this.maskCanvas = document.createElement('canvas');
|
||||||
this.maskCtx = this.maskCanvas.getContext('2d');
|
this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
this.x = 0;
|
this.x = 0;
|
||||||
this.y = 0;
|
this.y = 0;
|
||||||
|
|
||||||
|
this.isOverlayVisible = true;
|
||||||
this.isActive = false;
|
this.isActive = false;
|
||||||
this.brushSize = 20;
|
this.brushSize = 20;
|
||||||
this.brushStrength = 0.5;
|
this.brushStrength = 0.5;
|
||||||
@@ -21,7 +22,7 @@ export class MaskTool {
|
|||||||
this.lastPosition = null;
|
this.lastPosition = null;
|
||||||
|
|
||||||
this.previewCanvas = document.createElement('canvas');
|
this.previewCanvas = document.createElement('canvas');
|
||||||
this.previewCtx = this.previewCanvas.getContext('2d');
|
this.previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
this.previewVisible = false;
|
this.previewVisible = false;
|
||||||
this.previewCanvasInitialized = false;
|
this.previewCanvasInitialized = false;
|
||||||
|
|
||||||
@@ -162,7 +163,7 @@ export class MaskTool {
|
|||||||
if (this.brushHardness === 1) {
|
if (this.brushHardness === 1) {
|
||||||
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
||||||
} else {
|
} else {
|
||||||
// hardness: 1 = hard edge, 0 = soft edge
|
|
||||||
const innerRadius = gradientRadius * this.brushHardness;
|
const innerRadius = gradientRadius * this.brushHardness;
|
||||||
const gradient = this.maskCtx.createRadialGradient(
|
const gradient = this.maskCtx.createRadialGradient(
|
||||||
canvasX, canvasY, innerRadius,
|
canvasX, canvasY, innerRadius,
|
||||||
@@ -220,7 +221,7 @@ export class MaskTool {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = this.maskCanvas.width;
|
tempCanvas.width = this.maskCanvas.width;
|
||||||
tempCanvas.height = this.maskCanvas.height;
|
tempCanvas.height = this.maskCanvas.height;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
tempCtx.drawImage(this.maskCanvas, 0, 0);
|
tempCtx.drawImage(this.maskCanvas, 0, 0);
|
||||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
@@ -258,7 +259,7 @@ export class MaskTool {
|
|||||||
|
|
||||||
this.maskCanvas.width = newWidth;
|
this.maskCanvas.width = newWidth;
|
||||||
this.maskCanvas.height = newHeight;
|
this.maskCanvas.height = newHeight;
|
||||||
this.maskCtx = this.maskCanvas.getContext('2d');
|
this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
if (oldMask.width > 0 && oldMask.height > 0) {
|
if (oldMask.width > 0 && oldMask.height > 0) {
|
||||||
|
|
||||||
@@ -279,4 +280,28 @@ export class MaskTool {
|
|||||||
this.y += dy;
|
this.y += dy;
|
||||||
log.info(`Mask position updated to (${this.x}, ${this.y})`);
|
log.info(`Mask position updated to (${this.x}, ${this.y})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleOverlayVisibility() {
|
||||||
|
this.isOverlayVisible = !this.isOverlayVisible;
|
||||||
|
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMask(image) {
|
||||||
|
|
||||||
|
|
||||||
|
const destX = -this.x;
|
||||||
|
const destY = -this.y;
|
||||||
|
|
||||||
|
|
||||||
|
this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height);
|
||||||
|
|
||||||
|
|
||||||
|
this.maskCtx.drawImage(image, destX, destY);
|
||||||
|
|
||||||
|
if (this.onStateChange) {
|
||||||
|
this.onStateChange();
|
||||||
|
}
|
||||||
|
this.canvasInstance.render(); // Wymuś odświeżenie, aby zobaczyć zmianę
|
||||||
|
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
||||||
93
js/state-saver.worker.js
Normal file
93
js/state-saver.worker.js
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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
510
js/utils/ClipboardManager.js
Normal file
510
js/utils/ClipboardManager.js
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
import {createModuleLogger} from "./LoggerUtils.js";
|
||||||
|
import {api} from "../../../scripts/api.js";
|
||||||
|
import {ComfyApp} from "../../../scripts/app.js";
|
||||||
|
|
||||||
|
const log = createModuleLogger('ClipboardManager');
|
||||||
|
|
||||||
|
export class ClipboardManager {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.clipboardPreference = 'system'; // 'system', 'clipspace'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main paste handler that delegates to appropriate methods
|
||||||
|
* @param {string} addMode - The mode for adding the layer
|
||||||
|
* @param {string} preference - Clipboard preference ('system' or 'clipspace')
|
||||||
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async handlePaste(addMode = 'mouse', preference = 'system') {
|
||||||
|
try {
|
||||||
|
log.info(`ClipboardManager handling paste with preference: ${preference}`);
|
||||||
|
|
||||||
|
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||||
|
log.info("Found layers in internal clipboard, pasting layers");
|
||||||
|
this.canvas.canvasLayers.pasteLayers();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preference === 'clipspace') {
|
||||||
|
log.info("Attempting paste from ComfyUI Clipspace");
|
||||||
|
const success = await this.tryClipspacePaste(addMode);
|
||||||
|
if (success) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
log.info("No image found in ComfyUI Clipspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Attempting paste from system clipboard");
|
||||||
|
return await this.trySystemClipboardPaste(addMode);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
log.error("ClipboardManager paste operation failed:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to paste from ComfyUI Clipspace
|
||||||
|
* @param {string} addMode - The mode for adding the layer
|
||||||
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async tryClipspacePaste(addMode) {
|
||||||
|
try {
|
||||||
|
log.info("Attempting to paste from ComfyUI Clipspace");
|
||||||
|
const clipspaceResult = ComfyApp.pasteFromClipspace(this.canvas.node);
|
||||||
|
|
||||||
|
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
||||||
|
const clipspaceImage = this.canvas.node.imgs[0];
|
||||||
|
if (clipspaceImage && clipspaceImage.src) {
|
||||||
|
log.info("Successfully got image from ComfyUI Clipspace");
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
};
|
||||||
|
img.src = clipspaceImage.src;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (clipspaceError) {
|
||||||
|
log.warn("ComfyUI Clipspace paste failed:", clipspaceError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System clipboard paste - handles both image data and text paths
|
||||||
|
* @param {string} 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);
|
||||||
|
};
|
||||||
|
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 {string} 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a local file via the ComfyUI backend endpoint
|
||||||
|
* @param {string} filePath - The file path to load
|
||||||
|
* @param {string} addMode - The mode for adding the layer
|
||||||
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async loadFileViaBackend(filePath, addMode) {
|
||||||
|
try {
|
||||||
|
log.info("Loading file via ComfyUI backend:", filePath);
|
||||||
|
|
||||||
|
const response = await api.fetchApi("/ycnode/load_image_from_path", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_path: filePath
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
log.debug("Backend failed to load image:", errorData.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
log.debug("Backend returned error:", data.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Successfully loaded image via ComfyUI backend:", filePath);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
const success = await new Promise((resolve) => {
|
||||||
|
img.onload = async () => {
|
||||||
|
log.info("Successfully loaded image from backend response");
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
log.warn("Failed to load image from backend response");
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = data.image_data;
|
||||||
|
});
|
||||||
|
|
||||||
|
return success;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log.debug("Error loading file via ComfyUI backend:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts the user to select a file when a local path is detected
|
||||||
|
* @param {string} originalPath - The original file path from clipboard
|
||||||
|
* @param {string} 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 file = event.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);
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
|
||||||
|
|
||||||
|
document.body.appendChild(fileInput);
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a message to the user about file path limitations
|
||||||
|
* @param {string} filePath - The file path that couldn't be loaded
|
||||||
|
*/
|
||||||
|
showFilePathMessage(filePath) {
|
||||||
|
const fileName = filePath.split(/[\\\/]/).pop();
|
||||||
|
const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`;
|
||||||
|
this.showNotification(message, 5000);
|
||||||
|
log.info("Showed file path limitation message to user");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a helpful message when clipboard appears empty and offers file picker
|
||||||
|
* @param {string} 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");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a temporary notification to the user
|
||||||
|
* @param {string} message - The message to show
|
||||||
|
* @param {number} duration - Duration in milliseconds
|
||||||
|
*/
|
||||||
|
showNotification(message, duration = 3000) {
|
||||||
|
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
z-index: 10001;
|
||||||
|
max-width: 300px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
`;
|
||||||
|
notification.textContent = message;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (notification.parentNode) {
|
||||||
|
notification.parentNode.removeChild(notification);
|
||||||
|
}
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
* @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
* @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||||
*/
|
*/
|
||||||
export function generateUUID() {
|
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);
|
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||||
return v.toString(16);
|
return v.toString(16);
|
||||||
});
|
});
|
||||||
@@ -42,7 +42,7 @@ export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) {
|
|||||||
top: layer.y,
|
top: layer.y,
|
||||||
bottom: layer.y + layer.height
|
bottom: layer.y + layer.height
|
||||||
};
|
};
|
||||||
|
|
||||||
const x_adjustments = [
|
const x_adjustments = [
|
||||||
{type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left},
|
{type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left},
|
||||||
{type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right}
|
{type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right}
|
||||||
@@ -52,17 +52,17 @@ export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) {
|
|||||||
{type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top},
|
{type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top},
|
||||||
{type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom}
|
{type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom}
|
||||||
];
|
];
|
||||||
|
|
||||||
x_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
|
x_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
|
||||||
y_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
|
y_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
|
||||||
|
|
||||||
const bestXSnap = x_adjustments
|
const bestXSnap = x_adjustments
|
||||||
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
||||||
.sort((a, b) => a.abs - b.abs)[0];
|
.sort((a, b) => a.abs - b.abs)[0];
|
||||||
const bestYSnap = y_adjustments
|
const bestYSnap = y_adjustments
|
||||||
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
||||||
.sort((a, b) => a.abs - b.abs)[0];
|
.sort((a, b) => a.abs - b.abs)[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dx: bestXSnap ? bestXSnap.delta : 0,
|
dx: bestXSnap ? bestXSnap.delta : 0,
|
||||||
dy: bestYSnap ? bestYSnap.delta : 0
|
dy: bestYSnap ? bestYSnap.delta : 0
|
||||||
@@ -145,7 +145,7 @@ export function getStateSignature(layers) {
|
|||||||
if (layer.image && layer.image.src) {
|
if (layer.image && layer.image.src) {
|
||||||
sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures
|
sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures
|
||||||
}
|
}
|
||||||
|
|
||||||
return sig;
|
return sig;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -179,7 +179,7 @@ export function debounce(func, wait, immediate) {
|
|||||||
*/
|
*/
|
||||||
export function throttle(func, limit) {
|
export function throttle(func, limit) {
|
||||||
let inThrottle;
|
let inThrottle;
|
||||||
return function(...args) {
|
return function (...args) {
|
||||||
if (!inThrottle) {
|
if (!inThrottle) {
|
||||||
func.apply(this, args);
|
func.apply(this, args);
|
||||||
inThrottle = true;
|
inThrottle = true;
|
||||||
@@ -241,7 +241,7 @@ export function createCanvas(width, height, contextType = '2d', contextOptions =
|
|||||||
if (width) canvas.width = width;
|
if (width) canvas.width = width;
|
||||||
if (height) canvas.height = height;
|
if (height) canvas.height = height;
|
||||||
const ctx = canvas.getContext(contextType, contextOptions);
|
const ctx = canvas.getContext(contextType, contextOptions);
|
||||||
return { canvas, ctx };
|
return {canvas, ctx};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -284,5 +284,5 @@ export function generateUniqueFileName(baseName, nodeId) {
|
|||||||
*/
|
*/
|
||||||
export function isPointInRect(pointX, pointY, rectX, rectY, rectWidth, rectHeight) {
|
export function isPointInRect(pointX, pointY, rectX, rectY, rectWidth, rectHeight) {
|
||||||
return pointX >= rectX && pointX <= rectX + rectWidth &&
|
return pointX >= rectX && pointX <= rectX + rectWidth &&
|
||||||
pointY >= rectY && pointY <= rectY + rectHeight;
|
pointY >= rectY && pointY <= rectY + rectHeight;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {createModuleLogger} from "./LoggerUtils.js";
|
import {createModuleLogger} from "./LoggerUtils.js";
|
||||||
import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
|
import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
|
||||||
|
|
||||||
const log = createModuleLogger('ImageUtils');
|
const log = createModuleLogger('ImageUtils');
|
||||||
|
|
||||||
export function validateImageData(data) {
|
export function validateImageData(data) {
|
||||||
@@ -114,7 +115,7 @@ export function applyMaskToImageData(imageData, maskData) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const prepareImageForCanvas = withErrorHandling(function(inputImage) {
|
export const prepareImageForCanvas = withErrorHandling(function (inputImage) {
|
||||||
log.info("Preparing image for canvas:", inputImage);
|
log.info("Preparing image for canvas:", inputImage);
|
||||||
|
|
||||||
if (Array.isArray(inputImage)) {
|
if (Array.isArray(inputImage)) {
|
||||||
@@ -122,7 +123,7 @@ export const prepareImageForCanvas = withErrorHandling(function(inputImage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!inputImage || !inputImage.shape || !inputImage.data) {
|
if (!inputImage || !inputImage.shape || !inputImage.data) {
|
||||||
throw createValidationError("Invalid input image format", { inputImage });
|
throw createValidationError("Invalid input image format", {inputImage});
|
||||||
}
|
}
|
||||||
|
|
||||||
const shape = inputImage.shape;
|
const shape = inputImage.shape;
|
||||||
@@ -161,29 +162,29 @@ export const prepareImageForCanvas = withErrorHandling(function(inputImage) {
|
|||||||
* @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji
|
* @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji
|
||||||
* @returns {Promise<Object>} Tensor z danymi obrazu
|
* @returns {Promise<Object>} Tensor z danymi obrazu
|
||||||
*/
|
*/
|
||||||
export const imageToTensor = withErrorHandling(async function(image) {
|
export const imageToTensor = withErrorHandling(async function (image) {
|
||||||
if (!image) {
|
if (!image) {
|
||||||
throw createValidationError("Image is required");
|
throw createValidationError("Image is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
canvas.width = image.width || image.naturalWidth;
|
canvas.width = image.width || image.naturalWidth;
|
||||||
canvas.height = image.height || image.naturalHeight;
|
canvas.height = image.height || image.naturalHeight;
|
||||||
|
|
||||||
ctx.drawImage(image, 0, 0);
|
ctx.drawImage(image, 0, 0);
|
||||||
|
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
const data = new Float32Array(canvas.width * canvas.height * 3);
|
const data = new Float32Array(canvas.width * canvas.height * 3);
|
||||||
|
|
||||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||||
const pixelIndex = i / 4;
|
const pixelIndex = i / 4;
|
||||||
data[pixelIndex * 3] = imageData.data[i] / 255;
|
data[pixelIndex * 3] = imageData.data[i] / 255;
|
||||||
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
|
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
|
||||||
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
|
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: data,
|
data: data,
|
||||||
shape: [1, canvas.height, canvas.width, 3],
|
shape: [1, canvas.height, canvas.width, 3],
|
||||||
@@ -197,33 +198,33 @@ export const imageToTensor = withErrorHandling(async function(image) {
|
|||||||
* @param {Object} tensor - Tensor z danymi obrazu
|
* @param {Object} tensor - Tensor z danymi obrazu
|
||||||
* @returns {Promise<HTMLImageElement>} Obraz HTML
|
* @returns {Promise<HTMLImageElement>} Obraz HTML
|
||||||
*/
|
*/
|
||||||
export const tensorToImage = withErrorHandling(async function(tensor) {
|
export const tensorToImage = withErrorHandling(async function (tensor) {
|
||||||
if (!tensor || !tensor.data || !tensor.shape) {
|
if (!tensor || !tensor.data || !tensor.shape) {
|
||||||
throw createValidationError("Invalid tensor format", { tensor });
|
throw createValidationError("Invalid tensor format", {tensor});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [, height, width, channels] = tensor.shape;
|
const [, height, width, channels] = tensor.shape;
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
|
|
||||||
const imageData = ctx.createImageData(width, height);
|
const imageData = ctx.createImageData(width, height);
|
||||||
const data = tensor.data;
|
const data = tensor.data;
|
||||||
|
|
||||||
for (let i = 0; i < width * height; i++) {
|
for (let i = 0; i < width * height; i++) {
|
||||||
const pixelIndex = i * 4;
|
const pixelIndex = i * 4;
|
||||||
const tensorIndex = i * channels;
|
const tensorIndex = i * channels;
|
||||||
|
|
||||||
imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255);
|
imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255);
|
||||||
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255);
|
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255);
|
||||||
imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
|
imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
|
||||||
imageData.data[pixelIndex + 3] = 255;
|
imageData.data[pixelIndex + 3] = 255;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.putImageData(imageData, 0, 0);
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => resolve(img);
|
img.onload = () => resolve(img);
|
||||||
@@ -239,27 +240,27 @@ export const tensorToImage = withErrorHandling(async function(tensor) {
|
|||||||
* @param {number} maxHeight - Maksymalna wysokość
|
* @param {number} maxHeight - Maksymalna wysokość
|
||||||
* @returns {Promise<HTMLImageElement>} Przeskalowany obraz
|
* @returns {Promise<HTMLImageElement>} Przeskalowany obraz
|
||||||
*/
|
*/
|
||||||
export const resizeImage = withErrorHandling(async function(image, maxWidth, maxHeight) {
|
export const resizeImage = withErrorHandling(async function (image, maxWidth, maxHeight) {
|
||||||
if (!image) {
|
if (!image) {
|
||||||
throw createValidationError("Image is required");
|
throw createValidationError("Image is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
const originalWidth = image.width || image.naturalWidth;
|
const originalWidth = image.width || image.naturalWidth;
|
||||||
const originalHeight = image.height || image.naturalHeight;
|
const originalHeight = image.height || image.naturalHeight;
|
||||||
const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
|
const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
|
||||||
const newWidth = Math.round(originalWidth * scale);
|
const newWidth = Math.round(originalWidth * scale);
|
||||||
const newHeight = Math.round(originalHeight * scale);
|
const newHeight = Math.round(originalHeight * scale);
|
||||||
|
|
||||||
canvas.width = newWidth;
|
canvas.width = newWidth;
|
||||||
canvas.height = newHeight;
|
canvas.height = newHeight;
|
||||||
ctx.imageSmoothingEnabled = true;
|
ctx.imageSmoothingEnabled = true;
|
||||||
ctx.imageSmoothingQuality = 'high';
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
|
||||||
ctx.drawImage(image, 0, 0, newWidth, newHeight);
|
ctx.drawImage(image, 0, 0, newWidth, newHeight);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => resolve(img);
|
img.onload = () => resolve(img);
|
||||||
@@ -274,7 +275,7 @@ export const resizeImage = withErrorHandling(async function(image, maxWidth, max
|
|||||||
* @param {number} size - Rozmiar miniatury (kwadrat)
|
* @param {number} size - Rozmiar miniatury (kwadrat)
|
||||||
* @returns {Promise<HTMLImageElement>} Miniatura
|
* @returns {Promise<HTMLImageElement>} Miniatura
|
||||||
*/
|
*/
|
||||||
export const createThumbnail = withErrorHandling(async function(image, size = 128) {
|
export const createThumbnail = withErrorHandling(async function (image, size = 128) {
|
||||||
return resizeImage(image, size, size);
|
return resizeImage(image, size, size);
|
||||||
}, 'createThumbnail');
|
}, 'createThumbnail');
|
||||||
|
|
||||||
@@ -285,19 +286,19 @@ export const createThumbnail = withErrorHandling(async function(image, size = 12
|
|||||||
* @param {number} quality - Jakość (0-1) dla formatów stratnych
|
* @param {number} quality - Jakość (0-1) dla formatów stratnych
|
||||||
* @returns {string} Base64 string
|
* @returns {string} Base64 string
|
||||||
*/
|
*/
|
||||||
export const imageToBase64 = withErrorHandling(function(image, format = 'png', quality = 0.9) {
|
export const imageToBase64 = withErrorHandling(function (image, format = 'png', quality = 0.9) {
|
||||||
if (!image) {
|
if (!image) {
|
||||||
throw createValidationError("Image is required");
|
throw createValidationError("Image is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
canvas.width = image.width || image.naturalWidth;
|
canvas.width = image.width || image.naturalWidth;
|
||||||
canvas.height = image.height || image.naturalHeight;
|
canvas.height = image.height || image.naturalHeight;
|
||||||
|
|
||||||
ctx.drawImage(image, 0, 0);
|
ctx.drawImage(image, 0, 0);
|
||||||
|
|
||||||
const mimeType = `image/${format}`;
|
const mimeType = `image/${format}`;
|
||||||
return canvas.toDataURL(mimeType, quality);
|
return canvas.toDataURL(mimeType, quality);
|
||||||
}, 'imageToBase64');
|
}, 'imageToBase64');
|
||||||
@@ -307,7 +308,7 @@ export const imageToBase64 = withErrorHandling(function(image, format = 'png', q
|
|||||||
* @param {string} base64 - Base64 string
|
* @param {string} base64 - Base64 string
|
||||||
* @returns {Promise<HTMLImageElement>} Obraz
|
* @returns {Promise<HTMLImageElement>} Obraz
|
||||||
*/
|
*/
|
||||||
export const base64ToImage = withErrorHandling(function(base64) {
|
export const base64ToImage = withErrorHandling(function (base64) {
|
||||||
if (!base64) {
|
if (!base64) {
|
||||||
throw createValidationError("Base64 string is required");
|
throw createValidationError("Base64 string is required");
|
||||||
}
|
}
|
||||||
@@ -326,10 +327,10 @@ export const base64ToImage = withErrorHandling(function(base64) {
|
|||||||
* @returns {boolean} Czy obraz jest prawidłowy
|
* @returns {boolean} Czy obraz jest prawidłowy
|
||||||
*/
|
*/
|
||||||
export function isValidImage(image) {
|
export function isValidImage(image) {
|
||||||
return image &&
|
return image &&
|
||||||
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
|
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
|
||||||
image.width > 0 &&
|
image.width > 0 &&
|
||||||
image.height > 0;
|
image.height > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -371,18 +372,18 @@ export function createImageFromSource(source) {
|
|||||||
* @param {string} color - Kolor tła (CSS color)
|
* @param {string} color - Kolor tła (CSS color)
|
||||||
* @returns {Promise<HTMLImageElement>} Pusty obraz
|
* @returns {Promise<HTMLImageElement>} Pusty obraz
|
||||||
*/
|
*/
|
||||||
export const createEmptyImage = withErrorHandling(function(width, height, color = 'transparent') {
|
export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
|
|
||||||
if (color !== 'transparent') {
|
if (color !== 'transparent') {
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillRect(0, 0, width, height);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => resolve(img);
|
img.onload = () => resolve(img);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {logger, LogLevel} from "../logger.js";
|
import {logger, LogLevel} from "../logger.js";
|
||||||
|
import { LOG_LEVEL } from '../config.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tworzy obiekt loggera dla modułu z predefiniowanymi metodami
|
* Tworzy obiekt loggera dla modułu z predefiniowanymi metodami
|
||||||
@@ -11,9 +12,9 @@ import {logger, LogLevel} from "../logger.js";
|
|||||||
* @param {LogLevel} level - Poziom logowania (domyślnie DEBUG)
|
* @param {LogLevel} level - Poziom logowania (domyślnie DEBUG)
|
||||||
* @returns {Object} Obiekt z metodami logowania
|
* @returns {Object} Obiekt z metodami logowania
|
||||||
*/
|
*/
|
||||||
export function createModuleLogger(moduleName, level = LogLevel.NONE) {
|
export function createModuleLogger(moduleName) {
|
||||||
logger.setModuleLevel(moduleName, level);
|
logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
debug: (...args) => logger.debug(moduleName, ...args),
|
debug: (...args) => logger.debug(moduleName, ...args),
|
||||||
info: (...args) => logger.info(moduleName, ...args),
|
info: (...args) => logger.info(moduleName, ...args),
|
||||||
@@ -31,7 +32,7 @@ export function createAutoLogger(level = LogLevel.DEBUG) {
|
|||||||
const stack = new Error().stack;
|
const stack = new Error().stack;
|
||||||
const match = stack.match(/\/([^\/]+)\.js/);
|
const match = stack.match(/\/([^\/]+)\.js/);
|
||||||
const moduleName = match ? match[1] : 'Unknown';
|
const moduleName = match ? match[1] : 'Unknown';
|
||||||
|
|
||||||
return createModuleLogger(moduleName, level);
|
return createModuleLogger(moduleName, level);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ export function createAutoLogger(level = LogLevel.DEBUG) {
|
|||||||
* @returns {Function} Opakowana funkcja
|
* @returns {Function} Opakowana funkcja
|
||||||
*/
|
*/
|
||||||
export function withErrorLogging(operation, log, operationName) {
|
export function withErrorLogging(operation, log, operationName) {
|
||||||
return async function(...args) {
|
return async function (...args) {
|
||||||
try {
|
try {
|
||||||
log.debug(`Starting ${operationName}`);
|
log.debug(`Starting ${operationName}`);
|
||||||
const result = await operation.apply(this, args);
|
const result = await operation.apply(this, args);
|
||||||
@@ -62,10 +63,10 @@ export function withErrorLogging(operation, log, operationName) {
|
|||||||
* @param {string} methodName - Nazwa metody
|
* @param {string} methodName - Nazwa metody
|
||||||
*/
|
*/
|
||||||
export function logMethod(log, methodName) {
|
export function logMethod(log, methodName) {
|
||||||
return function(target, propertyKey, descriptor) {
|
return function (target, propertyKey, descriptor) {
|
||||||
const originalMethod = descriptor.value;
|
const originalMethod = descriptor.value;
|
||||||
|
|
||||||
descriptor.value = async function(...args) {
|
descriptor.value = async function (...args) {
|
||||||
try {
|
try {
|
||||||
log.debug(`${methodName || propertyKey} started`);
|
log.debug(`${methodName || propertyKey} started`);
|
||||||
const result = await originalMethod.apply(this, args);
|
const result = await originalMethod.apply(this, args);
|
||||||
@@ -76,7 +77,7 @@ export function logMethod(log, methodName) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return descriptor;
|
return descriptor;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class WebSocketManager {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
log.debug("Received message:", data);
|
log.debug("Received message:", data);
|
||||||
|
|
||||||
if (data.type === 'ack' && data.nodeId) {
|
if (data.type === 'ack' && data.nodeId) {
|
||||||
const callback = this.ackCallbacks.get(data.nodeId);
|
const callback = this.ackCallbacks.get(data.nodeId);
|
||||||
if (callback) {
|
if (callback) {
|
||||||
@@ -130,7 +130,6 @@ class WebSocketManager {
|
|||||||
log.warn("WebSocket not open. Queuing message.");
|
log.warn("WebSocket not open. Queuing message.");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.messageQueue.push(message);
|
this.messageQueue.push(message);
|
||||||
if (!this.isConnecting) {
|
if (!this.isConnecting) {
|
||||||
this.connect();
|
this.connect();
|
||||||
@@ -147,7 +146,6 @@ class WebSocketManager {
|
|||||||
log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
|
log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
while (this.messageQueue.length > 0) {
|
while (this.messageQueue.length > 0) {
|
||||||
const message = this.messageQueue.shift();
|
const message = this.messageQueue.shift();
|
||||||
this.socket.send(message);
|
this.socket.send(message);
|
||||||
|
|||||||
174
js/utils/mask_utils.js
Normal file
174
js/utils/mask_utils.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import {createModuleLogger} from "./LoggerUtils.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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mask_editor_showing(app) {
|
||||||
|
const editor = get_mask_editor_element(app);
|
||||||
|
return editor && editor.style.display !== "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hide_mask_editor() {
|
||||||
|
if (mask_editor_showing()) document.getElementById('maskEditor').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) {
|
||||||
|
return editorElement?.parentElement?.lastChild?.childNodes[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_mask_editor_save_button(app) {
|
||||||
|
if (document.getElementById("maskEditor_topBarSaveButton")) return document.getElementById("maskEditor_topBarSaveButton")
|
||||||
|
return get_mask_editor_element(app)?.parentElement?.lastChild?.childNodes[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && !cancel_button.filter_listener_added) {
|
||||||
|
log.info("Cancel button found, attaching listener");
|
||||||
|
cancel_button.addEventListener('click', callback);
|
||||||
|
cancel_button.filter_listener_added = true;
|
||||||
|
return true; // Znaleziono i podłączono
|
||||||
|
} 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 (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) {
|
||||||
|
get_mask_editor_save_button(app)?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function press_maskeditor_cancel(app) {
|
||||||
|
get_mask_editor_cancel_button(app)?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uruchamia mask editor z predefiniowaną maską
|
||||||
|
* @param {Object} canvasInstance - Instancja Canvas
|
||||||
|
* @param {Image|HTMLCanvasElement} maskImage - Obraz maski do nałożenia
|
||||||
|
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
|
||||||
|
*/
|
||||||
|
export function start_mask_editor_with_predefined_mask(canvasInstance, maskImage, sendCleanImage = true) {
|
||||||
|
if (!canvasInstance || !maskImage) {
|
||||||
|
log.error('Canvas instance and mask image are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasInstance.startMaskEditor(maskImage, sendCleanImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska)
|
||||||
|
* @param {Object} canvasInstance - Instancja Canvas
|
||||||
|
*/
|
||||||
|
export function start_mask_editor_auto(canvasInstance) {
|
||||||
|
if (!canvasInstance) {
|
||||||
|
log.error('Canvas instance is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
canvasInstance.startMaskEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tworzy maskę z obrazu dla użycia w mask editorze
|
||||||
|
* @param {string} imageSrc - Źródło obrazu (URL lub data URL)
|
||||||
|
* @returns {Promise<Image>} Promise zwracający obiekt Image
|
||||||
|
*/
|
||||||
|
export function create_mask_from_image_src(imageSrc) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = imageSrc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konwertuje canvas do Image dla użycia jako maska
|
||||||
|
* @param {HTMLCanvasElement} canvas - Canvas do konwersji
|
||||||
|
* @returns {Promise<Image>} Promise zwracający obiekt Image
|
||||||
|
*/
|
||||||
|
export function canvas_to_mask_image(canvas) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = canvas.toDataURL();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "layerforge"
|
name = "layerforge"
|
||||||
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
||||||
version = "1.2.4"
|
version = "1.3.6"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||||
|
|
||||||
|
|||||||
3
python/config.py
Normal file
3
python/config.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Log level for development.
|
||||||
|
# Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE'
|
||||||
|
LOG_LEVEL = 'NONE'
|
||||||
Reference in New Issue
Block a user