mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 05:02:11 -03:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3027587d6 | ||
|
|
20d52b632a | ||
|
|
57bd1e1499 | ||
|
|
674879b497 | ||
|
|
98d4769ba1 | ||
|
|
5419acad27 | ||
|
|
db65c0c72e | ||
|
|
3c85b99167 | ||
|
|
4e55bb25bc | ||
|
|
5adc77471f | ||
|
|
3e4cdf10bc | ||
|
|
bd8007d8aa | ||
|
|
af5e81c56b | ||
|
|
aa31a347d1 | ||
|
|
dfa7309132 | ||
|
|
2ab406ebfd | ||
|
|
d40f68b8c6 | ||
|
|
e5060fd8c3 | ||
|
|
f8eb91c4ad | ||
|
|
c4af745b2a | ||
|
|
c9c0babf3c | ||
|
|
152a3f7dff | ||
|
|
9f9a733731 | ||
|
|
3419061b6c | ||
|
|
9e4da30b59 | ||
|
|
2f730c87fa | ||
|
|
aca1f4e422 | ||
|
|
195e25437a | ||
|
|
d1004d5864 | ||
|
|
d2ccfc4e20 | ||
|
|
2c313f43e8 | ||
|
|
2636521026 | ||
|
|
e0a4549321 | ||
|
|
29ab916759 | ||
|
|
ac21aa9579 | ||
|
|
cae24310db | ||
|
|
7d8fd30bbf | ||
|
|
244d48728c | ||
|
|
ef01be3323 | ||
|
|
b3d1206f3f | ||
|
|
a73a3dcf96 | ||
|
|
53aa35491e | ||
|
|
b3b901a8d6 | ||
|
|
826f448af9 | ||
|
|
42e13f1551 | ||
|
|
562b0db042 | ||
|
|
038dad759a | ||
|
|
6f4602eb31 | ||
|
|
cac7652b7d | ||
|
|
d5573f426c | ||
|
|
979fcd59bc | ||
|
|
4ec470a3ed | ||
|
|
8a456db6a0 | ||
|
|
55a60d710c | ||
|
|
e40c85b0ee | ||
|
|
145d64ea39 | ||
|
|
281350f75a | ||
|
|
dc3197e914 | ||
|
|
5a71eb46db | ||
|
|
35d3c77ba8 | ||
|
|
813df556fb | ||
|
|
6372aea90c | ||
|
|
9ab8680a85 | ||
|
|
90a0c6476f | ||
|
|
3544576605 | ||
|
|
3b16c00b66 | ||
|
|
d0ade5ebc7 | ||
|
|
9dcf38b36d | ||
|
|
7a7c8f2295 | ||
|
|
fc8ebedb1e | ||
|
|
98037324cd | ||
|
|
372a7a4718 | ||
|
|
8a18e4ec30 | ||
|
|
ade3cd7818 | ||
|
|
4a9dc3219b |
138
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
138
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Report an error or unexpected behavior
|
||||
title: "[BUG] "
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Thank you for reporting a bug!**
|
||||
Please follow these steps to capture all necessary information:
|
||||
|
||||
### ✅ Before You Report:
|
||||
1. Make sure you have the **latest versions**:
|
||||
- [ComfyUI Github](https://github.com/comfyanonymous/ComfyUI/releases)
|
||||
- [LayerForge Github](https://github.com/Azornes/Comfyui-LayerForge/releases) or via [ComfyUI Node Manager](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
|
||||
2. Gather the required logs:
|
||||
|
||||
### 🔍 Enable Debug Logs (for **full** logs):
|
||||
|
||||
#### 1. Edit `config.js` (Frontend Logs):
|
||||
Path:
|
||||
```
|
||||
ComfyUI/custom_nodes/Comfyui-LayerForge/js/config.js
|
||||
```
|
||||
Find:
|
||||
```js
|
||||
export const LOG_LEVEL = 'NONE';
|
||||
```
|
||||
Change to:
|
||||
```js
|
||||
export const LOG_LEVEL = 'DEBUG';
|
||||
```
|
||||
|
||||
#### 2. Edit `config.py` (Backend Logs):
|
||||
Path:
|
||||
```
|
||||
ComfyUI/custom_nodes/Comfyui-LayerForge/python/config.py
|
||||
```
|
||||
Find:
|
||||
```python
|
||||
LOG_LEVEL = 'NONE'
|
||||
```
|
||||
Change to:
|
||||
```python
|
||||
LOG_LEVEL = 'DEBUG'
|
||||
```
|
||||
|
||||
➡️ **Restart ComfyUI** after applying these changes to activate full logging.
|
||||
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment (OS, ComfyUI version, LayerForge version)
|
||||
placeholder: e.g. Windows 11, ComfyUI v0.3.43, LayerForge v1.2.4
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser & Version
|
||||
placeholder: e.g. Chrome 115.0.0, Firefox 120.1.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what_happened
|
||||
attributes:
|
||||
label: What Happened?
|
||||
placeholder: Describe the issue you encountered
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
placeholder: |
|
||||
1. …
|
||||
2. …
|
||||
3. …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
placeholder: Describe what you expected to happen
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
placeholder: Describe what happened instead
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: backend_logs
|
||||
attributes:
|
||||
label: ComfyUI (Backend) Logs
|
||||
description: |
|
||||
After enabling DEBUG logs, please:
|
||||
1. Restart ComfyUI.
|
||||
2. Reproduce the issue.
|
||||
3. Copy-paste the newest **TEXT** logs from the terminal/console here.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: console_logs
|
||||
attributes:
|
||||
label: Browser Console Logs
|
||||
description: |
|
||||
After enabling DEBUG logs:
|
||||
1. Open Developer Tools → Console.
|
||||
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
|
||||
Mac: `Cmd+Option+J`
|
||||
- Firefox (Win/Linux): `Ctrl+Shift+K`
|
||||
Mac: `Cmd+Option+K`
|
||||
- Safari (Mac): enable **Develop** menu in Preferences → Advanced, then `Cmd+Option+C`
|
||||
2. Clear console (before reproducing):
|
||||
- Chrome/Edge: “🚫 Clear console” or `Ctrl+L` (Win/Linux) / `Cmd+K` (Mac).
|
||||
- Firefox: `Ctrl+Shift+L` (newer) or `Ctrl+L` (older) (Win/Linux) / `Cmd+K` (Mac).
|
||||
- Safari: 🗑 icon or `Cmd+K`.
|
||||
3. Reproduce the issue.
|
||||
4. Copy-paste the **TEXT** logs here (no screenshots).
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually.
|
||||
Simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files.
|
||||
24
.github/ISSUE_TEMPLATE/docs_request.yml
vendored
Normal file
24
.github/ISSUE_TEMPLATE/docs_request.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: 📝 Documentation Request
|
||||
description: Suggest improvements or additions to documentation
|
||||
title: "[Docs] "
|
||||
labels: [documentation]
|
||||
body:
|
||||
- type: input
|
||||
id: doc_area
|
||||
attributes:
|
||||
label: Area of documentation
|
||||
placeholder: e.g. Getting started, Node API, Deployment guide
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: current_issue
|
||||
attributes:
|
||||
label: What's wrong or missing?
|
||||
placeholder: Describe the gap or confusing part
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: suggested_content
|
||||
attributes:
|
||||
label: How should it be improved?
|
||||
placeholder: Provide concrete suggestions or examples
|
||||
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 }}
|
||||
62
.github/workflows/release.yml
vendored
62
.github/workflows/release.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Auto Release with Version Patch
|
||||
name: Auto Release with Version Check
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,6 +12,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Pobierz pełną historię Git (potrzebne do git log)
|
||||
|
||||
- name: Extract base version from pyproject.toml
|
||||
id: version
|
||||
@@ -19,22 +21,46 @@ jobs:
|
||||
base=$(grep '^version *= *"' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
|
||||
echo "base_version=$base" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Determine unique version tag
|
||||
- name: Check if tag for this version already exists
|
||||
run: |
|
||||
TAG="v${{ steps.version.outputs.base_version }}"
|
||||
git fetch --tags
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag $TAG already exists. Skipping release."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Set version tag
|
||||
id: unique_tag
|
||||
run: |
|
||||
BASE="v${{ steps.version.outputs.base_version }}"
|
||||
TAG=$BASE
|
||||
COUNT=0
|
||||
echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Fetch remote tags
|
||||
git fetch --tags
|
||||
|
||||
while git rev-parse "$TAG" >/dev/null 2>&1; do
|
||||
COUNT=$((COUNT + 1))
|
||||
TAG="$BASE.$COUNT"
|
||||
done
|
||||
|
||||
echo "final_tag=$TAG" >> $GITHUB_OUTPUT
|
||||
# ZMIANA: Zamiast tylko ostatniego commita, pobierz historię commitów od ostatniego tagu
|
||||
- name: Get commit history since last tag
|
||||
id: commit_history
|
||||
run: |
|
||||
# Znajdź ostatni tag (jeśli istnieje)
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
|
||||
# Jeśli nie ma ostatniego tagu, użyj pustego (pobierz od początku repo)
|
||||
if [ -z "$LAST_TAG" ]; then
|
||||
RANGE="HEAD"
|
||||
else
|
||||
RANGE="$LAST_TAG..HEAD"
|
||||
fi
|
||||
|
||||
# Pobierz listę commitów (tylko subject/tytuł, format: - Commit message)
|
||||
HISTORY=$(git log --pretty=format:"- %s" $RANGE)
|
||||
|
||||
# Zastąp nowe linie na \\n, aby dobrze wyglądało w output
|
||||
HISTORY=${HISTORY//$'\n'/\\n}
|
||||
|
||||
# Jeśli brak commitów, ustaw domyślną wiadomość
|
||||
if [ -z "$HISTORY" ]; then
|
||||
HISTORY="No changes since last release."
|
||||
fi
|
||||
|
||||
echo "commit_history=$HISTORY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
@@ -43,6 +69,10 @@ jobs:
|
||||
name: Release ${{ steps.unique_tag.outputs.final_tag }}
|
||||
body: |
|
||||
📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}`
|
||||
🔁 Auto-postfix to avoid duplicate tag: `${{ steps.unique_tag.outputs.final_tag }}`
|
||||
|
||||
📝 Changes since last release:
|
||||
```
|
||||
${{ steps.commit_history.outputs.commit_history }}
|
||||
```
|
||||
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>
|
||||
|
||||
|
||||
<p align="center"><i>LayerForge is an advanced canvas node for ComfyUI, providing a Photoshop-like layer-based editing experience directly within your workflow. It extends the concept of a simple canvas with multi-layer support, masking, blend modes, precise transformations, and seamless integration with other nodes.</i></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://registry.comfy.org/publishers/azornes/nodes/layerforge">
|
||||
<img alt="Downloads" src="https://img.shields.io/badge/dynamic/json?color=2F80ED&label=Downloads&query=$.downloads&url=https://api.comfy.org/nodes/layerforge&style=for-the-badge">
|
||||
</a>
|
||||
|
||||
<a href="https://registry.comfy.org/publishers/azornes/nodes/layerforge" style="display:inline-flex; align-items:center; gap:6px;">
|
||||
<img alt="ComfyUI" src="https://img.shields.io/badge/ComfyUI-1a1a1a?style=for-the-badge&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAASFBMVEVHcEwYLtsYLtkXLtkXLdkYLtkWLdcFIdoAD95uerfI1XLR3mq3xIP8/yj0/zvw/0FSYMP5/zKMmKQtPNOuuozj8FOhrZW7x4FMWFFbAAAABnRSTlMAUrPX87KxijklAAAA00lEQVR4AX3SBw6DMAxA0UzbrIzO+9+02GkEpoWP9hPZZs06Hw75aI3k4W/+wkQtnGZNhF1I34BzalQcxkmasY0b9raklNcvLYU1GNiiOeVWauOa/XS526gRyzpV/7HeUOG9Jp6vcsvUrCPeKg/3KBKBQhoTD1dQggPWzPVfFOIgo85/kR4y6oB/8SlIEh7wvmTuKd3wgLVW1sTfRBoR7oWVqy/U2NcrWDYMINE7NUuJuoV+2fhaWmnbjzcOWnRv7XbiLh/Y9dNUqk2y0QcNwTu7wgf+/BhsPUhf4QAAAABJRU5ErkJggg==" />
|
||||
<img alt="Downloads" src="https://img.shields.io/badge/dynamic/json?color=%230D2A4A&label=&query=downloads&url=https://gist.githubusercontent.com/Azornes/912463d4edd123956066a7aaaa3ef835/raw/top_layerforge.json&style=for-the-badge" />
|
||||
</a>
|
||||
<a href='https://github.com/Azornes/Comfyui-LayerForge'>
|
||||
<img alt='GitHub Clones' src='https://img.shields.io/badge/dynamic/json?color=2F80ED&label=Clone&query=count&url=https://gist.githubusercontent.com/Azornes/5fa586b9e6938f48638fad37a1d146ae/raw/clone.json&logo=github&style=for-the-badge'>
|
||||
</a>
|
||||
<a href="https://visitorbadge.io/status?path=https%3A%2F%2Fgithub.com%2FAzornes%2FComfyui-LayerForge">
|
||||
<img src="https://api.visitorbadge.io/api/combined?path=https%3A%2F%2Fgithub.com%2FAzornes%2FComfyui-LayerForge&countColor=%2337d67a&style=for-the-badge&labelStyle=none" />
|
||||
</a>
|
||||
<img alt="Python 3.10+" src="https://img.shields.io/badge/-Python_3.10+-4B8BBE?logo=python&logoColor=FFFFFF&style=for-the-badge&logoWidth=20">
|
||||
<img alt="JavaScript" src="https://img.shields.io/badge/-JavaScript-000000?logo=javascript&logoColor=F7DF1E&style=for-the-badge&logoWidth=20">
|
||||
<a href="https://docs.comfy.org/" target="_blank" rel="noopener noreferrer">
|
||||
<img alt="ComfyUI" src="https://img.shields.io/badge/ComfyUI-1a1a1a?style=for-the-badge&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAMAAABF0y+mAAAASFBMVEVHcEwYLtsYLtkXLtkXLdkYLtkWLdcFIdoAD95uerfI1XLR3mq3xIP8/yj0/zvw/0FSYMP5/zKMmKQtPNOuuozj8FOhrZW7x4FMWFFbAAAABnRSTlMAUrPX87KxijklAAAA00lEQVR4AX3SBw6DMAxA0UzbrIzO+9+02GkEpoWP9hPZZs06Hw75aI3k4W/+wkQtnGZNhF1I34BzalQcxkmasY0b9raklNcvLYU1GNiiOeVWauOa/XS526gRyzpV/7HeUOG9Jp6vcsvUrCPeKg/3KBKBQhoTD1dQggPWzPVfFOIgo85/kR4y6oB/8SlIEh7wvmTuKd3wgLVW1sTfRBoR7oWVqy/U2NcrWDYMINE7NUuJuoV+2fhaWmnbjzcOWnRv7XbiLh/Y9dNUqk2y0QcNwTu7wgf+/BhsPUhf4QAAAABJRU5ErkJggg==" />
|
||||
</a>
|
||||
|
||||
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
|
||||
### Why LayerForge?
|
||||
|
||||
- **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without
|
||||
@@ -149,7 +147,7 @@ optional feature and requires a model.
|
||||
> - **Download from**:
|
||||
>
|
||||
- [Hugging Face](https://huggingface.co/ZhengPeng7/BiRefNet/tree/main) (Recommended)
|
||||
> - [Google Drive](https://drive.google.com/drive/folders/1BCLInCLH89fmTpYoP8Sgs_Eqww28f_wq?usp=sharing)
|
||||
- [Google Drive](https://drive.google.com/drive/folders/1BCLInCLH89fmTpYoP8Sgs_Eqww28f_wq?usp=sharing)
|
||||
> - **Installation Path**: Place the model file in `ComfyUI/models/BiRefNet/`.
|
||||
|
||||
---
|
||||
|
||||
203
canvas_node.py
203
canvas_node.py
@@ -10,7 +10,12 @@ import threading
|
||||
import os
|
||||
from tqdm import tqdm
|
||||
from torchvision import transforms
|
||||
from transformers import AutoModelForImageSegmentation, PretrainedConfig
|
||||
try:
|
||||
from transformers import AutoModelForImageSegmentation, PretrainedConfig
|
||||
from requests.exceptions import ConnectionError as RequestsConnectionError
|
||||
TRANSFORMERS_AVAILABLE = True
|
||||
except ImportError:
|
||||
TRANSFORMERS_AVAILABLE = False
|
||||
import torch.nn.functional as F
|
||||
import traceback
|
||||
import uuid
|
||||
@@ -23,8 +28,9 @@ import os
|
||||
|
||||
try:
|
||||
from python.logger import logger, LogLevel, debug, info, warn, error, exception
|
||||
from python.config import LOG_LEVEL
|
||||
|
||||
logger.set_module_level('canvas_node', LogLevel.NONE)
|
||||
logger.set_module_level('canvas_node', LogLevel[LOG_LEVEL])
|
||||
|
||||
logger.configure({
|
||||
'log_to_file': True,
|
||||
@@ -168,8 +174,10 @@ class CanvasNode:
|
||||
return {
|
||||
"required": {
|
||||
"fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}),
|
||||
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}),
|
||||
"node_id": ("STRING", {"default": "0", "hidden": True}),
|
||||
"show_preview": ("BOOLEAN", {"default": False, "label_on": "Show Preview", "label_off": "Hide Preview"}),
|
||||
"auto_refresh_after_generation": ("BOOLEAN", {"default": False, "label_on": "True", "label_off": "False"}),
|
||||
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
|
||||
"node_id": ("STRING", {"default": "0"}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": ("PROMPT",),
|
||||
@@ -231,7 +239,7 @@ class CanvasNode:
|
||||
|
||||
_processing_lock = threading.Lock()
|
||||
|
||||
def process_canvas_image(self, fit_on_add, trigger, node_id, prompt=None, unique_id=None):
|
||||
def process_canvas_image(self, fit_on_add, show_preview, auto_refresh_after_generation, trigger, node_id, prompt=None, unique_id=None):
|
||||
|
||||
try:
|
||||
|
||||
@@ -327,6 +335,24 @@ class CanvasNode:
|
||||
latest_image_path = max(image_files, key=os.path.getctime)
|
||||
return latest_image_path
|
||||
|
||||
@classmethod
|
||||
def get_latest_images(cls, since_timestamp=0):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
files = []
|
||||
for f_name in os.listdir(output_dir):
|
||||
file_path = os.path.join(output_dir, f_name)
|
||||
if os.path.isfile(file_path) and file_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
|
||||
try:
|
||||
mtime = os.path.getmtime(file_path)
|
||||
if mtime > since_timestamp:
|
||||
files.append((mtime, file_path))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
files.sort(key=lambda x: x[0])
|
||||
|
||||
return [f[1] for f in files]
|
||||
|
||||
@classmethod
|
||||
def get_flow_status(cls, flow_id=None):
|
||||
|
||||
@@ -366,7 +392,7 @@ class CanvasNode:
|
||||
def setup_routes(cls):
|
||||
@PromptServer.instance.routes.get("/layerforge/canvas_ws")
|
||||
async def handle_canvas_websocket(request):
|
||||
ws = web.WebSocketResponse()
|
||||
ws = web.WebSocketResponse(max_msg_size=33554432)
|
||||
await ws.prepare(request)
|
||||
|
||||
async for msg in ws:
|
||||
@@ -448,6 +474,30 @@ class CanvasNode:
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
@PromptServer.instance.routes.get("/layerforge/get-latest-images/{since}")
|
||||
async def get_latest_images_route(request):
|
||||
try:
|
||||
since_timestamp = float(request.match_info.get('since', 0))
|
||||
# JS Timestamps are in milliseconds, Python's are in seconds
|
||||
latest_image_paths = cls.get_latest_images(since_timestamp / 1000.0)
|
||||
|
||||
images_data = []
|
||||
for image_path in latest_image_paths:
|
||||
with open(image_path, "rb") as f:
|
||||
encoded_string = base64.b64encode(f.read()).decode('utf-8')
|
||||
images_data.append(f"data:image/png;base64,{encoded_string}")
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'images': images_data
|
||||
})
|
||||
except Exception as e:
|
||||
log_error(f"Error in get_latest_images_route: {str(e)}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.get("/ycnode/get_latest_image")
|
||||
async def get_latest_image_route(request):
|
||||
try:
|
||||
@@ -470,6 +520,70 @@ class CanvasNode:
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
@PromptServer.instance.routes.post("/ycnode/load_image_from_path")
|
||||
async def load_image_from_path_route(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get('file_path')
|
||||
|
||||
if not file_path:
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': 'file_path is required'
|
||||
}, status=400)
|
||||
|
||||
log_info(f"Attempting to load image from path: {file_path}")
|
||||
|
||||
# Check if file exists and is accessible
|
||||
if not os.path.exists(file_path):
|
||||
log_warn(f"File not found: {file_path}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f'File not found: {file_path}'
|
||||
}, status=404)
|
||||
|
||||
# Check if it's an image file
|
||||
valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.avif')
|
||||
if not file_path.lower().endswith(valid_extensions):
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f'Invalid image file extension. Supported: {valid_extensions}'
|
||||
}, status=400)
|
||||
|
||||
# Try to load and convert the image
|
||||
try:
|
||||
with Image.open(file_path) as img:
|
||||
# Convert to RGB if necessary
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Convert to base64
|
||||
buffered = io.BytesIO()
|
||||
img.save(buffered, format="PNG")
|
||||
img_str = base64.b64encode(buffered.getvalue()).decode('utf-8')
|
||||
|
||||
log_info(f"Successfully loaded image from path: {file_path}")
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'image_data': f"data:image/png;base64,{img_str}",
|
||||
'width': img.width,
|
||||
'height': img.height
|
||||
})
|
||||
|
||||
except Exception as img_error:
|
||||
log_error(f"Error processing image file {file_path}: {str(img_error)}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': f'Error processing image file: {str(img_error)}'
|
||||
}, status=500)
|
||||
|
||||
except Exception as e:
|
||||
log_error(f"Error in load_image_from_path_route: {str(e)}")
|
||||
return web.json_response({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
def store_image(self, image_data):
|
||||
|
||||
if isinstance(image_data, str) and image_data.startswith('data:image'):
|
||||
@@ -499,44 +613,47 @@ class BiRefNetMatting:
|
||||
"models")
|
||||
|
||||
def load_model(self, model_path):
|
||||
from json.decoder import JSONDecodeError
|
||||
try:
|
||||
if model_path not in self.model_cache:
|
||||
|
||||
full_model_path = os.path.join(self.base_path, "BiRefNet")
|
||||
|
||||
log_info(f"Loading BiRefNet model from {full_model_path}...")
|
||||
|
||||
try:
|
||||
|
||||
self.model = AutoModelForImageSegmentation.from_pretrained(
|
||||
"ZhengPeng7/BiRefNet",
|
||||
trust_remote_code=True,
|
||||
cache_dir=full_model_path
|
||||
)
|
||||
|
||||
self.model.eval()
|
||||
if torch.cuda.is_available():
|
||||
self.model = self.model.cuda()
|
||||
|
||||
self.model_cache[model_path] = self.model
|
||||
log_info("Model loaded successfully from Hugging Face")
|
||||
log_debug(f"Model type: {type(self.model)}")
|
||||
log_debug(f"Model device: {next(self.model.parameters()).device}")
|
||||
|
||||
except JSONDecodeError as e:
|
||||
log_error(f"JSONDecodeError: Failed to load model from {full_model_path}. The model's config.json may be corrupted.")
|
||||
raise RuntimeError(
|
||||
"The matting model's configuration file (config.json) appears to be corrupted. "
|
||||
f"Please manually delete the directory '{full_model_path}' and try again. "
|
||||
"This will force a fresh download of the model."
|
||||
) from e
|
||||
except Exception as e:
|
||||
log_error(f"Failed to load model: {str(e)}")
|
||||
raise
|
||||
|
||||
log_error(f"Failed to load model from Hugging Face: {str(e)}")
|
||||
# Re-raise with a more informative message
|
||||
raise RuntimeError(
|
||||
"Failed to download or load the matting model. "
|
||||
"This could be due to a network issue, file permissions, or a corrupted model cache. "
|
||||
f"Please check your internet connection and the model cache path: {full_model_path}. "
|
||||
f"Original error: {str(e)}"
|
||||
) from e
|
||||
else:
|
||||
self.model = self.model_cache[model_path]
|
||||
log_debug("Using cached model")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
# Catch the re-raised exception or any other error
|
||||
log_error(f"Error loading model: {str(e)}")
|
||||
log_exception("Model loading failed")
|
||||
return False
|
||||
raise # Re-raise the exception to be caught by the execute method
|
||||
|
||||
def preprocess_image(self, image):
|
||||
|
||||
@@ -566,11 +683,9 @@ class BiRefNetMatting:
|
||||
|
||||
def execute(self, image, model_path, threshold=0.5, refinement=1):
|
||||
try:
|
||||
|
||||
PromptServer.instance.send_sync("matting_status", {"status": "processing"})
|
||||
|
||||
if not self.load_model(model_path):
|
||||
raise RuntimeError("Failed to load model")
|
||||
self.load_model(model_path)
|
||||
|
||||
if isinstance(image, torch.Tensor):
|
||||
original_size = image.shape[-2:] if image.dim() == 4 else image.shape[-2:]
|
||||
@@ -647,25 +762,31 @@ _matting_lock = None
|
||||
async def matting(request):
|
||||
global _matting_lock
|
||||
|
||||
if not TRANSFORMERS_AVAILABLE:
|
||||
log_error("Matting request failed: 'transformers' library is not installed.")
|
||||
return web.json_response({
|
||||
"error": "Dependency Not Found",
|
||||
"details": "The 'transformers' library is required for the matting feature. Please install it by running: pip install transformers"
|
||||
}, status=400)
|
||||
|
||||
if _matting_lock is not None:
|
||||
log_warn("Matting already in progress, rejecting request")
|
||||
return web.json_response({
|
||||
"error": "Another matting operation is in progress",
|
||||
"details": "Please wait for the current operation to complete"
|
||||
}, status=429) # 429 Too Many Requests
|
||||
}, status=429)
|
||||
|
||||
_matting_lock = True
|
||||
|
||||
try:
|
||||
log_info("Received matting request")
|
||||
data = await request.json()
|
||||
|
||||
matting = BiRefNetMatting()
|
||||
matting_instance = BiRefNetMatting()
|
||||
|
||||
image_tensor, original_alpha = convert_base64_to_tensor(data["image"])
|
||||
log_debug(f"Input image shape: {image_tensor.shape}")
|
||||
|
||||
matted_image, alpha_mask = matting.execute(
|
||||
matted_image, alpha_mask = matting_instance.execute(
|
||||
image_tensor,
|
||||
"BiRefNet/model.safetensors",
|
||||
threshold=data.get("threshold", 0.5),
|
||||
@@ -680,14 +801,32 @@ async def matting(request):
|
||||
"alpha_mask": result_mask
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_exception(f"Error in matting endpoint: {str(e)}")
|
||||
except RequestsConnectionError as e:
|
||||
log_error(f"Connection error during matting model download: {e}")
|
||||
return web.json_response({
|
||||
"error": str(e),
|
||||
"error": "Network Connection Error",
|
||||
"details": "Failed to download the matting model from Hugging Face. Please check your internet connection."
|
||||
}, status=400)
|
||||
except RuntimeError as e:
|
||||
log_error(f"Runtime error during matting: {e}")
|
||||
return web.json_response({
|
||||
"error": "Matting Model Error",
|
||||
"details": str(e)
|
||||
}, status=500)
|
||||
except Exception as e:
|
||||
log_exception(f"Error in matting endpoint: {e}")
|
||||
# Check for offline error message from Hugging Face
|
||||
if "Offline mode is enabled" in str(e) or "Can't load 'ZhengPeng7/BiRefNet' offline" in str(e):
|
||||
return web.json_response({
|
||||
"error": "Network Connection Error",
|
||||
"details": "Failed to download the matting model from Hugging Face. Please check your internet connection and ensure you are not in offline mode."
|
||||
}, status=400)
|
||||
|
||||
return web.json_response({
|
||||
"error": "An unexpected error occurred",
|
||||
"details": traceback.format_exc()
|
||||
}, status=500)
|
||||
finally:
|
||||
|
||||
_matting_lock = None
|
||||
log_debug("Matting lock released")
|
||||
|
||||
|
||||
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 |
213
js/BatchPreviewManager.js
Normal file
213
js/BatchPreviewManager.js
Normal file
@@ -0,0 +1,213 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('BatchPreviewManager');
|
||||
export class BatchPreviewManager {
|
||||
constructor(canvas, initialPosition = { x: 0, y: 0 }, generationArea = null) {
|
||||
this.canvas = canvas;
|
||||
this.active = false;
|
||||
this.layers = [];
|
||||
this.currentIndex = 0;
|
||||
this.element = null;
|
||||
this.counterElement = null;
|
||||
this.uiInitialized = false;
|
||||
this.maskWasVisible = false;
|
||||
this.worldX = initialPosition.x;
|
||||
this.worldY = initialPosition.y;
|
||||
this.isDragging = false;
|
||||
this.generationArea = generationArea;
|
||||
}
|
||||
updateScreenPosition(viewport) {
|
||||
if (!this.active || !this.element)
|
||||
return;
|
||||
const screenX = (this.worldX - viewport.x) * viewport.zoom;
|
||||
const screenY = (this.worldY - viewport.y) * viewport.zoom;
|
||||
const scale = 1;
|
||||
this.element.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`;
|
||||
}
|
||||
_createUI() {
|
||||
if (this.uiInitialized)
|
||||
return;
|
||||
this.element = document.createElement('div');
|
||||
this.element.id = 'layerforge-batch-preview';
|
||||
this.element.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #333;
|
||||
color: white;
|
||||
padding: 8px 15px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
font-family: sans-serif;
|
||||
z-index: 1001;
|
||||
border: 1px solid #555;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
`;
|
||||
this.element.addEventListener('mousedown', (e) => {
|
||||
if (e.target.tagName === 'BUTTON')
|
||||
return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.isDragging = true;
|
||||
const handleMouseMove = (moveEvent) => {
|
||||
if (this.isDragging) {
|
||||
const deltaX = moveEvent.movementX / this.canvas.viewport.zoom;
|
||||
const deltaY = moveEvent.movementY / this.canvas.viewport.zoom;
|
||||
this.worldX += deltaX;
|
||||
this.worldY += deltaY;
|
||||
// The render loop will handle updating the screen position, but we need to trigger it.
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
this.isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
const prevButton = this._createButton('◀', 'Previous'); // Left arrow
|
||||
const nextButton = this._createButton('▶', 'Next'); // Right arrow
|
||||
const confirmButton = this._createButton('✔', 'Confirm'); // Checkmark
|
||||
const cancelButton = this._createButton('✖', 'Cancel All');
|
||||
const closeButton = this._createButton('➲', 'Close');
|
||||
this.counterElement = document.createElement('span');
|
||||
this.counterElement.style.minWidth = '40px';
|
||||
this.counterElement.style.textAlign = 'center';
|
||||
this.counterElement.style.fontWeight = 'bold';
|
||||
prevButton.onclick = () => this.navigate(-1);
|
||||
nextButton.onclick = () => this.navigate(1);
|
||||
confirmButton.onclick = () => this.confirm();
|
||||
cancelButton.onclick = () => this.cancelAndRemoveAll();
|
||||
closeButton.onclick = () => this.hide();
|
||||
this.element.append(prevButton, this.counterElement, nextButton, confirmButton, cancelButton, closeButton);
|
||||
if (this.canvas.canvas.parentElement) {
|
||||
this.canvas.canvas.parentElement.appendChild(this.element);
|
||||
}
|
||||
else {
|
||||
log.error("Could not find parent node to attach batch preview UI.");
|
||||
}
|
||||
this.uiInitialized = true;
|
||||
}
|
||||
_createButton(innerHTML, title) {
|
||||
const button = document.createElement('button');
|
||||
button.innerHTML = innerHTML;
|
||||
button.title = title;
|
||||
button.style.cssText = `
|
||||
background: #555;
|
||||
color: white;
|
||||
border: 1px solid #777;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
button.onmouseover = () => button.style.background = '#666';
|
||||
button.onmouseout = () => button.style.background = '#555';
|
||||
return button;
|
||||
}
|
||||
show(layers) {
|
||||
if (!layers || layers.length <= 1) {
|
||||
return;
|
||||
}
|
||||
this._createUI();
|
||||
// Auto-hide mask logic
|
||||
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
||||
if (this.maskWasVisible) {
|
||||
this.canvas.maskTool.toggleOverlayVisibility();
|
||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.remove('primary');
|
||||
toggleBtn.textContent = "Hide Mask";
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
log.info(`Showing batch preview for ${layers.length} layers.`);
|
||||
this.layers = layers;
|
||||
this.currentIndex = 0;
|
||||
if (this.element) {
|
||||
this.element.style.display = 'flex';
|
||||
}
|
||||
this.active = true;
|
||||
if (this.element) {
|
||||
const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom;
|
||||
const paddingInWorld = 20 / this.canvas.viewport.zoom;
|
||||
this.worldX -= menuWidthInWorld / 2;
|
||||
this.worldY += paddingInWorld;
|
||||
}
|
||||
this._update();
|
||||
}
|
||||
hide() {
|
||||
log.info('Hiding batch preview.');
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
}
|
||||
this.active = false;
|
||||
const index = this.canvas.batchPreviewManagers.indexOf(this);
|
||||
if (index > -1) {
|
||||
this.canvas.batchPreviewManagers.splice(index, 1);
|
||||
}
|
||||
this.canvas.render();
|
||||
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
||||
this.canvas.maskTool.toggleOverlayVisibility();
|
||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.add('primary');
|
||||
toggleBtn.textContent = "Show Mask";
|
||||
}
|
||||
}
|
||||
this.maskWasVisible = false;
|
||||
this.canvas.layers.forEach((l) => 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() {
|
||||
if (this.counterElement) {
|
||||
this.counterElement.textContent = `${this.currentIndex + 1} / ${this.layers.length}`;
|
||||
}
|
||||
this._focusOnLayer(this.layers[this.currentIndex]);
|
||||
}
|
||||
_focusOnLayer(layer) {
|
||||
if (!layer)
|
||||
return;
|
||||
log.debug(`Focusing on layer ${layer.id}`);
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
782
js/Canvas.js
782
js/Canvas.js
@@ -1,118 +1,447 @@
|
||||
import {removeImage} from "./db.js";
|
||||
import {MaskTool} from "./MaskTool.js";
|
||||
import {CanvasState} from "./CanvasState.js";
|
||||
import {CanvasInteractions} from "./CanvasInteractions.js";
|
||||
import {CanvasLayers} from "./CanvasLayers.js";
|
||||
import {CanvasRenderer} from "./CanvasRenderer.js";
|
||||
import {CanvasIO} from "./CanvasIO.js";
|
||||
import {ImageReferenceManager} from "./ImageReferenceManager.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
// @ts-ignore
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { MaskTool } from "./MaskTool.js";
|
||||
import { CanvasState } from "./CanvasState.js";
|
||||
import { CanvasInteractions } from "./CanvasInteractions.js";
|
||||
import { CanvasLayers } from "./CanvasLayers.js";
|
||||
import { CanvasLayersPanel } from "./CanvasLayersPanel.js";
|
||||
import { CanvasRenderer } from "./CanvasRenderer.js";
|
||||
import { CanvasIO } from "./CanvasIO.js";
|
||||
import { ImageReferenceManager } from "./ImageReferenceManager.js";
|
||||
import { BatchPreviewManager } from "./BatchPreviewManager.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { debounce } from "./utils/CommonUtils.js";
|
||||
import { CanvasMask } from "./CanvasMask.js";
|
||||
import { CanvasSelection } from "./CanvasSelection.js";
|
||||
const useChainCallback = (original, next) => {
|
||||
if (original === undefined || original === null) {
|
||||
return next;
|
||||
}
|
||||
return function (...args) {
|
||||
const originalReturn = original.apply(this, args);
|
||||
const nextReturn = next.apply(this, args);
|
||||
return nextReturn === undefined ? originalReturn : nextReturn;
|
||||
};
|
||||
};
|
||||
const log = createModuleLogger('Canvas');
|
||||
|
||||
/**
|
||||
* Canvas - Fasada dla systemu rysowania
|
||||
*
|
||||
* Klasa Canvas pełni rolę fasady, oferując uproszczony interfejs wysokiego poziomu
|
||||
* dla złożonego systemu rysowania. Zamiast eksponować wszystkie metody modułów,
|
||||
* udostępnia tylko kluczowe operacje i umożliwia bezpośredni dostęp do modułów
|
||||
* gdy potrzebna jest bardziej szczegółowa kontrola.
|
||||
*/
|
||||
export class Canvas {
|
||||
constructor(node, widget, callbacks = {}) {
|
||||
this.node = node;
|
||||
this.widget = widget;
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
const ctx = this.canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx)
|
||||
throw new Error("Could not create canvas context");
|
||||
this.ctx = ctx;
|
||||
this.width = 512;
|
||||
this.height = 512;
|
||||
this.layers = [];
|
||||
this.selectedLayer = null;
|
||||
this.selectedLayers = [];
|
||||
this.onSelectionChange = null;
|
||||
this.onStateChange = callbacks.onStateChange || null;
|
||||
this.lastMousePosition = {x: 0, y: 0};
|
||||
|
||||
this.onStateChange = callbacks.onStateChange;
|
||||
this.onHistoryChange = callbacks.onHistoryChange;
|
||||
this.lastMousePosition = { x: 0, y: 0 };
|
||||
this.viewport = {
|
||||
x: -(this.width / 4),
|
||||
y: -(this.height / 4),
|
||||
zoom: 0.8,
|
||||
};
|
||||
|
||||
this.offscreenCanvas = document.createElement('canvas');
|
||||
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
|
||||
alpha: false
|
||||
});
|
||||
|
||||
this.dataInitialized = false;
|
||||
this.pendingDataCheck = null;
|
||||
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
|
||||
this.initCanvas();
|
||||
this.imageCache = new Map();
|
||||
this.requestSaveState = () => { };
|
||||
this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange });
|
||||
this.canvasMask = new CanvasMask(this);
|
||||
this.canvasState = new CanvasState(this);
|
||||
this.canvasSelection = new CanvasSelection(this);
|
||||
this.canvasInteractions = new CanvasInteractions(this);
|
||||
this.canvasLayers = new CanvasLayers(this);
|
||||
this.canvasLayersPanel = new CanvasLayersPanel(this);
|
||||
this.canvasRenderer = new CanvasRenderer(this);
|
||||
this.canvasIO = new CanvasIO(this);
|
||||
this.imageReferenceManager = new ImageReferenceManager(this);
|
||||
this.batchPreviewManagers = [];
|
||||
this.pendingBatchContext = null;
|
||||
this.interaction = this.canvasInteractions.interaction;
|
||||
|
||||
this.setupEventListeners();
|
||||
this.initNodeData();
|
||||
|
||||
this.layers = this.layers.map(layer => ({
|
||||
this.previewVisible = false;
|
||||
this.isMouseOver = false;
|
||||
this._initializeModules();
|
||||
this._setupCanvas();
|
||||
log.debug('Canvas widget element:', this.node);
|
||||
log.info('Canvas initialized', {
|
||||
nodeId: this.node.id,
|
||||
dimensions: { width: this.width, height: this.height },
|
||||
viewport: this.viewport
|
||||
});
|
||||
this.previewVisible = false;
|
||||
}
|
||||
async waitForWidget(name, node, interval = 100, timeout = 20000) {
|
||||
const startTime = Date.now();
|
||||
return new Promise((resolve, reject) => {
|
||||
const check = () => {
|
||||
const widget = node.widgets.find((w) => w.name === name);
|
||||
if (widget) {
|
||||
resolve(widget);
|
||||
}
|
||||
else if (Date.now() - startTime > timeout) {
|
||||
reject(new Error(`Widget "${name}" not found within timeout.`));
|
||||
}
|
||||
else {
|
||||
setTimeout(check, interval);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Kontroluje widoczność podglądu canvas
|
||||
* @param {boolean} visible - Czy podgląd ma być widoczny
|
||||
*/
|
||||
async setPreviewVisibility(visible) {
|
||||
this.previewVisible = visible;
|
||||
log.info("Canvas preview visibility set to:", visible);
|
||||
const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node);
|
||||
if (imagePreviewWidget) {
|
||||
log.debug("Found $$canvas-image-preview widget, controlling visibility");
|
||||
if (visible) {
|
||||
if (imagePreviewWidget.options) {
|
||||
imagePreviewWidget.options.hidden = false;
|
||||
}
|
||||
if ('visible' in imagePreviewWidget) {
|
||||
imagePreviewWidget.visible = true;
|
||||
}
|
||||
if ('hidden' in imagePreviewWidget) {
|
||||
imagePreviewWidget.hidden = false;
|
||||
}
|
||||
imagePreviewWidget.computeSize = function () {
|
||||
return [0, 250]; // Szerokość 0 (auto), wysokość 250
|
||||
};
|
||||
}
|
||||
else {
|
||||
if (imagePreviewWidget.options) {
|
||||
imagePreviewWidget.options.hidden = true;
|
||||
}
|
||||
if ('visible' in imagePreviewWidget) {
|
||||
imagePreviewWidget.visible = false;
|
||||
}
|
||||
if ('hidden' in imagePreviewWidget) {
|
||||
imagePreviewWidget.hidden = true;
|
||||
}
|
||||
imagePreviewWidget.computeSize = function () {
|
||||
return [0, 0]; // Szerokość 0, wysokość 0
|
||||
};
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
else {
|
||||
log.warn("$$canvas-image-preview widget not found in Canvas.js");
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Inicjalizuje moduły systemu canvas
|
||||
* @private
|
||||
*/
|
||||
_initializeModules() {
|
||||
log.debug('Initializing Canvas modules...');
|
||||
// Stwórz opóźnioną wersję funkcji zapisu stanu
|
||||
this.requestSaveState = debounce(() => this.saveState(), 500);
|
||||
this._setupAutoRefreshHandlers();
|
||||
log.debug('Canvas modules initialized successfully');
|
||||
}
|
||||
/**
|
||||
* Konfiguruje podstawowe właściwości canvas
|
||||
* @private
|
||||
*/
|
||||
_setupCanvas() {
|
||||
this.initCanvas();
|
||||
this.canvasInteractions.setupEventListeners();
|
||||
this.canvasIO.initNodeData();
|
||||
this.layers = this.layers.map((layer) => ({
|
||||
...layer,
|
||||
opacity: 1
|
||||
}));
|
||||
|
||||
this.imageCache = new Map();
|
||||
}
|
||||
|
||||
async loadStateFromDB() {
|
||||
return this.canvasState.loadStateFromDB();
|
||||
}
|
||||
|
||||
async saveStateToDB(immediate = false) {
|
||||
return this.canvasState.saveStateToDB(immediate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje stan canvas z bazy danych
|
||||
*/
|
||||
async loadInitialState() {
|
||||
log.info("Loading initial state for node:", this.node.id);
|
||||
const loaded = await this.loadStateFromDB();
|
||||
const loaded = await this.canvasState.loadStateFromDB();
|
||||
if (!loaded) {
|
||||
log.info("No saved state found, initializing from node data.");
|
||||
await this.initNodeData();
|
||||
await this.canvasIO.initNodeData();
|
||||
}
|
||||
this.saveState();
|
||||
this.render();
|
||||
}
|
||||
|
||||
_notifyStateChange() {
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
// Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisuje obecny stan
|
||||
* @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii
|
||||
*/
|
||||
saveState(replaceLast = false) {
|
||||
log.debug('Saving canvas state', { replaceLast, layersCount: this.layers.length });
|
||||
this.canvasState.saveState(replaceLast);
|
||||
this.incrementOperationCount();
|
||||
this._notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cofnij ostatnią operację
|
||||
*/
|
||||
undo() {
|
||||
log.info('Performing undo operation');
|
||||
const historyInfo = this.canvasState.getHistoryInfo();
|
||||
log.debug('History state before undo:', historyInfo);
|
||||
this.canvasState.undo();
|
||||
this.incrementOperationCount();
|
||||
this._notifyStateChange();
|
||||
// Powiadom panel warstw o zmianie stanu warstw
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
this.canvasLayersPanel.onSelectionChanged();
|
||||
}
|
||||
log.debug('Undo completed, layers count:', this.layers.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ponów cofniętą operację
|
||||
*/
|
||||
redo() {
|
||||
log.info('Performing redo operation');
|
||||
const historyInfo = this.canvasState.getHistoryInfo();
|
||||
log.debug('History state before redo:', historyInfo);
|
||||
this.canvasState.redo();
|
||||
this.incrementOperationCount();
|
||||
this._notifyStateChange();
|
||||
}
|
||||
|
||||
updateSelectionAfterHistory() {
|
||||
const newSelectedLayers = [];
|
||||
if (this.selectedLayers) {
|
||||
this.selectedLayers.forEach(sl => {
|
||||
const found = this.layers.find(l => l.id === sl.id);
|
||||
if (found) newSelectedLayers.push(found);
|
||||
});
|
||||
// Powiadom panel warstw o zmianie stanu warstw
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
this.canvasLayersPanel.onSelectionChanged();
|
||||
}
|
||||
this.updateSelection(newSelectedLayers);
|
||||
log.debug('Redo completed, layers count:', this.layers.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderuje canvas
|
||||
*/
|
||||
render() {
|
||||
this.canvasRenderer.render();
|
||||
}
|
||||
/**
|
||||
* Dodaje warstwę z obrazem
|
||||
* @param {Image} image - Obraz do dodania
|
||||
* @param {Object} layerProps - Właściwości warstwy
|
||||
* @param {string} addMode - Tryb dodawania
|
||||
*/
|
||||
async addLayer(image, layerProps = {}, addMode = 'default') {
|
||||
const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
|
||||
// Powiadom panel warstw o dodaniu nowej warstwy
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Usuwa wybrane warstwy
|
||||
*/
|
||||
removeLayersByIds(layerIds) {
|
||||
if (!layerIds || layerIds.length === 0)
|
||||
return;
|
||||
const initialCount = this.layers.length;
|
||||
this.saveState();
|
||||
this.layers = this.layers.filter((l) => !layerIds.includes(l.id));
|
||||
// If the current selection was part of the removal, clear it
|
||||
const newSelection = this.canvasSelection.selectedLayers.filter((l) => !layerIds.includes(l.id));
|
||||
this.canvasSelection.updateSelection(newSelection);
|
||||
this.render();
|
||||
this.saveState();
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
log.info(`Removed ${initialCount - this.layers.length} layers by ID.`);
|
||||
}
|
||||
removeSelectedLayers() {
|
||||
return this.canvasSelection.removeSelectedLayers();
|
||||
}
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
_setupAutoRefreshHandlers() {
|
||||
let lastExecutionStartTime = 0;
|
||||
// Helper function to get auto-refresh value from node widget
|
||||
const getAutoRefreshValue = () => {
|
||||
const widget = this.node.widgets.find((w) => w.name === 'auto_refresh_after_generation');
|
||||
return widget ? widget.value : false;
|
||||
};
|
||||
const handleExecutionStart = () => {
|
||||
if (getAutoRefreshValue()) {
|
||||
lastExecutionStartTime = Date.now();
|
||||
// Store a snapshot of the context for the upcoming batch
|
||||
this.pendingBatchContext = {
|
||||
// For the menu position
|
||||
spawnPosition: {
|
||||
x: this.width / 2,
|
||||
y: this.height
|
||||
},
|
||||
// For the image placement
|
||||
outputArea: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.width,
|
||||
height: this.height
|
||||
}
|
||||
};
|
||||
log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext);
|
||||
this.render(); // Trigger render to show the pending outline immediately
|
||||
}
|
||||
};
|
||||
const handleExecutionSuccess = async () => {
|
||||
if (getAutoRefreshValue()) {
|
||||
log.info('Auto-refresh triggered, importing latest images.');
|
||||
if (!this.pendingBatchContext) {
|
||||
log.warn("execution_start did not fire, cannot process batch. Awaiting next execution.");
|
||||
return;
|
||||
}
|
||||
// Use the captured output area for image import
|
||||
const newLayers = await this.canvasIO.importLatestImages(lastExecutionStartTime, this.pendingBatchContext.outputArea);
|
||||
if (newLayers && newLayers.length > 1) {
|
||||
const newManager = new BatchPreviewManager(this, this.pendingBatchContext.spawnPosition, this.pendingBatchContext.outputArea);
|
||||
this.batchPreviewManagers.push(newManager);
|
||||
newManager.show(newLayers);
|
||||
}
|
||||
// Consume the context
|
||||
this.pendingBatchContext = null;
|
||||
// Final render to clear the outline if it was the last one
|
||||
this.render();
|
||||
}
|
||||
};
|
||||
api.addEventListener('execution_start', handleExecutionStart);
|
||||
api.addEventListener('execution_success', handleExecutionSuccess);
|
||||
this.node.onRemoved = useChainCallback(this.node.onRemoved, () => {
|
||||
log.info('Node removed, cleaning up auto-refresh listeners.');
|
||||
api.removeEventListener('execution_start', handleExecutionStart);
|
||||
api.removeEventListener('execution_success', handleExecutionSuccess);
|
||||
});
|
||||
log.debug('Auto-refresh handlers setup complete, reading from node widget: auto_refresh_after_generation');
|
||||
}
|
||||
/**
|
||||
* Uruchamia edytor masek
|
||||
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora
|
||||
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
|
||||
*/
|
||||
async startMaskEditor(predefinedMask = null, sendCleanImage = true) {
|
||||
return this.canvasMask.startMaskEditor(predefinedMask, sendCleanImage);
|
||||
}
|
||||
/**
|
||||
* Inicjalizuje podstawowe właściwości canvas
|
||||
*/
|
||||
initCanvas() {
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
this.canvas.style.border = '1px solid black';
|
||||
this.canvas.style.maxWidth = '100%';
|
||||
this.canvas.style.backgroundColor = '#606060';
|
||||
this.canvas.style.width = '100%';
|
||||
this.canvas.style.height = '100%';
|
||||
this.canvas.tabIndex = 0;
|
||||
this.canvas.style.outline = 'none';
|
||||
}
|
||||
/**
|
||||
* Pobiera współrzędne myszy w układzie świata
|
||||
* @param {MouseEvent} e - Zdarzenie myszy
|
||||
*/
|
||||
getMouseWorldCoordinates(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mouseX_DOM = e.clientX - rect.left;
|
||||
const mouseY_DOM = e.clientY - rect.top;
|
||||
if (!this.offscreenCanvas)
|
||||
throw new Error("Offscreen canvas not initialized");
|
||||
const scaleX = this.offscreenCanvas.width / rect.width;
|
||||
const scaleY = this.offscreenCanvas.height / rect.height;
|
||||
const mouseX_Buffer = mouseX_DOM * scaleX;
|
||||
const mouseY_Buffer = mouseY_DOM * scaleY;
|
||||
const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x;
|
||||
const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y;
|
||||
return { x: worldX, y: worldY };
|
||||
}
|
||||
/**
|
||||
* Pobiera współrzędne myszy w układzie widoku
|
||||
* @param {MouseEvent} e - Zdarzenie myszy
|
||||
*/
|
||||
getMouseViewCoordinates(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mouseX_DOM = e.clientX - rect.left;
|
||||
const mouseY_DOM = e.clientY - rect.top;
|
||||
const scaleX = this.canvas.width / rect.width;
|
||||
const scaleY = this.canvas.height / rect.height;
|
||||
const mouseX_Canvas = mouseX_DOM * scaleX;
|
||||
const mouseY_Canvas = mouseY_DOM * scaleY;
|
||||
return { x: mouseX_Canvas, y: mouseY_Canvas };
|
||||
}
|
||||
/**
|
||||
* Aktualizuje zaznaczenie po operacji historii
|
||||
*/
|
||||
updateSelectionAfterHistory() {
|
||||
return this.canvasSelection.updateSelectionAfterHistory();
|
||||
}
|
||||
/**
|
||||
* Aktualizuje przyciski historii
|
||||
*/
|
||||
updateHistoryButtons() {
|
||||
if (this.onHistoryChange) {
|
||||
const historyInfo = this.canvasState.getHistoryInfo();
|
||||
@@ -122,338 +451,16 @@ export class Canvas {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initCanvas() {
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
this.canvas.style.border = '1px solid black';
|
||||
this.canvas.style.maxWidth = '100%';
|
||||
this.canvas.style.backgroundColor = '#606060';
|
||||
this.canvas.style.width = '100%';
|
||||
this.canvas.style.height = '100%';
|
||||
|
||||
|
||||
this.canvas.tabIndex = 0;
|
||||
this.canvas.style.outline = 'none';
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.canvasInteractions.setupEventListeners();
|
||||
}
|
||||
|
||||
updateSelection(newSelection) {
|
||||
this.selectedLayers = newSelection || [];
|
||||
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
|
||||
if (this.onSelectionChange) {
|
||||
this.onSelectionChange();
|
||||
}
|
||||
}
|
||||
|
||||
async copySelectedLayers() {
|
||||
return this.canvasLayers.copySelectedLayers();
|
||||
}
|
||||
|
||||
pasteLayers() {
|
||||
return this.canvasLayers.pasteLayers();
|
||||
}
|
||||
|
||||
async handlePaste(addMode) {
|
||||
return this.canvasLayers.handlePaste(addMode);
|
||||
}
|
||||
|
||||
|
||||
handleMouseMove(e) {
|
||||
this.canvasInteractions.handleMouseMove(e);
|
||||
}
|
||||
|
||||
|
||||
handleMouseUp(e) {
|
||||
this.canvasInteractions.handleMouseUp(e);
|
||||
}
|
||||
|
||||
|
||||
handleMouseLeave(e) {
|
||||
this.canvasInteractions.handleMouseLeave(e);
|
||||
}
|
||||
|
||||
|
||||
handleWheel(e) {
|
||||
this.canvasInteractions.handleWheel(e);
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
this.canvasInteractions.handleKeyDown(e);
|
||||
}
|
||||
|
||||
handleKeyUp(e) {
|
||||
this.canvasInteractions.handleKeyUp(e);
|
||||
}
|
||||
|
||||
|
||||
isRotationHandle(x, y) {
|
||||
return this.canvasLayers.isRotationHandle(x, y);
|
||||
}
|
||||
|
||||
async addLayerWithImage(image, layerProps = {}, addMode = 'default') {
|
||||
return this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
|
||||
}
|
||||
|
||||
|
||||
async addLayer(image, addMode = 'default') {
|
||||
return this.addLayerWithImage(image, {}, addMode);
|
||||
}
|
||||
|
||||
async removeLayer(index) {
|
||||
if (index >= 0 && index < this.layers.length) {
|
||||
const layer = this.layers[index];
|
||||
if (layer.imageId) {
|
||||
const isImageUsedElsewhere = this.layers.some((l, i) => i !== index && l.imageId === layer.imageId);
|
||||
if (!isImageUsedElsewhere) {
|
||||
await removeImage(layer.imageId);
|
||||
this.imageCache.delete(layer.imageId);
|
||||
}
|
||||
}
|
||||
this.layers.splice(index, 1);
|
||||
this.selectedLayer = this.layers[this.layers.length - 1] || null;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
removeSelectedLayers() {
|
||||
if (this.selectedLayers.length > 0) {
|
||||
this.saveState();
|
||||
this.layers = this.layers.filter(l => !this.selectedLayers.includes(l));
|
||||
this.updateSelection([]);
|
||||
this.render();
|
||||
this.saveState();
|
||||
}
|
||||
}
|
||||
|
||||
getMouseWorldCoordinates(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
|
||||
const mouseX_DOM = e.clientX - rect.left;
|
||||
const mouseY_DOM = e.clientY - rect.top;
|
||||
|
||||
const scaleX = this.offscreenCanvas.width / rect.width;
|
||||
const scaleY = this.offscreenCanvas.height / rect.height;
|
||||
|
||||
const mouseX_Buffer = mouseX_DOM * scaleX;
|
||||
const mouseY_Buffer = mouseY_DOM * scaleY;
|
||||
|
||||
const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x;
|
||||
const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y;
|
||||
|
||||
return {x: worldX, y: worldY};
|
||||
}
|
||||
|
||||
getMouseViewCoordinates(e) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mouseX_DOM = e.clientX - rect.left;
|
||||
const mouseY_DOM = e.clientY - rect.top;
|
||||
|
||||
const scaleX = this.canvas.width / rect.width;
|
||||
const scaleY = this.canvas.height / rect.height;
|
||||
|
||||
const mouseX_Canvas = mouseX_DOM * scaleX;
|
||||
const mouseY_Canvas = mouseY_DOM * scaleY;
|
||||
|
||||
return { x: mouseX_Canvas, y: mouseY_Canvas };
|
||||
}
|
||||
|
||||
|
||||
moveLayer(fromIndex, toIndex) {
|
||||
return this.canvasLayers.moveLayer(fromIndex, toIndex);
|
||||
}
|
||||
|
||||
resizeLayer(scale) {
|
||||
this.selectedLayers.forEach(layer => {
|
||||
layer.width *= scale;
|
||||
layer.height *= scale;
|
||||
});
|
||||
this.render();
|
||||
this.saveState();
|
||||
}
|
||||
|
||||
rotateLayer(angle) {
|
||||
this.selectedLayers.forEach(layer => {
|
||||
layer.rotation += angle;
|
||||
});
|
||||
this.render();
|
||||
this.saveState();
|
||||
}
|
||||
|
||||
updateOutputAreaSize(width, height, saveHistory = true) {
|
||||
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
|
||||
}
|
||||
|
||||
render() {
|
||||
this.canvasRenderer.render();
|
||||
}
|
||||
|
||||
|
||||
getHandles(layer) {
|
||||
return this.canvasLayers.getHandles(layer);
|
||||
}
|
||||
|
||||
getHandleAtPosition(worldX, worldY) {
|
||||
return this.canvasLayers.getHandleAtPosition(worldX, worldY);
|
||||
}
|
||||
|
||||
|
||||
async getFlattenedCanvasAsBlob() {
|
||||
return this.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
}
|
||||
|
||||
async getFlattenedSelectionAsBlob() {
|
||||
return this.canvasLayers.getFlattenedSelectionAsBlob();
|
||||
}
|
||||
|
||||
moveLayerUp() {
|
||||
return this.canvasLayers.moveLayerUp();
|
||||
}
|
||||
|
||||
moveLayerDown() {
|
||||
return this.canvasLayers.moveLayerDown();
|
||||
}
|
||||
|
||||
|
||||
getLayerAtPosition(worldX, worldY) {
|
||||
return this.canvasLayers.getLayerAtPosition(worldX, worldY);
|
||||
}
|
||||
|
||||
getResizeHandle(x, y) {
|
||||
return this.canvasLayers.getResizeHandle(x, y);
|
||||
}
|
||||
|
||||
async mirrorHorizontal() {
|
||||
return this.canvasLayers.mirrorHorizontal();
|
||||
}
|
||||
|
||||
async mirrorVertical() {
|
||||
return this.canvasLayers.mirrorVertical();
|
||||
}
|
||||
|
||||
async getLayerImageData(layer) {
|
||||
return this.canvasLayers.getLayerImageData(layer);
|
||||
}
|
||||
|
||||
addMattedLayer(image, mask) {
|
||||
return this.canvasLayers.addMattedLayer(image, mask);
|
||||
}
|
||||
|
||||
async addInputToCanvas(inputImage, inputMask) {
|
||||
return this.canvasIO.addInputToCanvas(inputImage, inputMask);
|
||||
}
|
||||
|
||||
async convertTensorToImage(tensor) {
|
||||
return this.canvasIO.convertTensorToImage(tensor);
|
||||
}
|
||||
|
||||
async convertTensorToMask(tensor) {
|
||||
return this.canvasIO.convertTensorToMask(tensor);
|
||||
}
|
||||
|
||||
async initNodeData() {
|
||||
return this.canvasIO.initNodeData();
|
||||
}
|
||||
|
||||
scheduleDataCheck() {
|
||||
return this.canvasIO.scheduleDataCheck();
|
||||
}
|
||||
|
||||
async processImageData(imageData) {
|
||||
return this.canvasIO.processImageData(imageData);
|
||||
}
|
||||
|
||||
addScaledLayer(image, scale) {
|
||||
return this.canvasIO.addScaledLayer(image, scale);
|
||||
}
|
||||
|
||||
convertTensorToImageData(tensor) {
|
||||
return this.canvasIO.convertTensorToImageData(tensor);
|
||||
}
|
||||
|
||||
async createImageFromData(imageData) {
|
||||
return this.canvasIO.createImageFromData(imageData);
|
||||
}
|
||||
|
||||
async retryDataLoad(maxRetries = 3, delay = 1000) {
|
||||
return this.canvasIO.retryDataLoad(maxRetries, delay);
|
||||
}
|
||||
|
||||
async processMaskData(maskData) {
|
||||
return this.canvasIO.processMaskData(maskData);
|
||||
}
|
||||
|
||||
async loadImageFromCache(base64Data) {
|
||||
return this.canvasIO.loadImageFromCache(base64Data);
|
||||
}
|
||||
|
||||
async importImage(cacheData) {
|
||||
return this.canvasIO.importImage(cacheData);
|
||||
}
|
||||
|
||||
async importLatestImage() {
|
||||
return this.canvasIO.importLatestImage();
|
||||
}
|
||||
|
||||
showBlendModeMenu(x, y) {
|
||||
return this.canvasLayers.showBlendModeMenu(x, y);
|
||||
}
|
||||
|
||||
handleBlendModeSelection(mode) {
|
||||
return this.canvasLayers.handleBlendModeSelection(mode);
|
||||
}
|
||||
|
||||
showOpacitySlider(mode) {
|
||||
return this.canvasLayers.showOpacitySlider(mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwiększa licznik operacji (wywoływane przy każdej operacji na canvas)
|
||||
* Zwiększa licznik operacji (dla garbage collection)
|
||||
*/
|
||||
incrementOperationCount() {
|
||||
if (this.imageReferenceManager) {
|
||||
this.imageReferenceManager.incrementOperationCount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ręczne uruchomienie garbage collection
|
||||
*/
|
||||
async runGarbageCollection() {
|
||||
if (this.imageReferenceManager) {
|
||||
await this.imageReferenceManager.manualGarbageCollection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca statystyki garbage collection
|
||||
*/
|
||||
getGarbageCollectionStats() {
|
||||
if (this.imageReferenceManager) {
|
||||
const stats = this.imageReferenceManager.getStats();
|
||||
return {
|
||||
...stats,
|
||||
operationCount: this.imageReferenceManager.operationCount,
|
||||
operationThreshold: this.imageReferenceManager.operationThreshold
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustawia próg operacji dla automatycznego GC
|
||||
*/
|
||||
setGarbageCollectionThreshold(threshold) {
|
||||
if (this.imageReferenceManager) {
|
||||
this.imageReferenceManager.setOperationThreshold(threshold);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści zasoby canvas (wywoływane przy usuwaniu)
|
||||
* Czyści zasoby canvas
|
||||
*/
|
||||
destroy() {
|
||||
if (this.imageReferenceManager) {
|
||||
@@ -461,4 +468,13 @@ export class Canvas {
|
||||
}
|
||||
log.info("Canvas destroyed");
|
||||
}
|
||||
/**
|
||||
* Powiadamia o zmianie stanu
|
||||
* @private
|
||||
*/
|
||||
_notifyStateChange() {
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
439
js/CanvasIO.js
439
js/CanvasIO.js
@@ -1,91 +1,70 @@
|
||||
import {createCanvas} from "./utils/CommonUtils.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import {webSocketManager} from "./utils/WebSocketManager.js";
|
||||
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { webSocketManager } from "./utils/WebSocketManager.js";
|
||||
const log = createModuleLogger('CanvasIO');
|
||||
|
||||
export class CanvasIO {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this._saveInProgress = null;
|
||||
}
|
||||
|
||||
async saveToServer(fileName, outputMode = 'disk') {
|
||||
if (outputMode === 'disk') {
|
||||
if (!window.canvasSaveStates) {
|
||||
window.canvasSaveStates = new Map();
|
||||
}
|
||||
|
||||
const nodeId = this.canvas.node.id;
|
||||
const saveKey = `${nodeId}_${fileName}`;
|
||||
if (this._saveInProgress || window.canvasSaveStates.get(saveKey)) {
|
||||
log.warn(`Save already in progress for node ${nodeId}, waiting...`);
|
||||
return this._saveInProgress || window.canvasSaveStates.get(saveKey);
|
||||
}
|
||||
|
||||
log.info(`Starting saveToServer (disk) with fileName: ${fileName} for node: ${nodeId}`);
|
||||
this._saveInProgress = this._performSave(fileName, outputMode);
|
||||
window.canvasSaveStates.set(saveKey, this._saveInProgress);
|
||||
|
||||
try {
|
||||
return await this._saveInProgress;
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
this._saveInProgress = null;
|
||||
window.canvasSaveStates.delete(saveKey);
|
||||
log.debug(`Save completed for node ${nodeId}, lock released`);
|
||||
}
|
||||
} else {
|
||||
|
||||
}
|
||||
else {
|
||||
log.info(`Starting saveToServer (RAM) for node: ${this.canvas.node.id}`);
|
||||
return this._performSave(fileName, outputMode);
|
||||
}
|
||||
}
|
||||
|
||||
async _performSave(fileName, outputMode) {
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
await this.canvas.saveStateToDB(true);
|
||||
await this.canvas.canvasState.saveStateToDB();
|
||||
const nodeId = this.canvas.node.id;
|
||||
const delay = (nodeId % 10) * 50;
|
||||
if (delay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(this.canvas.width, this.canvas.height);
|
||||
const {canvas: maskCanvas, ctx: maskCtx} = createCanvas(this.canvas.width, this.canvas.height);
|
||||
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height);
|
||||
const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height);
|
||||
const visibilityCanvas = document.createElement('canvas');
|
||||
visibilityCanvas.width = this.canvas.width;
|
||||
visibilityCanvas.height = this.canvas.height;
|
||||
const visibilityCtx = visibilityCanvas.getContext('2d', {alpha: true});
|
||||
const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
|
||||
if (!visibilityCtx)
|
||||
throw new Error("Could not create visibility context");
|
||||
if (!maskCtx)
|
||||
throw new Error("Could not create mask context");
|
||||
if (!tempCtx)
|
||||
throw new Error("Could not create temp context");
|
||||
maskCtx.fillStyle = '#ffffff';
|
||||
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
log.debug(`Canvas contexts created, starting layer rendering`);
|
||||
const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
|
||||
log.debug(`Processing ${sortedLayers.length} layers in order`);
|
||||
sortedLayers.forEach((layer, index) => {
|
||||
log.debug(`Processing layer ${index}: zIndex=${layer.zIndex}, size=${layer.width}x${layer.height}, pos=(${layer.x},${layer.y})`);
|
||||
log.debug(`Layer ${index}: blendMode=${layer.blendMode || 'normal'}, opacity=${layer.opacity !== undefined ? layer.opacity : 1}`);
|
||||
|
||||
tempCtx.save();
|
||||
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
||||
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
||||
tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
tempCtx.restore();
|
||||
|
||||
log.debug(`Layer ${index} rendered successfully`);
|
||||
visibilityCtx.save();
|
||||
visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
||||
visibilityCtx.rotate(layer.rotation * Math.PI / 180);
|
||||
visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
visibilityCtx.restore();
|
||||
});
|
||||
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
|
||||
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
|
||||
log.debug(`Finished rendering layers`);
|
||||
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
||||
@@ -94,48 +73,35 @@ export class CanvasIO {
|
||||
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
|
||||
maskData.data[i + 3] = 255;
|
||||
}
|
||||
|
||||
maskCtx.putImageData(maskData, 0, 0);
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||
if (toolMaskCanvas) {
|
||||
|
||||
const tempMaskCanvas = document.createElement('canvas');
|
||||
tempMaskCanvas.width = this.canvas.width;
|
||||
tempMaskCanvas.height = this.canvas.height;
|
||||
const tempMaskCtx = tempMaskCanvas.getContext('2d');
|
||||
|
||||
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!tempMaskCtx)
|
||||
throw new Error("Could not create temp mask context");
|
||||
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||
|
||||
|
||||
const maskX = this.canvas.maskTool.x;
|
||||
const maskY = this.canvas.maskTool.y;
|
||||
|
||||
log.debug(`Extracting mask from world position (${maskX}, ${maskY}) for output area (0,0) to (${this.canvas.width}, ${this.canvas.height})`);
|
||||
|
||||
const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading
|
||||
const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading
|
||||
const sourceY = Math.max(0, -maskY);
|
||||
const destX = Math.max(0, maskX); // Where in the output canvas to start writing
|
||||
const destX = Math.max(0, maskX); // Where in the output canvas to start writing
|
||||
const destY = Math.max(0, maskY);
|
||||
|
||||
const copyWidth = Math.min(
|
||||
toolMaskCanvas.width - sourceX, // Available width in source
|
||||
this.canvas.width - destX // Available width in destination
|
||||
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, // Available width in source
|
||||
this.canvas.width - destX // Available width in destination
|
||||
);
|
||||
const copyHeight = Math.min(
|
||||
toolMaskCanvas.height - sourceY, // Available height in source
|
||||
this.canvas.height - destY // Available height in destination
|
||||
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, // Available height in source
|
||||
this.canvas.height - destY // Available height in destination
|
||||
);
|
||||
|
||||
if (copyWidth > 0 && copyHeight > 0) {
|
||||
log.debug(`Copying mask region: source(${sourceX}, ${sourceY}) to dest(${destX}, ${destY}) size(${copyWidth}, ${copyHeight})`);
|
||||
|
||||
tempMaskCtx.drawImage(
|
||||
toolMaskCanvas,
|
||||
sourceX, sourceY, copyWidth, copyHeight, // Source rectangle
|
||||
destX, destY, copyWidth, copyHeight // Destination rectangle
|
||||
tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, // Source rectangle
|
||||
destX, destY, copyWidth, copyHeight // Destination rectangle
|
||||
);
|
||||
}
|
||||
|
||||
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < tempMaskData.data.length; i += 4) {
|
||||
const alpha = tempMaskData.data[i + 3];
|
||||
@@ -143,7 +109,6 @@ export class CanvasIO {
|
||||
tempMaskData.data[i + 3] = alpha;
|
||||
}
|
||||
tempMaskCtx.putImageData(tempMaskData, 0, 0);
|
||||
|
||||
maskCtx.globalCompositeOperation = 'source-over';
|
||||
maskCtx.drawImage(tempMaskCanvas, 0, 0);
|
||||
}
|
||||
@@ -151,60 +116,59 @@ export class CanvasIO {
|
||||
const imageData = tempCanvas.toDataURL('image/png');
|
||||
const maskData = maskCanvas.toDataURL('image/png');
|
||||
log.info("Returning image and mask data as base64 for RAM mode.");
|
||||
resolve({image: imageData, mask: maskData});
|
||||
resolve({ image: imageData, mask: maskData });
|
||||
return;
|
||||
}
|
||||
|
||||
const fileNameWithoutMask = fileName.replace('.png', '_without_mask.png');
|
||||
log.info(`Saving image without mask as: ${fileNameWithoutMask}`);
|
||||
|
||||
tempCanvas.toBlob(async (blobWithoutMask) => {
|
||||
if (!blobWithoutMask)
|
||||
return;
|
||||
log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`);
|
||||
const formDataWithoutMask = new FormData();
|
||||
formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask);
|
||||
formDataWithoutMask.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const response = await fetch("/upload/image", {
|
||||
method: "POST",
|
||||
body: formDataWithoutMask,
|
||||
});
|
||||
log.debug(`Image without mask upload response: ${response.status}`);
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Error uploading image without mask:`, error);
|
||||
}
|
||||
}, "image/png");
|
||||
log.info(`Saving main image as: ${fileName}`);
|
||||
tempCanvas.toBlob(async (blob) => {
|
||||
if (!blob)
|
||||
return;
|
||||
log.debug(`Created blob for main image, size: ${blob.size} bytes`);
|
||||
const formData = new FormData();
|
||||
formData.append("image", blob, fileName);
|
||||
formData.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const resp = await fetch("/upload/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
log.debug(`Main image upload response: ${resp.status}`);
|
||||
|
||||
if (resp.status === 200) {
|
||||
const maskFileName = fileName.replace('.png', '_mask.png');
|
||||
log.info(`Saving mask as: ${maskFileName}`);
|
||||
|
||||
maskCanvas.toBlob(async (maskBlob) => {
|
||||
if (!maskBlob)
|
||||
return;
|
||||
log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`);
|
||||
const maskFormData = new FormData();
|
||||
maskFormData.append("image", maskBlob, maskFileName);
|
||||
maskFormData.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const maskResp = await fetch("/upload/image", {
|
||||
method: "POST",
|
||||
body: maskFormData,
|
||||
});
|
||||
log.debug(`Mask upload response: ${maskResp.status}`);
|
||||
|
||||
if (maskResp.status === 200) {
|
||||
const data = await resp.json();
|
||||
if (this.canvas.widget) {
|
||||
@@ -212,57 +176,48 @@ export class CanvasIO {
|
||||
}
|
||||
log.info(`All files saved successfully, widget value set to: ${fileName}`);
|
||||
resolve(true);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.error(`Error saving mask: ${maskResp.status}`);
|
||||
resolve(false);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Error saving mask:`, error);
|
||||
resolve(false);
|
||||
}
|
||||
}, "image/png");
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.error(`Main image upload failed: ${resp.status} - ${resp.statusText}`);
|
||||
resolve(false);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Error uploading main image:`, error);
|
||||
resolve(false);
|
||||
}
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
async _renderOutputData() {
|
||||
return new Promise((resolve) => {
|
||||
const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(this.canvas.width, this.canvas.height);
|
||||
const {canvas: maskCanvas, ctx: maskCtx} = createCanvas(this.canvas.width, this.canvas.height);
|
||||
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height);
|
||||
const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height);
|
||||
const visibilityCanvas = document.createElement('canvas');
|
||||
visibilityCanvas.width = this.canvas.width;
|
||||
visibilityCanvas.height = this.canvas.height;
|
||||
const visibilityCtx = visibilityCanvas.getContext('2d', {alpha: true});
|
||||
const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
|
||||
if (!visibilityCtx)
|
||||
throw new Error("Could not create visibility context");
|
||||
if (!maskCtx)
|
||||
throw new Error("Could not create mask context");
|
||||
if (!tempCtx)
|
||||
throw new Error("Could not create temp context");
|
||||
maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
|
||||
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
const sortedLayers = this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach((layer) => {
|
||||
|
||||
tempCtx.save();
|
||||
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
tempCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
||||
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
||||
tempCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
tempCtx.restore();
|
||||
|
||||
visibilityCtx.save();
|
||||
visibilityCtx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2);
|
||||
visibilityCtx.rotate(layer.rotation * Math.PI / 180);
|
||||
visibilityCtx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
visibilityCtx.restore();
|
||||
});
|
||||
|
||||
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
|
||||
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
|
||||
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
||||
@@ -272,64 +227,45 @@ export class CanvasIO {
|
||||
maskData.data[i + 3] = 255; // Solid mask
|
||||
}
|
||||
maskCtx.putImageData(maskData, 0, 0);
|
||||
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||
if (toolMaskCanvas) {
|
||||
|
||||
const tempMaskCanvas = document.createElement('canvas');
|
||||
tempMaskCanvas.width = this.canvas.width;
|
||||
tempMaskCanvas.height = this.canvas.height;
|
||||
const tempMaskCtx = tempMaskCanvas.getContext('2d');
|
||||
|
||||
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!tempMaskCtx)
|
||||
throw new Error("Could not create temp mask context");
|
||||
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||
|
||||
const maskX = this.canvas.maskTool.x;
|
||||
const maskY = this.canvas.maskTool.y;
|
||||
|
||||
log.debug(`[renderOutputData] Extracting mask from world position (${maskX}, ${maskY})`);
|
||||
|
||||
const sourceX = Math.max(0, -maskX);
|
||||
const sourceY = Math.max(0, -maskY);
|
||||
const destX = Math.max(0, maskX);
|
||||
const destY = Math.max(0, maskY);
|
||||
|
||||
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
|
||||
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
|
||||
|
||||
if (copyWidth > 0 && copyHeight > 0) {
|
||||
tempMaskCtx.drawImage(
|
||||
toolMaskCanvas,
|
||||
sourceX, sourceY, copyWidth, copyHeight,
|
||||
destX, destY, copyWidth, copyHeight
|
||||
);
|
||||
tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight);
|
||||
}
|
||||
|
||||
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < tempMaskData.data.length; i += 4) {
|
||||
const alpha = tempMaskData.data[i + 3];
|
||||
|
||||
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha;
|
||||
tempMaskData.data[i + 3] = 255; // Solid alpha
|
||||
}
|
||||
tempMaskCtx.putImageData(tempMaskData, 0, 0);
|
||||
|
||||
|
||||
maskCtx.globalCompositeOperation = 'screen';
|
||||
maskCtx.drawImage(tempMaskCanvas, 0, 0);
|
||||
}
|
||||
|
||||
const imageDataUrl = tempCanvas.toDataURL('image/png');
|
||||
const maskDataUrl = maskCanvas.toDataURL('image/png');
|
||||
|
||||
resolve({image: imageDataUrl, mask: maskDataUrl});
|
||||
resolve({ image: imageDataUrl, mask: maskDataUrl });
|
||||
});
|
||||
}
|
||||
|
||||
async sendDataViaWebSocket(nodeId) {
|
||||
log.info(`Preparing to send data for node ${nodeId} via WebSocket.`);
|
||||
|
||||
const {image, mask} = await this._renderOutputData();
|
||||
|
||||
const { image, mask } = await this._renderOutputData();
|
||||
try {
|
||||
log.info(`Sending data for node ${nodeId}...`);
|
||||
await webSocketManager.sendMessage({
|
||||
@@ -338,205 +274,167 @@ export class CanvasIO {
|
||||
image: image,
|
||||
mask: mask,
|
||||
}, true); // `true` requires an acknowledgment
|
||||
|
||||
log.info(`Data for node ${nodeId} has been sent and acknowledged by the server.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Failed to send data for node ${nodeId}:`, error);
|
||||
|
||||
|
||||
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
|
||||
}
|
||||
}
|
||||
|
||||
async addInputToCanvas(inputImage, inputMask) {
|
||||
try {
|
||||
log.debug("Adding input to canvas:", {inputImage});
|
||||
|
||||
const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(inputImage.width, inputImage.height);
|
||||
|
||||
const imgData = new ImageData(
|
||||
inputImage.data,
|
||||
inputImage.width,
|
||||
inputImage.height
|
||||
);
|
||||
log.debug("Adding input to canvas:", { inputImage });
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
|
||||
if (!tempCtx)
|
||||
throw new Error("Could not create temp context");
|
||||
const imgData = new ImageData(new Uint8ClampedArray(inputImage.data), inputImage.width, inputImage.height);
|
||||
tempCtx.putImageData(imgData, 0, 0);
|
||||
|
||||
const image = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = reject;
|
||||
image.src = tempCanvas.toDataURL();
|
||||
});
|
||||
|
||||
const scale = Math.min(
|
||||
this.canvas.width / inputImage.width * 0.8,
|
||||
this.canvas.height / inputImage.height * 0.8
|
||||
);
|
||||
|
||||
const layer = await this.canvas.addLayerWithImage(image, {
|
||||
const scale = Math.min(this.canvas.width / inputImage.width * 0.8, this.canvas.height / inputImage.height * 0.8);
|
||||
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
|
||||
x: (this.canvas.width - inputImage.width * scale) / 2,
|
||||
y: (this.canvas.height - inputImage.height * scale) / 2,
|
||||
width: inputImage.width * scale,
|
||||
height: inputImage.height * scale,
|
||||
});
|
||||
|
||||
if (inputMask) {
|
||||
if (inputMask && layer) {
|
||||
layer.mask = inputMask.data;
|
||||
}
|
||||
|
||||
log.info("Layer added successfully");
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error in addInputToCanvas:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async convertTensorToImage(tensor) {
|
||||
try {
|
||||
log.debug("Converting tensor to image:", tensor);
|
||||
|
||||
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
|
||||
throw new Error("Invalid tensor data");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx)
|
||||
throw new Error("Could not create canvas context");
|
||||
canvas.width = tensor.width;
|
||||
canvas.height = tensor.height;
|
||||
|
||||
const imageData = new ImageData(
|
||||
new Uint8ClampedArray(tensor.data),
|
||||
tensor.width,
|
||||
tensor.height
|
||||
);
|
||||
|
||||
const imageData = new ImageData(new Uint8ClampedArray(tensor.data), tensor.width, tensor.height);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error converting tensor to image:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async convertTensorToMask(tensor) {
|
||||
if (!tensor || !tensor.data) {
|
||||
throw new Error("Invalid mask tensor");
|
||||
}
|
||||
|
||||
try {
|
||||
return new Float32Array(tensor.data);
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
throw new Error(`Mask conversion failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async initNodeData() {
|
||||
try {
|
||||
log.info("Starting node data initialization...");
|
||||
|
||||
if (!this.canvas.node || !this.canvas.node.inputs) {
|
||||
log.debug("Node or inputs not ready");
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
|
||||
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
|
||||
const imageLinkId = this.canvas.node.inputs[0].link;
|
||||
const imageData = app.nodeOutputs[imageLinkId];
|
||||
|
||||
const imageData = window.app.nodeOutputs[imageLinkId];
|
||||
if (imageData) {
|
||||
log.debug("Found image data:", imageData);
|
||||
await this.processImageData(imageData);
|
||||
this.canvas.dataInitialized = true;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.debug("Image data not available yet");
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
|
||||
const maskLinkId = this.canvas.node.inputs[1].link;
|
||||
const maskData = app.nodeOutputs[maskLinkId];
|
||||
|
||||
const maskData = window.app.nodeOutputs[maskLinkId];
|
||||
if (maskData) {
|
||||
log.debug("Found mask data:", maskData);
|
||||
await this.processMaskData(maskData);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error in initNodeData:", error);
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
}
|
||||
|
||||
scheduleDataCheck() {
|
||||
if (this.canvas.pendingDataCheck) {
|
||||
clearTimeout(this.canvas.pendingDataCheck);
|
||||
}
|
||||
|
||||
this.canvas.pendingDataCheck = setTimeout(() => {
|
||||
this.canvas.pendingDataCheck = window.setTimeout(() => {
|
||||
this.canvas.pendingDataCheck = null;
|
||||
if (!this.canvas.dataInitialized) {
|
||||
this.initNodeData();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async processImageData(imageData) {
|
||||
try {
|
||||
if (!imageData) return;
|
||||
|
||||
if (!imageData)
|
||||
return;
|
||||
log.debug("Processing image data:", {
|
||||
type: typeof imageData,
|
||||
isArray: Array.isArray(imageData),
|
||||
shape: imageData.shape,
|
||||
hasData: !!imageData.data
|
||||
});
|
||||
|
||||
if (Array.isArray(imageData)) {
|
||||
imageData = imageData[0];
|
||||
}
|
||||
|
||||
if (!imageData.shape || !imageData.data) {
|
||||
throw new Error("Invalid image data format");
|
||||
}
|
||||
|
||||
const originalWidth = imageData.shape[2];
|
||||
const originalHeight = imageData.shape[1];
|
||||
|
||||
const scale = Math.min(
|
||||
this.canvas.width / originalWidth * 0.8,
|
||||
this.canvas.height / originalHeight * 0.8
|
||||
);
|
||||
|
||||
const scale = Math.min(this.canvas.width / originalWidth * 0.8, this.canvas.height / originalHeight * 0.8);
|
||||
const convertedData = this.convertTensorToImageData(imageData);
|
||||
if (convertedData) {
|
||||
const image = await this.createImageFromData(convertedData);
|
||||
|
||||
this.addScaledLayer(image, scale);
|
||||
log.info("Image layer added successfully with scale:", scale);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error processing image data:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
addScaledLayer(image, scale) {
|
||||
try {
|
||||
const scaledWidth = image.width * scale;
|
||||
const scaledHeight = image.height * scale;
|
||||
|
||||
const layer = {
|
||||
id: '', // This will be set in addLayerWithImage
|
||||
imageId: '', // This will be set in addLayerWithImage
|
||||
name: 'Layer',
|
||||
image: image,
|
||||
x: (this.canvas.width - scaledWidth) / 2,
|
||||
y: (this.canvas.height - scaledHeight) / 2,
|
||||
@@ -545,31 +443,30 @@ export class CanvasIO {
|
||||
rotation: 0,
|
||||
zIndex: this.canvas.layers.length,
|
||||
originalWidth: image.width,
|
||||
originalHeight: image.height
|
||||
originalHeight: image.height,
|
||||
blendMode: 'normal',
|
||||
opacity: 1
|
||||
};
|
||||
|
||||
this.canvas.layers.push(layer);
|
||||
this.canvas.selectedLayer = layer;
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.canvas.render();
|
||||
|
||||
log.debug("Scaled layer added:", {
|
||||
originalSize: `${image.width}x${image.height}`,
|
||||
scaledSize: `${scaledWidth}x${scaledHeight}`,
|
||||
scale: scale
|
||||
});
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error adding scaled layer:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
convertTensorToImageData(tensor) {
|
||||
try {
|
||||
const shape = tensor.shape;
|
||||
const height = shape[1];
|
||||
const width = shape[2];
|
||||
const channels = shape[3];
|
||||
|
||||
log.debug("Converting tensor:", {
|
||||
shape: shape,
|
||||
dataRange: {
|
||||
@@ -577,56 +474,50 @@ export class CanvasIO {
|
||||
max: tensor.max_val
|
||||
}
|
||||
});
|
||||
|
||||
const imageData = new ImageData(width, height);
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
const flatData = tensor.data;
|
||||
const pixelCount = width * height;
|
||||
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
const tensorIndex = i * channels;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const value = flatData[tensorIndex + c];
|
||||
|
||||
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
|
||||
data[pixelIndex + c] = Math.round(normalizedValue * 255);
|
||||
}
|
||||
|
||||
data[pixelIndex + 3] = 255;
|
||||
}
|
||||
|
||||
imageData.data.set(data);
|
||||
return imageData;
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error converting tensor:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createImageFromData(imageData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx)
|
||||
throw new Error("Could not create canvas context");
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
|
||||
async retryDataLoad(maxRetries = 3, delay = 1000) {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
await this.initNodeData();
|
||||
return;
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.warn(`Retry ${i + 1}/${maxRetries} failed:`, error);
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
@@ -635,32 +526,28 @@ export class CanvasIO {
|
||||
}
|
||||
log.error("Failed to load data after", maxRetries, "retries");
|
||||
}
|
||||
|
||||
async processMaskData(maskData) {
|
||||
try {
|
||||
if (!maskData) return;
|
||||
|
||||
if (!maskData)
|
||||
return;
|
||||
log.debug("Processing mask data:", maskData);
|
||||
|
||||
if (Array.isArray(maskData)) {
|
||||
maskData = maskData[0];
|
||||
}
|
||||
|
||||
if (!maskData.shape || !maskData.data) {
|
||||
throw new Error("Invalid mask data format");
|
||||
}
|
||||
|
||||
if (this.canvas.selectedLayer) {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
const maskTensor = await this.convertTensorToMask(maskData);
|
||||
this.canvas.selectedLayer.mask = maskTensor;
|
||||
this.canvas.canvasSelection.selectedLayers[0].mask = maskTensor;
|
||||
this.canvas.render();
|
||||
log.info("Mask applied to selected layer");
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error processing mask data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadImageFromCache(base64Data) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
@@ -669,72 +556,69 @@ export class CanvasIO {
|
||||
img.src = base64Data;
|
||||
});
|
||||
}
|
||||
|
||||
async importImage(cacheData) {
|
||||
try {
|
||||
log.info("Starting image import with cache data");
|
||||
const img = await this.loadImageFromCache(cacheData.image);
|
||||
const mask = cacheData.mask ? await this.loadImageFromCache(cacheData.mask) : null;
|
||||
|
||||
const scale = Math.min(
|
||||
this.canvas.width / img.width * 0.8,
|
||||
this.canvas.height / img.height * 0.8
|
||||
);
|
||||
|
||||
const scale = Math.min(this.canvas.width / img.width * 0.8, this.canvas.height / img.height * 0.8);
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = img.width;
|
||||
tempCanvas.height = img.height;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!tempCtx)
|
||||
throw new Error("Could not create temp context");
|
||||
tempCtx.drawImage(img, 0, 0);
|
||||
|
||||
if (mask) {
|
||||
const imageData = tempCtx.getImageData(0, 0, img.width, img.height);
|
||||
const maskCanvas = document.createElement('canvas');
|
||||
maskCanvas.width = img.width;
|
||||
maskCanvas.height = img.height;
|
||||
const maskCtx = maskCanvas.getContext('2d');
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!maskCtx)
|
||||
throw new Error("Could not create mask context");
|
||||
maskCtx.drawImage(mask, 0, 0);
|
||||
const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
imageData.data[i + 3] = maskData.data[i];
|
||||
}
|
||||
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
const finalImage = new Image();
|
||||
await new Promise((resolve) => {
|
||||
finalImage.onload = resolve;
|
||||
finalImage.src = tempCanvas.toDataURL();
|
||||
});
|
||||
|
||||
const layer = {
|
||||
id: '', // This will be set in addLayerWithImage
|
||||
imageId: '', // This will be set in addLayerWithImage
|
||||
name: 'Layer',
|
||||
image: finalImage,
|
||||
x: (this.canvas.width - img.width * scale) / 2,
|
||||
y: (this.canvas.height - img.height * scale) / 2,
|
||||
width: img.width * scale,
|
||||
height: img.height * scale,
|
||||
originalWidth: img.width,
|
||||
originalHeight: img.height,
|
||||
rotation: 0,
|
||||
zIndex: this.canvas.layers.length
|
||||
zIndex: this.canvas.layers.length,
|
||||
blendMode: 'normal',
|
||||
opacity: 1,
|
||||
};
|
||||
|
||||
this.canvas.layers.push(layer);
|
||||
this.canvas.selectedLayer = layer;
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error('Error importing image:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async importLatestImage() {
|
||||
try {
|
||||
log.info("Fetching latest image from server...");
|
||||
const response = await fetch('/ycnode/get_latest_image');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.image_data) {
|
||||
log.info("Latest image received, adding to canvas.");
|
||||
const img = new Image();
|
||||
@@ -743,22 +627,53 @@ export class CanvasIO {
|
||||
img.onerror = reject;
|
||||
img.src = result.image_data;
|
||||
});
|
||||
|
||||
await this.canvas.addLayerWithImage(img, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
});
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit');
|
||||
log.info("Latest image imported and placed on canvas successfully.");
|
||||
return true;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
throw new Error(result.error || "Failed to fetch the latest image.");
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error importing latest image:", error);
|
||||
alert(`Failed to import latest image: ${error.message}`);
|
||||
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.filter(l => l !== null);
|
||||
}
|
||||
else if (result.success) {
|
||||
log.info("No new images found since last generation.");
|
||||
return [];
|
||||
}
|
||||
else {
|
||||
throw new Error(result.error || "Failed to fetch latest images.");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error importing latest images:", error);
|
||||
alert(`Failed to import latest images: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1104
js/CanvasLayers.js
1104
js/CanvasLayers.js
File diff suppressed because it is too large
Load Diff
528
js/CanvasLayersPanel.js
Normal file
528
js/CanvasLayersPanel.js
Normal file
@@ -0,0 +1,528 @@
|
||||
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;
|
||||
this.handleLayerClick = this.handleLayerClick.bind(this);
|
||||
this.handleDragStart = this.handleDragStart.bind(this);
|
||||
this.handleDragOver = this.handleDragOver.bind(this);
|
||||
this.handleDragEnd = this.handleDragEnd.bind(this);
|
||||
this.handleDrop = this.handleDrop.bind(this);
|
||||
log.info('CanvasLayersPanel initialized');
|
||||
}
|
||||
createPanelStructure() {
|
||||
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');
|
||||
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;
|
||||
}
|
||||
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');
|
||||
}
|
||||
setupControlButtons() {
|
||||
if (!this.container)
|
||||
return;
|
||||
const deleteBtn = this.container.querySelector('#delete-layer-btn');
|
||||
deleteBtn?.addEventListener('click', () => {
|
||||
log.info('Delete layer button clicked');
|
||||
this.deleteSelectedLayers();
|
||||
});
|
||||
}
|
||||
renderLayers() {
|
||||
if (!this.layersContainer) {
|
||||
log.warn('Layers container not initialized');
|
||||
return;
|
||||
}
|
||||
// Wyczyść istniejącą zawartość
|
||||
this.layersContainer.innerHTML = '';
|
||||
// Usuń linię wstawiania jeśli istnieje
|
||||
this.removeDragInsertionLine();
|
||||
// Sortuj warstwy według zIndex (od najwyższej do najniższej)
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||
sortedLayers.forEach((layer, index) => {
|
||||
const layerElement = this.createLayerElement(layer, index);
|
||||
if (this.layersContainer)
|
||||
this.layersContainer.appendChild(layerElement);
|
||||
});
|
||||
log.debug(`Rendered ${sortedLayers.length} layers`);
|
||||
}
|
||||
createLayerElement(layer, index) {
|
||||
const layerRow = document.createElement('div');
|
||||
layerRow.className = 'layer-row';
|
||||
layerRow.draggable = true;
|
||||
layerRow.dataset.layerIndex = String(index);
|
||||
const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
|
||||
if (isSelected) {
|
||||
layerRow.classList.add('selected');
|
||||
}
|
||||
// Ustawienie domyślnych właściwości jeśli nie istnieją
|
||||
if (!layer.name) {
|
||||
layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer);
|
||||
}
|
||||
else {
|
||||
// Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu)
|
||||
layer.name = this.ensureUniqueName(layer.name, layer);
|
||||
}
|
||||
layerRow.innerHTML = `
|
||||
<div class="layer-thumbnail" data-layer-index="${index}"></div>
|
||||
<span class="layer-name" data-layer-index="${index}">${layer.name}</span>
|
||||
`;
|
||||
const thumbnailContainer = layerRow.querySelector('.layer-thumbnail');
|
||||
if (thumbnailContainer) {
|
||||
this.generateThumbnail(layer, thumbnailContainer);
|
||||
}
|
||||
this.setupLayerEventListeners(layerRow, layer, index);
|
||||
return layerRow;
|
||||
}
|
||||
generateThumbnail(layer, thumbnailContainer) {
|
||||
if (!layer.image) {
|
||||
thumbnailContainer.style.background = '#4a4a4a';
|
||||
return;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx)
|
||||
return;
|
||||
canvas.width = 48;
|
||||
canvas.height = 48;
|
||||
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
|
||||
const scaledWidth = layer.image.width * scale;
|
||||
const scaledHeight = layer.image.height * scale;
|
||||
// Wycentruj obraz
|
||||
const x = (48 - scaledWidth) / 2;
|
||||
const y = (48 - scaledHeight) / 2;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight);
|
||||
thumbnailContainer.appendChild(canvas);
|
||||
}
|
||||
setupLayerEventListeners(layerRow, layer, index) {
|
||||
layerRow.addEventListener('mousedown', (e) => {
|
||||
const nameElement = layerRow.querySelector('.layer-name');
|
||||
if (nameElement && nameElement.classList.contains('editing')) {
|
||||
return;
|
||||
}
|
||||
this.handleLayerClick(e, layer, index);
|
||||
});
|
||||
layerRow.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const nameElement = layerRow.querySelector('.layer-name');
|
||||
if (nameElement) {
|
||||
this.startEditingLayerName(nameElement, layer);
|
||||
}
|
||||
});
|
||||
layerRow.addEventListener('dragstart', (e) => this.handleDragStart(e, layer, index));
|
||||
layerRow.addEventListener('dragover', this.handleDragOver.bind(this));
|
||||
layerRow.addEventListener('dragend', this.handleDragEnd.bind(this));
|
||||
layerRow.addEventListener('drop', (e) => this.handleDrop(e, index));
|
||||
}
|
||||
handleLayerClick(e, layer, index) {
|
||||
const isCtrlPressed = e.ctrlKey || e.metaKey;
|
||||
const isShiftPressed = e.shiftKey;
|
||||
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
|
||||
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
|
||||
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||
this.updateSelectionAppearance();
|
||||
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||
}
|
||||
startEditingLayerName(nameElement, layer) {
|
||||
const currentName = layer.name;
|
||||
nameElement.classList.add('editing');
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = currentName;
|
||||
input.style.width = '100%';
|
||||
nameElement.innerHTML = '';
|
||||
nameElement.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
const finishEditing = () => {
|
||||
let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`;
|
||||
newName = this.ensureUniqueName(newName, layer);
|
||||
layer.name = newName;
|
||||
nameElement.classList.remove('editing');
|
||||
nameElement.textContent = newName;
|
||||
this.canvas.saveState();
|
||||
log.info(`Layer renamed to: ${newName}`);
|
||||
};
|
||||
input.addEventListener('blur', finishEditing);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
finishEditing();
|
||||
}
|
||||
else if (e.key === 'Escape') {
|
||||
nameElement.classList.remove('editing');
|
||||
nameElement.textContent = currentName;
|
||||
}
|
||||
});
|
||||
}
|
||||
ensureUniqueName(proposedName, currentLayer) {
|
||||
const existingNames = this.canvas.layers
|
||||
.filter((layer) => layer !== currentLayer)
|
||||
.map((layer) => layer.name);
|
||||
if (!existingNames.includes(proposedName)) {
|
||||
return proposedName;
|
||||
}
|
||||
// Sprawdź czy nazwa już ma numerację w nawiasach
|
||||
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
|
||||
let baseName, startNumber;
|
||||
if (match) {
|
||||
baseName = match[1].trim();
|
||||
startNumber = parseInt(match[2]) + 1;
|
||||
}
|
||||
else {
|
||||
baseName = proposedName;
|
||||
startNumber = 1;
|
||||
}
|
||||
// Znajdź pierwszą dostępną numerację
|
||||
let counter = startNumber;
|
||||
let uniqueName;
|
||||
do {
|
||||
uniqueName = `${baseName} (${counter})`;
|
||||
counter++;
|
||||
} while (existingNames.includes(uniqueName));
|
||||
return uniqueName;
|
||||
}
|
||||
deleteSelectedLayers() {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
|
||||
log.debug('No layers selected for deletion');
|
||||
return;
|
||||
}
|
||||
log.info(`Deleting ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
|
||||
this.canvas.removeSelectedLayers();
|
||||
this.renderLayers();
|
||||
}
|
||||
handleDragStart(e, layer, index) {
|
||||
if (!this.layersContainer || !e.dataTransfer)
|
||||
return;
|
||||
const editingElement = this.layersContainer.querySelector('.layer-name.editing');
|
||||
if (editingElement) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją
|
||||
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.renderLayers();
|
||||
}
|
||||
this.draggedElements = [...this.canvas.canvasSelection.selectedLayers];
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', '');
|
||||
this.layersContainer.querySelectorAll('.layer-row').forEach((row, idx) => {
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||
if (this.draggedElements.includes(sortedLayers[idx])) {
|
||||
row.classList.add('dragging');
|
||||
}
|
||||
});
|
||||
log.debug(`Started dragging ${this.draggedElements.length} layers`);
|
||||
}
|
||||
handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer)
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
const layerRow = e.currentTarget;
|
||||
const rect = layerRow.getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
const isUpperHalf = e.clientY < midpoint;
|
||||
this.showDragInsertionLine(layerRow, isUpperHalf);
|
||||
}
|
||||
showDragInsertionLine(targetRow, isUpperHalf) {
|
||||
this.removeDragInsertionLine();
|
||||
const line = document.createElement('div');
|
||||
line.className = 'drag-insertion-line';
|
||||
if (isUpperHalf) {
|
||||
line.style.top = '-1px';
|
||||
}
|
||||
else {
|
||||
line.style.bottom = '-1px';
|
||||
}
|
||||
targetRow.style.position = 'relative';
|
||||
targetRow.appendChild(line);
|
||||
this.dragInsertionLine = line;
|
||||
}
|
||||
removeDragInsertionLine() {
|
||||
if (this.dragInsertionLine) {
|
||||
this.dragInsertionLine.remove();
|
||||
this.dragInsertionLine = null;
|
||||
}
|
||||
}
|
||||
handleDrop(e, targetIndex) {
|
||||
e.preventDefault();
|
||||
this.removeDragInsertionLine();
|
||||
if (this.draggedElements.length === 0 || !(e.currentTarget instanceof HTMLElement))
|
||||
return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
const isUpperHalf = e.clientY < midpoint;
|
||||
// Oblicz docelowy indeks
|
||||
let insertIndex = targetIndex;
|
||||
if (!isUpperHalf) {
|
||||
insertIndex = targetIndex + 1;
|
||||
}
|
||||
// Użyj nowej, centralnej funkcji do przesuwania warstw
|
||||
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
|
||||
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
|
||||
}
|
||||
handleDragEnd(e) {
|
||||
this.removeDragInsertionLine();
|
||||
if (!this.layersContainer)
|
||||
return;
|
||||
this.layersContainer.querySelectorAll('.layer-row').forEach((row) => {
|
||||
row.classList.remove('dragging');
|
||||
});
|
||||
this.draggedElements = [];
|
||||
}
|
||||
onLayersChanged() {
|
||||
this.renderLayers();
|
||||
}
|
||||
updateSelectionAppearance() {
|
||||
if (!this.layersContainer)
|
||||
return;
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||
const layerRows = this.layersContainer.querySelectorAll('.layer-row');
|
||||
layerRows.forEach((row, index) => {
|
||||
const layer = sortedLayers[index];
|
||||
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
row.classList.add('selected');
|
||||
}
|
||||
else {
|
||||
row.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
||||
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
||||
*/
|
||||
onSelectionChanged() {
|
||||
this.updateSelectionAppearance();
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
467
js/CanvasMask.js
Normal file
467
js/CanvasMask.js
Normal file
@@ -0,0 +1,467 @@
|
||||
// @ts-ignore
|
||||
import { app } from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import { ComfyApp } from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js";
|
||||
const log = createModuleLogger('CanvasMask');
|
||||
export class CanvasMask {
|
||||
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');
|
||||
if (maskCanvas) {
|
||||
editorReady = !!(maskCanvas.getContext('2d') && maskCanvas.width > 0 && maskCanvas.height > 0);
|
||||
if (editorReady) {
|
||||
log.info("Old mask editor detected as ready");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (editorReady) {
|
||||
log.info("Applying mask to editor after", attempts * 100, "ms wait");
|
||||
setTimeout(() => {
|
||||
this.applyMaskToEditor(this.pendingMask);
|
||||
this.pendingMask = null;
|
||||
}, 300);
|
||||
}
|
||||
else if (attempts < maxAttempts) {
|
||||
if (attempts % 10 === 0) {
|
||||
log.info("Waiting for mask editor to be ready... attempt", attempts, "/", maxAttempts);
|
||||
}
|
||||
setTimeout(checkEditor, 100);
|
||||
}
|
||||
else {
|
||||
log.warn("Mask editor timeout - editor not ready after", maxAttempts * 100, "ms");
|
||||
log.info("Attempting to apply mask anyway...");
|
||||
setTimeout(() => {
|
||||
this.applyMaskToEditor(this.pendingMask);
|
||||
this.pendingMask = null;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
else if (attempts < maxAttempts) {
|
||||
setTimeout(checkEditor, 100);
|
||||
}
|
||||
else {
|
||||
log.warn("Mask editor timeout - editor not showing after", maxAttempts * 100, "ms");
|
||||
this.pendingMask = null;
|
||||
}
|
||||
};
|
||||
checkEditor();
|
||||
}
|
||||
/**
|
||||
* Nakłada maskę na otwarty mask editor
|
||||
* @param {Image|HTMLCanvasElement} maskData - Dane maski do nałożenia
|
||||
*/
|
||||
async applyMaskToEditor(maskData) {
|
||||
try {
|
||||
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
|
||||
if (useNewEditor) {
|
||||
const MaskEditorDialog = window.MaskEditorDialog;
|
||||
if (MaskEditorDialog && MaskEditorDialog.instance) {
|
||||
await this.applyMaskToNewEditor(maskData);
|
||||
}
|
||||
else {
|
||||
log.warn("New editor setting enabled but instance not found, trying old editor");
|
||||
await this.applyMaskToOldEditor(maskData);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await this.applyMaskToOldEditor(maskData);
|
||||
}
|
||||
log.info("Predefined mask applied to mask editor successfully");
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Failed to apply predefined mask to editor:", error);
|
||||
try {
|
||||
log.info("Trying alternative mask application method...");
|
||||
await this.applyMaskToOldEditor(maskData);
|
||||
log.info("Alternative method succeeded");
|
||||
}
|
||||
catch (fallbackError) {
|
||||
log.error("Alternative method also failed:", fallbackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Nakłada maskę na nowy mask editor (przez MessageBroker)
|
||||
* @param {Image|HTMLCanvasElement} maskData - Dane maski
|
||||
*/
|
||||
async applyMaskToNewEditor(maskData) {
|
||||
const MaskEditorDialog = window.MaskEditorDialog;
|
||||
if (!MaskEditorDialog || !MaskEditorDialog.instance) {
|
||||
throw new Error("New mask editor instance not found");
|
||||
}
|
||||
const editor = MaskEditorDialog.instance;
|
||||
const messageBroker = editor.getMessageBroker();
|
||||
const maskCanvas = await messageBroker.pull('maskCanvas');
|
||||
const maskCtx = await messageBroker.pull('maskCtx');
|
||||
const maskColor = await messageBroker.pull('getMaskColor');
|
||||
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(processedMask, 0, 0);
|
||||
messageBroker.publish('saveState');
|
||||
}
|
||||
/**
|
||||
* Nakłada maskę na stary mask editor
|
||||
* @param {Image|HTMLCanvasElement} maskData - Dane maski
|
||||
*/
|
||||
async applyMaskToOldEditor(maskData) {
|
||||
const maskCanvas = document.getElementById('maskCanvas');
|
||||
if (!maskCanvas) {
|
||||
throw new Error("Old mask editor canvas not found");
|
||||
}
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!maskCtx) {
|
||||
throw new Error("Old mask editor context not found");
|
||||
}
|
||||
const maskColor = { r: 255, g: 255, b: 255 };
|
||||
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(processedMask, 0, 0);
|
||||
}
|
||||
/**
|
||||
* Przetwarza maskę do odpowiedniego formatu dla editora
|
||||
* @param {Image|HTMLCanvasElement} maskData - Oryginalne dane maski
|
||||
* @param {number} targetWidth - Docelowa szerokość
|
||||
* @param {number} targetHeight - Docelowa wysokość
|
||||
* @param {Object} maskColor - Kolor maski {r, g, b}
|
||||
* @returns {HTMLCanvasElement} Przetworzona maska
|
||||
*/ async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) {
|
||||
// Współrzędne przesunięcia (pan) widoku edytora
|
||||
const panX = this.maskTool.x;
|
||||
const panY = this.maskTool.y;
|
||||
log.info("Processing mask for editor:", {
|
||||
sourceSize: { width: maskData.width, height: maskData.height },
|
||||
targetSize: { width: targetWidth, height: targetHeight },
|
||||
viewportPan: { x: panX, y: panY }
|
||||
});
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = targetWidth;
|
||||
tempCanvas.height = targetHeight;
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
const sourceX = -panX;
|
||||
const sourceY = -panY;
|
||||
if (tempCtx) {
|
||||
tempCtx.drawImage(maskData, // Źródło: pełna maska z "output area"
|
||||
sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000)
|
||||
sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000)
|
||||
targetWidth, // sWidth: Szerokość wycinanego fragmentu
|
||||
targetHeight, // sHeight: Wysokość wycinanego fragmentu
|
||||
0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0)
|
||||
0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0)
|
||||
targetWidth, // dWidth: Szerokość wklejanego obrazu
|
||||
targetHeight // dHeight: Wysokość wklejanego obrazu
|
||||
);
|
||||
}
|
||||
log.info("Mask viewport cropped correctly.", {
|
||||
source: "maskData",
|
||||
cropArea: { x: sourceX, y: sourceY, width: targetWidth, height: targetHeight }
|
||||
});
|
||||
// Reszta kodu (zmiana koloru) pozostaje bez zmian
|
||||
if (tempCtx) {
|
||||
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i + 3];
|
||||
if (alpha > 0) {
|
||||
data[i] = maskColor.r;
|
||||
data[i + 1] = maskColor.g;
|
||||
data[i + 2] = maskColor.b;
|
||||
}
|
||||
}
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
log.info("Mask processing completed - color applied.");
|
||||
return tempCanvas;
|
||||
}
|
||||
/**
|
||||
* Tworzy obiekt Image z obecnej maski canvas
|
||||
* @returns {Promise<Image>} Promise zwracający obiekt Image z maską
|
||||
*/
|
||||
async createMaskFromCurrentMask() {
|
||||
if (!this.maskTool || !this.maskTool.maskCanvas) {
|
||||
throw new Error("No mask canvas available");
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const maskImage = new Image();
|
||||
maskImage.onload = () => resolve(maskImage);
|
||||
maskImage.onerror = reject;
|
||||
maskImage.src = this.maskTool.maskCanvas.toDataURL();
|
||||
});
|
||||
}
|
||||
waitWhileMaskEditing() {
|
||||
if (mask_editor_showing(app)) {
|
||||
this.editorWasShowing = true;
|
||||
}
|
||||
if (!mask_editor_showing(app) && this.editorWasShowing) {
|
||||
this.editorWasShowing = false;
|
||||
setTimeout(() => this.handleMaskEditorClose(), 100);
|
||||
}
|
||||
else {
|
||||
setTimeout(this.waitWhileMaskEditing.bind(this), 100);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Zapisuje obecny stan maski przed otwarciem editora
|
||||
* @returns {Object} Zapisany stan maski
|
||||
*/
|
||||
async saveMaskState() {
|
||||
if (!this.maskTool || !this.maskTool.maskCanvas) {
|
||||
return null;
|
||||
}
|
||||
const maskCanvas = this.maskTool.maskCanvas;
|
||||
const savedCanvas = document.createElement('canvas');
|
||||
savedCanvas.width = maskCanvas.width;
|
||||
savedCanvas.height = maskCanvas.height;
|
||||
const savedCtx = savedCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (savedCtx) {
|
||||
savedCtx.drawImage(maskCanvas, 0, 0);
|
||||
}
|
||||
return {
|
||||
maskData: savedCanvas,
|
||||
maskPosition: {
|
||||
x: this.maskTool.x,
|
||||
y: this.maskTool.y
|
||||
}
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Przywraca zapisany stan maski
|
||||
* @param {Object} savedState - Zapisany stan maski
|
||||
*/
|
||||
async restoreMaskState(savedState) {
|
||||
if (!savedState || !this.maskTool) {
|
||||
return;
|
||||
}
|
||||
if (savedState.maskData) {
|
||||
const maskCtx = this.maskTool.maskCtx;
|
||||
maskCtx.clearRect(0, 0, this.maskTool.maskCanvas.width, this.maskTool.maskCanvas.height);
|
||||
maskCtx.drawImage(savedState.maskData, 0, 0);
|
||||
}
|
||||
if (savedState.maskPosition) {
|
||||
this.maskTool.x = savedState.maskPosition.x;
|
||||
this.maskTool.y = savedState.maskPosition.y;
|
||||
}
|
||||
this.canvas.render();
|
||||
log.info("Mask state restored after cancel");
|
||||
}
|
||||
/**
|
||||
* Konfiguruje nasłuchiwanie na przycisk Cancel w mask editorze
|
||||
*/
|
||||
setupCancelListener() {
|
||||
mask_editor_listen_for_cancel(app, () => {
|
||||
log.info("Mask editor cancel button clicked");
|
||||
this.maskEditorCancelled = true;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Sprawdza czy mask editor został anulowany i obsługuje to odpowiednio
|
||||
*/
|
||||
async handleMaskEditorClose() {
|
||||
log.info("Handling mask editor close");
|
||||
log.debug("Node object after mask editor close:", this.node);
|
||||
if (this.maskEditorCancelled) {
|
||||
log.info("Mask editor was cancelled - restoring original mask state");
|
||||
if (this.savedMaskState) {
|
||||
await this.restoreMaskState(this.savedMaskState);
|
||||
}
|
||||
this.maskEditorCancelled = false;
|
||||
this.savedMaskState = null;
|
||||
return;
|
||||
}
|
||||
if (!this.node.imgs || this.node.imgs.length === 0 || !this.node.imgs[0].src) {
|
||||
log.warn("Mask editor was closed without a result.");
|
||||
return;
|
||||
}
|
||||
log.debug("Processing mask editor result, image source:", this.node.imgs[0].src.substring(0, 100) + '...');
|
||||
const resultImage = new Image();
|
||||
resultImage.src = this.node.imgs[0].src;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
resultImage.onload = resolve;
|
||||
resultImage.onerror = reject;
|
||||
});
|
||||
log.debug("Result image loaded successfully", {
|
||||
width: resultImage.width,
|
||||
height: resultImage.height
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Failed to load image from mask editor.", error);
|
||||
this.node.imgs = [];
|
||||
return;
|
||||
}
|
||||
log.debug("Creating temporary canvas for mask processing");
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = this.canvas.width;
|
||||
tempCanvas.height = this.canvas.height;
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (tempCtx) {
|
||||
tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height);
|
||||
log.debug("Processing image data to create mask");
|
||||
const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const originalAlpha = data[i + 3];
|
||||
data[i] = 255;
|
||||
data[i + 1] = 255;
|
||||
data[i + 2] = 255;
|
||||
data[i + 3] = 255 - originalAlpha;
|
||||
}
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
log.debug("Converting processed mask to image");
|
||||
const maskAsImage = new Image();
|
||||
maskAsImage.src = tempCanvas.toDataURL();
|
||||
await new Promise(resolve => maskAsImage.onload = resolve);
|
||||
const maskCtx = this.maskTool.maskCtx;
|
||||
const destX = -this.maskTool.x;
|
||||
const destY = -this.maskTool.y;
|
||||
log.debug("Applying mask to canvas", { destX, destY });
|
||||
maskCtx.globalCompositeOperation = 'source-over';
|
||||
maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height);
|
||||
maskCtx.drawImage(maskAsImage, destX, destY);
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
log.debug("Creating new preview image");
|
||||
const new_preview = new Image();
|
||||
const blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (blob) {
|
||||
new_preview.src = URL.createObjectURL(blob);
|
||||
await new Promise(r => new_preview.onload = r);
|
||||
this.node.imgs = [new_preview];
|
||||
log.debug("New preview image created successfully");
|
||||
}
|
||||
else {
|
||||
this.node.imgs = [];
|
||||
log.warn("Failed to create preview blob");
|
||||
}
|
||||
this.canvas.render();
|
||||
this.savedMaskState = null;
|
||||
log.info("Mask editor result processed successfully");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('CanvasRenderer');
|
||||
|
||||
export class CanvasRenderer {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
@@ -10,7 +8,6 @@ export class CanvasRenderer {
|
||||
this.renderInterval = 1000 / 60;
|
||||
this.isDirty = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.renderAnimationFrame) {
|
||||
this.isDirty = true;
|
||||
@@ -23,16 +20,15 @@ export class CanvasRenderer {
|
||||
this.actualRender();
|
||||
this.isDirty = false;
|
||||
}
|
||||
|
||||
if (this.isDirty) {
|
||||
this.renderAnimationFrame = null;
|
||||
this.render();
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this.renderAnimationFrame = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
actualRender() {
|
||||
if (this.canvas.offscreenCanvas.width !== this.canvas.canvas.clientWidth ||
|
||||
this.canvas.offscreenCanvas.height !== this.canvas.canvas.clientHeight) {
|
||||
@@ -41,21 +37,17 @@ export class CanvasRenderer {
|
||||
this.canvas.offscreenCanvas.width = newWidth;
|
||||
this.canvas.offscreenCanvas.height = newHeight;
|
||||
}
|
||||
|
||||
const ctx = this.canvas.offscreenCtx;
|
||||
|
||||
ctx.fillStyle = '#606060';
|
||||
ctx.fillRect(0, 0, this.canvas.offscreenCanvas.width, this.canvas.offscreenCanvas.height);
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
|
||||
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
|
||||
|
||||
this.drawGrid(ctx);
|
||||
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach(layer => {
|
||||
if (!layer.image) return;
|
||||
if (!layer.image)
|
||||
return;
|
||||
ctx.save();
|
||||
const currentTransform = ctx.getTransform();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
@@ -66,57 +58,56 @@ export class CanvasRenderer {
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(layer.rotation * Math.PI / 180);
|
||||
const scaleH = layer.flipH ? -1 : 1;
|
||||
const scaleV = layer.flipV ? -1 : 1;
|
||||
if (layer.flipH || layer.flipV) {
|
||||
ctx.scale(scaleH, scaleV);
|
||||
}
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(
|
||||
layer.image, -layer.width / 2, -layer.height / 2,
|
||||
layer.width,
|
||||
layer.height
|
||||
);
|
||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
if (layer.mask) {
|
||||
}
|
||||
if (this.canvas.selectedLayers.includes(layer)) {
|
||||
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
this.drawSelectionFrame(ctx, layer);
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
this.drawCanvasOutline(ctx);
|
||||
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
||||
const maskImage = this.canvas.maskTool.getMask();
|
||||
if (maskImage) {
|
||||
|
||||
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||
ctx.save();
|
||||
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 0.5;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y);
|
||||
|
||||
ctx.globalAlpha = 1.0;
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
this.renderInteractionElements(ctx);
|
||||
this.renderLayerInfo(ctx);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width ||
|
||||
this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) {
|
||||
this.canvas.canvas.width = this.canvas.offscreenCanvas.width;
|
||||
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
||||
}
|
||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
||||
// Update Batch Preview UI positions
|
||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||
this.canvas.batchPreviewManagers.forEach((manager) => {
|
||||
manager.updateScreenPosition(this.canvas.viewport);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderInteractionElements(ctx) {
|
||||
const interaction = this.canvas.interaction;
|
||||
|
||||
if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) {
|
||||
const rect = interaction.canvasResizeRect;
|
||||
ctx.save();
|
||||
@@ -130,7 +121,6 @@ export class CanvasRenderer {
|
||||
const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
||||
const textWorldX = rect.x + rect.width / 2;
|
||||
const textWorldY = rect.y + rect.height + (20 / this.canvas.viewport.zoom);
|
||||
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
@@ -148,7 +138,6 @@ export class CanvasRenderer {
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) {
|
||||
const rect = interaction.canvasMoveRect;
|
||||
ctx.save();
|
||||
@@ -158,11 +147,9 @@ export class CanvasRenderer {
|
||||
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
|
||||
const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`;
|
||||
const textWorldX = rect.x + rect.width / 2;
|
||||
const textWorldY = rect.y - (20 / this.canvas.viewport.zoom);
|
||||
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
@@ -180,12 +167,11 @@ export class CanvasRenderer {
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
renderLayerInfo(ctx) {
|
||||
if (this.canvas.selectedLayer) {
|
||||
this.canvas.selectedLayers.forEach(layer => {
|
||||
if (!layer.image) return;
|
||||
|
||||
if (this.canvas.canvasSelection.selectedLayer) {
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||
if (!layer.image)
|
||||
return;
|
||||
const layerIndex = this.canvas.layers.indexOf(layer);
|
||||
const currentWidth = Math.round(layer.width);
|
||||
const currentHeight = Math.round(layer.height);
|
||||
@@ -199,15 +185,13 @@ export class CanvasRenderer {
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
|
||||
const localCorners = [
|
||||
{x: -halfW, y: -halfH},
|
||||
{x: halfW, y: -halfH},
|
||||
{x: halfW, y: halfH},
|
||||
{x: -halfW, y: halfH}
|
||||
{ x: -halfW, y: -halfH },
|
||||
{ x: halfW, y: -halfH },
|
||||
{ x: halfW, y: halfH },
|
||||
{ x: -halfW, y: halfH }
|
||||
];
|
||||
const worldCorners = localCorners.map(p => ({
|
||||
x: centerX + p.x * cos - p.y * sin,
|
||||
@@ -224,10 +208,8 @@ export class CanvasRenderer {
|
||||
const textWorldY = maxY + padding;
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
|
||||
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
|
||||
ctx.font = "14px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
@@ -236,59 +218,46 @@ export class CanvasRenderer {
|
||||
const textBgWidth = Math.max(...textMetrics.map(m => m.width)) + 10;
|
||||
const lineHeight = 18;
|
||||
const textBgHeight = lines.length * lineHeight + 4;
|
||||
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
|
||||
ctx.fillRect(screenX - textBgWidth / 2, screenY - textBgHeight / 2, textBgWidth, textBgHeight);
|
||||
|
||||
ctx.fillStyle = "white";
|
||||
lines.forEach((line, index) => {
|
||||
const yPos = screenY - (textBgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2;
|
||||
ctx.fillText(line, screenX, yPos);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drawGrid(ctx) {
|
||||
const gridSize = 64;
|
||||
const lineWidth = 0.5 / this.canvas.viewport.zoom;
|
||||
|
||||
const viewLeft = this.canvas.viewport.x;
|
||||
const viewTop = this.canvas.viewport.y;
|
||||
const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom;
|
||||
const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.viewport.zoom;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#707070';
|
||||
ctx.lineWidth = lineWidth;
|
||||
|
||||
for (let x = Math.floor(viewLeft / gridSize) * gridSize; x < viewRight; x += gridSize) {
|
||||
ctx.moveTo(x, viewTop);
|
||||
ctx.lineTo(x, viewBottom);
|
||||
}
|
||||
|
||||
for (let y = Math.floor(viewTop / gridSize) * gridSize; y < viewBottom; y += gridSize) {
|
||||
ctx.moveTo(viewLeft, y);
|
||||
ctx.lineTo(viewRight, y);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
drawCanvasOutline(ctx) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]);
|
||||
|
||||
ctx.rect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
drawSelectionFrame(ctx, layer) {
|
||||
const lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
const handleRadius = 5 / this.canvas.viewport.zoom;
|
||||
@@ -301,24 +270,48 @@ export class CanvasRenderer {
|
||||
ctx.moveTo(0, -layer.height / 2);
|
||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
||||
ctx.stroke();
|
||||
const handles = this.canvas.getHandles(layer);
|
||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||
|
||||
for (const key in handles) {
|
||||
const point = handles[key];
|
||||
ctx.beginPath();
|
||||
const localX = point.x - (layer.x + layer.width / 2);
|
||||
const localY = point.y - (layer.y + layer.height / 2);
|
||||
|
||||
const rad = -layer.rotation * Math.PI / 180;
|
||||
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
||||
|
||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
drawPendingGenerationAreas(ctx) {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
146
js/CanvasSelection.js
Normal file
146
js/CanvasSelection.js
Normal file
@@ -0,0 +1,146 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import {getCanvasState, setCanvasState, saveImage, getImage} from "./db.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import {generateUUID, cloneLayers, getStateSignature, debounce} from "./utils/CommonUtils.js";
|
||||
import {withErrorHandling} from "./ErrorHandler.js";
|
||||
|
||||
import { getCanvasState, setCanvasState, saveImage, getImage } from "./db.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { generateUUID, cloneLayers, getStateSignature, debounce } from "./utils/CommonUtils.js";
|
||||
const log = createModuleLogger('CanvasState');
|
||||
|
||||
export class CanvasState {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
@@ -16,275 +13,304 @@ export class CanvasState {
|
||||
this.saveTimeout = null;
|
||||
this.lastSavedStateSignature = null;
|
||||
this._loadInProgress = null;
|
||||
this._debouncedSave = null;
|
||||
try {
|
||||
// @ts-ignore
|
||||
this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' });
|
||||
log.info("State saver worker initialized successfully.");
|
||||
this.stateSaverWorker.onmessage = (e) => {
|
||||
log.info("Message from state saver worker:", e.data);
|
||||
};
|
||||
this.stateSaverWorker.onerror = (e) => {
|
||||
log.error("Error in state saver worker:", e.message, e.filename, e.lineno);
|
||||
this.stateSaverWorker = null;
|
||||
};
|
||||
}
|
||||
catch (e) {
|
||||
log.error("Failed to initialize state saver worker:", e);
|
||||
this.stateSaverWorker = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async loadStateFromDB() {
|
||||
if (this._loadInProgress) {
|
||||
log.warn("Load already in progress, waiting...");
|
||||
return this._loadInProgress;
|
||||
}
|
||||
|
||||
log.info("Attempting to load state from IndexedDB for node:", this.canvas.node.id);
|
||||
if (!this.canvas.node.id) {
|
||||
log.error("Node ID is not available for loading state from DB.");
|
||||
return false;
|
||||
}
|
||||
|
||||
this._loadInProgress = this._performLoad();
|
||||
|
||||
const loadPromise = this._performLoad();
|
||||
this._loadInProgress = loadPromise;
|
||||
try {
|
||||
const result = await this._loadInProgress;
|
||||
return result;
|
||||
} finally {
|
||||
const result = await loadPromise;
|
||||
this._loadInProgress = null;
|
||||
return result;
|
||||
}
|
||||
catch (error) {
|
||||
this._loadInProgress = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
_performLoad = withErrorHandling(async () => {
|
||||
const savedState = await getCanvasState(this.canvas.node.id);
|
||||
if (!savedState) {
|
||||
log.info("No saved state found in IndexedDB for node:", this.canvas.node.id);
|
||||
async _performLoad() {
|
||||
try {
|
||||
if (!this.canvas.node.id) {
|
||||
log.error("Node ID is not available for loading state from DB.");
|
||||
return false;
|
||||
}
|
||||
const savedState = await getCanvasState(String(this.canvas.node.id));
|
||||
if (!savedState) {
|
||||
log.info("No saved state found in IndexedDB for node:", this.canvas.node.id);
|
||||
return false;
|
||||
}
|
||||
log.info("Found saved state in IndexedDB.");
|
||||
this.canvas.width = savedState.width || 512;
|
||||
this.canvas.height = savedState.height || 512;
|
||||
this.canvas.viewport = savedState.viewport || {
|
||||
x: -(this.canvas.width / 4),
|
||||
y: -(this.canvas.height / 4),
|
||||
zoom: 0.8
|
||||
};
|
||||
this.canvas.canvasLayers.updateOutputAreaSize(this.canvas.width, this.canvas.height, false);
|
||||
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
|
||||
const loadedLayers = await this._loadLayers(savedState.layers);
|
||||
this.canvas.layers = loadedLayers.filter((l) => l !== null);
|
||||
log.info(`Loaded ${this.canvas.layers.length} layers.`);
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn("No valid layers loaded, state may be corrupted.");
|
||||
return false;
|
||||
}
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id);
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error during state load:", error);
|
||||
return false;
|
||||
}
|
||||
log.info("Found saved state in IndexedDB.");
|
||||
this.canvas.width = savedState.width || 512;
|
||||
this.canvas.height = savedState.height || 512;
|
||||
this.canvas.viewport = savedState.viewport || {
|
||||
x: -(this.canvas.width / 4),
|
||||
y: -(this.canvas.height / 4),
|
||||
zoom: 0.8
|
||||
};
|
||||
|
||||
this.canvas.updateOutputAreaSize(this.canvas.width, this.canvas.height, false);
|
||||
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
|
||||
const loadedLayers = await this._loadLayers(savedState.layers);
|
||||
this.canvas.layers = loadedLayers.filter(l => l !== null);
|
||||
log.info(`Loaded ${this.canvas.layers.length} layers.`);
|
||||
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn("No valid layers loaded, state may be corrupted.");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id);
|
||||
return true;
|
||||
}, 'CanvasState._performLoad');
|
||||
|
||||
}
|
||||
/**
|
||||
* Ładuje warstwy z zapisanego stanu
|
||||
* @param {Array} layersData - Dane warstw do załadowania
|
||||
* @returns {Promise<Array>} Załadowane warstwy
|
||||
* @param {any[]} layersData - Dane warstw do załadowania
|
||||
* @returns {Promise<(Layer | null)[]>} Załadowane warstwy
|
||||
*/
|
||||
async _loadLayers(layersData) {
|
||||
const imagePromises = layersData.map((layerData, index) =>
|
||||
this._loadSingleLayer(layerData, index)
|
||||
);
|
||||
const imagePromises = layersData.map((layerData, index) => this._loadSingleLayer(layerData, index));
|
||||
return Promise.all(imagePromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje pojedynczą warstwę
|
||||
* @param {Object} layerData - Dane warstwy
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @returns {Promise<Object|null>} Załadowana warstwa lub null
|
||||
* @returns {Promise<Layer | null>} Załadowana warstwa lub null
|
||||
*/
|
||||
async _loadSingleLayer(layerData, index) {
|
||||
return new Promise((resolve) => {
|
||||
if (layerData.imageId) {
|
||||
this._loadLayerFromImageId(layerData, index, resolve);
|
||||
} else if (layerData.imageSrc) {
|
||||
}
|
||||
else if (layerData.imageSrc) {
|
||||
this._convertLegacyLayer(layerData, index, resolve);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.error(`Layer ${index}: No imageId or imageSrc found, skipping layer.`);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje warstwę z imageId
|
||||
* @param {Object} layerData - Dane warstwy
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @param {Function} resolve - Funkcja resolve
|
||||
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
||||
*/
|
||||
_loadLayerFromImageId(layerData, index, resolve) {
|
||||
log.debug(`Layer ${index}: Loading image with id: ${layerData.imageId}`);
|
||||
|
||||
if (this.canvas.imageCache.has(layerData.imageId)) {
|
||||
log.debug(`Layer ${index}: Image found in cache.`);
|
||||
const imageSrc = this.canvas.imageCache.get(layerData.imageId);
|
||||
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
||||
} else {
|
||||
const imageData = this.canvas.imageCache.get(layerData.imageId);
|
||||
if (imageData) {
|
||||
const imageSrc = URL.createObjectURL(new Blob([imageData.data]));
|
||||
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
||||
}
|
||||
else {
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
else {
|
||||
getImage(layerData.imageId)
|
||||
.then(imageSrc => {
|
||||
if (imageSrc) {
|
||||
log.debug(`Layer ${index}: Loading image from data:URL...`);
|
||||
this.canvas.imageCache.set(layerData.imageId, imageSrc);
|
||||
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
||||
} else {
|
||||
log.error(`Layer ${index}: Image not found in IndexedDB.`);
|
||||
resolve(null);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
log.error(`Layer ${index}: Error loading image from IndexedDB:`, err);
|
||||
if (imageSrc) {
|
||||
log.debug(`Layer ${index}: Loading image from data:URL...`);
|
||||
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
||||
}
|
||||
else {
|
||||
log.error(`Layer ${index}: Image not found in IndexedDB.`);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
log.error(`Layer ${index}: Error loading image from IndexedDB:`, err);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje starą warstwę z imageSrc na nowy format
|
||||
* @param {Object} layerData - Dane warstwy
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @param {Function} resolve - Funkcja resolve
|
||||
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
||||
*/
|
||||
_convertLegacyLayer(layerData, index, resolve) {
|
||||
log.info(`Layer ${index}: Found imageSrc, converting to new format with imageId.`);
|
||||
const imageId = generateUUID();
|
||||
|
||||
saveImage(imageId, layerData.imageSrc)
|
||||
.then(() => {
|
||||
log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`);
|
||||
this.canvas.imageCache.set(imageId, layerData.imageSrc);
|
||||
const newLayerData = {...layerData, imageId};
|
||||
delete newLayerData.imageSrc;
|
||||
this._createLayerFromSrc(newLayerData, layerData.imageSrc, index, resolve);
|
||||
})
|
||||
log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`);
|
||||
const newLayerData = { ...layerData, imageId };
|
||||
delete newLayerData.imageSrc;
|
||||
this._createLayerFromSrc(newLayerData, layerData.imageSrc, index, resolve);
|
||||
})
|
||||
.catch(err => {
|
||||
log.error(`Layer ${index}: Error saving image to IndexedDB:`, err);
|
||||
resolve(null);
|
||||
});
|
||||
log.error(`Layer ${index}: Error saving image to IndexedDB:`, err);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy warstwę z src obrazu
|
||||
* @param {Object} layerData - Dane warstwy
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {string} imageSrc - Źródło obrazu
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @param {Function} resolve - Funkcja resolve
|
||||
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
||||
*/
|
||||
_createLayerFromSrc(layerData, imageSrc, index, resolve) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully.`);
|
||||
const newLayer = {...layerData, image: img};
|
||||
delete newLayer.imageId;
|
||||
resolve(newLayer);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.error(`Layer ${index}: Failed to load image from src.`);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = imageSrc;
|
||||
if (typeof imageSrc === 'string') {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully.`);
|
||||
const newLayer = { ...layerData, image: img };
|
||||
resolve(newLayer);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.error(`Layer ${index}: Failed to load image from src.`);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}
|
||||
else {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imageSrc.width;
|
||||
canvas.height = imageSrc.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(imageSrc, 0, 0);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
||||
const newLayer = { ...layerData, image: img };
|
||||
resolve(newLayer);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.error(`Layer ${index}: Failed to load image from ImageBitmap.`);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = canvas.toDataURL();
|
||||
}
|
||||
else {
|
||||
log.error(`Layer ${index}: Failed to get 2d context from canvas.`);
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveStateToDB(immediate = false) {
|
||||
log.info("Preparing to save state to IndexedDB for node:", this.canvas.node.id);
|
||||
async saveStateToDB() {
|
||||
if (!this.canvas.node.id) {
|
||||
log.error("Node ID is not available for saving state to DB.");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStateSignature = getStateSignature(this.canvas.layers);
|
||||
if (this.lastSavedStateSignature === currentStateSignature) {
|
||||
log.debug("State unchanged, skipping save to IndexedDB.");
|
||||
log.info("Preparing state to be sent to worker...");
|
||||
const layers = await this._prepareLayers();
|
||||
const state = {
|
||||
layers: layers.filter(layer => layer !== null),
|
||||
viewport: this.canvas.viewport,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
};
|
||||
if (state.layers.length === 0) {
|
||||
log.warn("No valid layers to save, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
if (this.stateSaverWorker) {
|
||||
log.info("Posting state to worker for background saving.");
|
||||
this.stateSaverWorker.postMessage({
|
||||
nodeId: String(this.canvas.node.id),
|
||||
state: state
|
||||
});
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
const saveFunction = withErrorHandling(async () => {
|
||||
const state = {
|
||||
layers: await this._prepareLayers(),
|
||||
viewport: this.canvas.viewport,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
};
|
||||
|
||||
state.layers = state.layers.filter(layer => layer !== null);
|
||||
if (state.layers.length === 0) {
|
||||
log.warn("No valid layers to save, skipping save to IndexedDB.");
|
||||
return;
|
||||
}
|
||||
|
||||
await setCanvasState(this.canvas.node.id, state);
|
||||
log.info("Canvas state saved to IndexedDB.");
|
||||
this.lastSavedStateSignature = currentStateSignature;
|
||||
}, 'CanvasState.saveStateToDB');
|
||||
|
||||
if (immediate) {
|
||||
await saveFunction();
|
||||
} else {
|
||||
this.saveTimeout = setTimeout(saveFunction, 1000);
|
||||
else {
|
||||
log.warn("State saver worker not available. Saving on main thread.");
|
||||
await setCanvasState(String(this.canvas.node.id), state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Przygotowuje warstwy do zapisu
|
||||
* @returns {Promise<Array>} Przygotowane warstwy
|
||||
* @returns {Promise<(Omit<Layer, 'image'> & { imageId: string })[]>} Przygotowane warstwy
|
||||
*/
|
||||
async _prepareLayers() {
|
||||
return Promise.all(this.canvas.layers.map(async (layer, index) => {
|
||||
const newLayer = {...layer};
|
||||
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer, index) => {
|
||||
const newLayer = { ...layer, imageId: layer.imageId || '' };
|
||||
delete newLayer.image;
|
||||
if (layer.image instanceof HTMLImageElement) {
|
||||
log.debug(`Layer ${index}: Using imageId instead of serializing image.`);
|
||||
if (!layer.imageId) {
|
||||
layer.imageId = generateUUID();
|
||||
await saveImage(layer.imageId, layer.image.src);
|
||||
this.canvas.imageCache.set(layer.imageId, layer.image.src);
|
||||
if (layer.imageId) {
|
||||
newLayer.imageId = layer.imageId;
|
||||
}
|
||||
newLayer.imageId = layer.imageId;
|
||||
} else if (!layer.imageId) {
|
||||
else {
|
||||
log.debug(`Layer ${index}: No imageId found, generating new one and saving image.`);
|
||||
newLayer.imageId = generateUUID();
|
||||
const imageBitmap = await createImageBitmap(layer.image);
|
||||
await saveImage(newLayer.imageId, imageBitmap);
|
||||
}
|
||||
}
|
||||
else if (!layer.imageId) {
|
||||
log.error(`Layer ${index}: No image or imageId found, skipping layer.`);
|
||||
return null;
|
||||
}
|
||||
delete newLayer.image;
|
||||
return newLayer;
|
||||
}));
|
||||
return preparedLayers.filter((layer) => layer !== null);
|
||||
}
|
||||
|
||||
saveState(replaceLast = false) {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.saveMaskState(replaceLast);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this.saveLayersState(replaceLast);
|
||||
}
|
||||
}
|
||||
|
||||
saveLayersState(replaceLast = false) {
|
||||
if (replaceLast && this.layersUndoStack.length > 0) {
|
||||
this.layersUndoStack.pop();
|
||||
}
|
||||
|
||||
const currentState = cloneLayers(this.canvas.layers);
|
||||
|
||||
const currentStateSignature = getStateSignature(currentState);
|
||||
if (this.layersUndoStack.length > 0) {
|
||||
const lastState = this.layersUndoStack[this.layersUndoStack.length - 1];
|
||||
if (getStateSignature(currentState) === getStateSignature(lastState)) {
|
||||
if (getStateSignature(lastState) === currentStateSignature) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.layersUndoStack.push(currentState);
|
||||
|
||||
if (this.layersUndoStack.length > this.historyLimit) {
|
||||
this.layersUndoStack.shift();
|
||||
}
|
||||
this.layersRedoStack = [];
|
||||
this.canvas.updateHistoryButtons();
|
||||
this._debouncedSave = this._debouncedSave || debounce(() => this.saveStateToDB(), 500);
|
||||
if (!this._debouncedSave) {
|
||||
this._debouncedSave = debounce(this.saveStateToDB.bind(this), 1000);
|
||||
}
|
||||
this._debouncedSave();
|
||||
}
|
||||
|
||||
saveMaskState(replaceLast = false) {
|
||||
if (!this.canvas.maskTool) return;
|
||||
|
||||
if (!this.canvas.maskTool)
|
||||
return;
|
||||
if (replaceLast && this.maskUndoStack.length > 0) {
|
||||
this.maskUndoStack.pop();
|
||||
}
|
||||
@@ -292,90 +318,93 @@ export class CanvasState {
|
||||
const clonedCanvas = document.createElement('canvas');
|
||||
clonedCanvas.width = maskCanvas.width;
|
||||
clonedCanvas.height = maskCanvas.height;
|
||||
const clonedCtx = clonedCanvas.getContext('2d');
|
||||
clonedCtx.drawImage(maskCanvas, 0, 0);
|
||||
|
||||
const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (clonedCtx) {
|
||||
clonedCtx.drawImage(maskCanvas, 0, 0);
|
||||
}
|
||||
this.maskUndoStack.push(clonedCanvas);
|
||||
|
||||
if (this.maskUndoStack.length > this.historyLimit) {
|
||||
this.maskUndoStack.shift();
|
||||
}
|
||||
this.maskRedoStack = [];
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.undoMaskState();
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this.undoLayersState();
|
||||
}
|
||||
}
|
||||
|
||||
redo() {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.redoMaskState();
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this.redoLayersState();
|
||||
}
|
||||
}
|
||||
|
||||
undoLayersState() {
|
||||
if (this.layersUndoStack.length <= 1) return;
|
||||
|
||||
if (this.layersUndoStack.length <= 1)
|
||||
return;
|
||||
const currentState = this.layersUndoStack.pop();
|
||||
this.layersRedoStack.push(currentState);
|
||||
if (currentState) {
|
||||
this.layersRedoStack.push(currentState);
|
||||
}
|
||||
const prevState = this.layersUndoStack[this.layersUndoStack.length - 1];
|
||||
this.canvas.layers = cloneLayers(prevState);
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
redoLayersState() {
|
||||
if (this.layersRedoStack.length === 0) return;
|
||||
|
||||
if (this.layersRedoStack.length === 0)
|
||||
return;
|
||||
const nextState = this.layersRedoStack.pop();
|
||||
this.layersUndoStack.push(nextState);
|
||||
this.canvas.layers = cloneLayers(nextState);
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
this.canvas.updateHistoryButtons();
|
||||
if (nextState) {
|
||||
this.layersUndoStack.push(nextState);
|
||||
this.canvas.layers = cloneLayers(nextState);
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
}
|
||||
|
||||
undoMaskState() {
|
||||
if (!this.canvas.maskTool || this.maskUndoStack.length <= 1) return;
|
||||
|
||||
if (!this.canvas.maskTool || this.maskUndoStack.length <= 1)
|
||||
return;
|
||||
const currentState = this.maskUndoStack.pop();
|
||||
this.maskRedoStack.push(currentState);
|
||||
|
||||
if (currentState) {
|
||||
this.maskRedoStack.push(currentState);
|
||||
}
|
||||
if (this.maskUndoStack.length > 0) {
|
||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d');
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(prevState, 0, 0);
|
||||
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (maskCtx) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(prevState, 0, 0);
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
redoMaskState() {
|
||||
if (!this.canvas.maskTool || this.maskRedoStack.length === 0) return;
|
||||
|
||||
if (!this.canvas.maskTool || this.maskRedoStack.length === 0)
|
||||
return;
|
||||
const nextState = this.maskRedoStack.pop();
|
||||
this.maskUndoStack.push(nextState);
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d');
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(nextState, 0, 0);
|
||||
|
||||
this.canvas.render();
|
||||
if (nextState) {
|
||||
this.maskUndoStack.push(nextState);
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (maskCtx) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(nextState, 0, 0);
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści historię undo/redo
|
||||
*/
|
||||
@@ -383,17 +412,17 @@ export class CanvasState {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.maskUndoStack = [];
|
||||
this.maskRedoStack = [];
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this.layersUndoStack = [];
|
||||
this.layersRedoStack = [];
|
||||
}
|
||||
this.canvas.updateHistoryButtons();
|
||||
log.info("History cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca informacje o historii
|
||||
* @returns {Object} Informacje o historii
|
||||
* @returns {HistoryInfo} Informacje o historii
|
||||
*/
|
||||
getHistoryInfo() {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
@@ -404,7 +433,8 @@ export class CanvasState {
|
||||
canRedo: this.maskRedoStack.length > 0,
|
||||
historyLimit: this.historyLimit
|
||||
};
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
return {
|
||||
undoCount: this.layersUndoStack.length,
|
||||
redoCount: this.layersRedoStack.length,
|
||||
|
||||
1189
js/CanvasView.js
1189
js/CanvasView.js
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,8 @@
|
||||
* ErrorHandler - Centralna obsługa błędów
|
||||
* Eliminuje powtarzalne wzorce obsługi błędów w całym projekcie
|
||||
*/
|
||||
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('ErrorHandler');
|
||||
|
||||
/**
|
||||
* Typy błędów w aplikacji
|
||||
*/
|
||||
@@ -20,7 +17,6 @@ export const ErrorTypes = {
|
||||
USER_INPUT: 'USER_INPUT_ERROR',
|
||||
SYSTEM: 'SYSTEM_ERROR'
|
||||
};
|
||||
|
||||
/**
|
||||
* Klasa błędu aplikacji z dodatkowymi informacjami
|
||||
*/
|
||||
@@ -37,7 +33,6 @@ export class AppError extends Error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler błędów z automatycznym logowaniem i kategoryzacją
|
||||
*/
|
||||
@@ -47,12 +42,11 @@ export class ErrorHandler {
|
||||
this.errorHistory = [];
|
||||
this.maxHistorySize = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsługuje błąd z automatycznym logowaniem
|
||||
* @param {Error|AppError} error - Błąd do obsłużenia
|
||||
* @param {Error | AppError | string} error - Błąd do obsłużenia
|
||||
* @param {string} context - Kontekst wystąpienia błędu
|
||||
* @param {Object} additionalInfo - Dodatkowe informacje
|
||||
* @param {object} additionalInfo - Dodatkowe informacje
|
||||
* @returns {AppError} Znormalizowany błąd
|
||||
*/
|
||||
handle(error, context = 'Unknown', additionalInfo = {}) {
|
||||
@@ -60,52 +54,33 @@ export class ErrorHandler {
|
||||
this.logError(normalizedError, context);
|
||||
this.recordError(normalizedError);
|
||||
this.incrementErrorCount(normalizedError.type);
|
||||
|
||||
return normalizedError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizuje błąd do standardowego formatu
|
||||
* @param {Error|AppError|string} error - Błąd do znormalizowania
|
||||
* @param {Error | AppError | string} error - Błąd do znormalizowania
|
||||
* @param {string} context - Kontekst
|
||||
* @param {Object} additionalInfo - Dodatkowe informacje
|
||||
* @param {object} additionalInfo - Dodatkowe informacje
|
||||
* @returns {AppError} Znormalizowany błąd
|
||||
*/
|
||||
normalizeError(error, context, additionalInfo) {
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const type = this.categorizeError(error, context);
|
||||
return new AppError(
|
||||
error.message,
|
||||
type,
|
||||
{context, ...additionalInfo},
|
||||
error
|
||||
);
|
||||
return new AppError(error.message, type, { context, ...additionalInfo }, error);
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return new AppError(
|
||||
error,
|
||||
ErrorTypes.SYSTEM,
|
||||
{context, ...additionalInfo}
|
||||
);
|
||||
return new AppError(error, ErrorTypes.SYSTEM, { context, ...additionalInfo });
|
||||
}
|
||||
|
||||
return new AppError(
|
||||
'Unknown error occurred',
|
||||
ErrorTypes.SYSTEM,
|
||||
{context, originalError: error, ...additionalInfo}
|
||||
);
|
||||
return new AppError('Unknown error occurred', ErrorTypes.SYSTEM, { context, originalError: error, ...additionalInfo });
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategoryzuje błąd na podstawie wiadomości i kontekstu
|
||||
* @param {Error} error - Błąd do skategoryzowania
|
||||
* @param {string} context - Kontekst
|
||||
* @returns {string} Typ błędu
|
||||
* @returns {ErrorType} Typ błędu
|
||||
*/
|
||||
categorizeError(error, context) {
|
||||
const message = error.message.toLowerCase();
|
||||
@@ -132,10 +107,8 @@ export class ErrorHandler {
|
||||
if (context.toLowerCase().includes('canvas')) {
|
||||
return ErrorTypes.CANVAS;
|
||||
}
|
||||
|
||||
return ErrorTypes.SYSTEM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loguje błąd z odpowiednim poziomem
|
||||
* @param {AppError} error - Błąd do zalogowania
|
||||
@@ -161,7 +134,6 @@ export class ErrorHandler {
|
||||
log.error(logMessage, logDetails);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisuje błąd w historii
|
||||
* @param {AppError} error - Błąd do zapisania
|
||||
@@ -177,36 +149,37 @@ export class ErrorHandler {
|
||||
this.errorHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwiększa licznik błędów dla danego typu
|
||||
* @param {string} errorType - Typ błędu
|
||||
* @param {ErrorType} errorType - Typ błędu
|
||||
*/
|
||||
incrementErrorCount(errorType) {
|
||||
const current = this.errorCounts.get(errorType) || 0;
|
||||
this.errorCounts.set(errorType, current + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca statystyki błędów
|
||||
* @returns {Object} Statystyki błędów
|
||||
* @returns {ErrorStats} Statystyki błędów
|
||||
*/
|
||||
getErrorStats() {
|
||||
const errorCountsObj = {};
|
||||
for (const [key, value] of this.errorCounts.entries()) {
|
||||
errorCountsObj[key] = value;
|
||||
}
|
||||
return {
|
||||
totalErrors: this.errorHistory.length,
|
||||
errorCounts: Object.fromEntries(this.errorCounts),
|
||||
errorCounts: errorCountsObj,
|
||||
recentErrors: this.errorHistory.slice(-10),
|
||||
errorsByType: this.groupErrorsByType()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Grupuje błędy według typu
|
||||
* @returns {Object} Błędy pogrupowane według typu
|
||||
* @returns {{ [key: string]: ErrorHistoryEntry[] }} Błędy pogrupowane według typu
|
||||
*/
|
||||
groupErrorsByType() {
|
||||
const grouped = {};
|
||||
this.errorHistory.forEach(error => {
|
||||
this.errorHistory.forEach((error) => {
|
||||
if (!grouped[error.type]) {
|
||||
grouped[error.type] = [];
|
||||
}
|
||||
@@ -214,7 +187,6 @@ export class ErrorHandler {
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści historię błędów
|
||||
*/
|
||||
@@ -224,9 +196,7 @@ export class ErrorHandler {
|
||||
log.info('Error history cleared');
|
||||
}
|
||||
}
|
||||
|
||||
const errorHandler = new ErrorHandler();
|
||||
|
||||
/**
|
||||
* Wrapper funkcji z automatyczną obsługą błędów
|
||||
* @param {Function} fn - Funkcja do opakowania
|
||||
@@ -237,7 +207,8 @@ export function withErrorHandling(fn, context) {
|
||||
return async function (...args) {
|
||||
try {
|
||||
return await fn.apply(this, args);
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
const handledError = errorHandler.handle(error, context, {
|
||||
functionName: fn.name,
|
||||
arguments: args.length
|
||||
@@ -246,7 +217,6 @@ export function withErrorHandling(fn, context) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator dla metod klasy z automatyczną obsługą błędów
|
||||
* @param {string} context - Kontekst wykonania
|
||||
@@ -254,11 +224,11 @@ export function withErrorHandling(fn, context) {
|
||||
export function handleErrors(context) {
|
||||
return function (target, propertyKey, descriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (...args) {
|
||||
try {
|
||||
return await originalMethod.apply(this, args);
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
const handledError = errorHandler.handle(error, `${context}.${propertyKey}`, {
|
||||
className: target.constructor.name,
|
||||
methodName: propertyKey,
|
||||
@@ -267,86 +237,77 @@ export function handleErrors(context) {
|
||||
throw handledError;
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia błędów walidacji
|
||||
* @param {string} message - Wiadomość błędu
|
||||
* @param {Object} details - Szczegóły walidacji
|
||||
* @param {object} details - Szczegóły walidacji
|
||||
* @returns {AppError} Błąd walidacji
|
||||
*/
|
||||
export function createValidationError(message, details = {}) {
|
||||
return new AppError(message, ErrorTypes.VALIDATION, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia błędów sieciowych
|
||||
* @param {string} message - Wiadomość błędu
|
||||
* @param {Object} details - Szczegóły sieci
|
||||
* @param {object} details - Szczegóły sieci
|
||||
* @returns {AppError} Błąd sieciowy
|
||||
*/
|
||||
export function createNetworkError(message, details = {}) {
|
||||
return new AppError(message, ErrorTypes.NETWORK, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia błędów plików
|
||||
* @param {string} message - Wiadomość błędu
|
||||
* @param {Object} details - Szczegóły pliku
|
||||
* @param {object} details - Szczegóły pliku
|
||||
* @returns {AppError} Błąd pliku
|
||||
*/
|
||||
export function createFileError(message, details = {}) {
|
||||
return new AppError(message, ErrorTypes.FILE_IO, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do bezpiecznego wykonania operacji
|
||||
* @param {Function} operation - Operacja do wykonania
|
||||
* @param {*} fallbackValue - Wartość fallback w przypadku błędu
|
||||
* @param {() => Promise<T>} operation - Operacja do wykonania
|
||||
* @param {T} fallbackValue - Wartość fallback w przypadku błędu
|
||||
* @param {string} context - Kontekst operacji
|
||||
* @returns {*} Wynik operacji lub wartość fallback
|
||||
* @returns {Promise<T>} Wynik operacji lub wartość fallback
|
||||
*/
|
||||
export async function safeExecute(operation, fallbackValue = null, context = 'SafeExecute') {
|
||||
export async function safeExecute(operation, fallbackValue, context = 'SafeExecute') {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
errorHandler.handle(error, context);
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja do retry operacji z exponential backoff
|
||||
* @param {Function} operation - Operacja do powtórzenia
|
||||
* @param {() => Promise<T>} operation - Operacja do powtórzenia
|
||||
* @param {number} maxRetries - Maksymalna liczba prób
|
||||
* @param {number} baseDelay - Podstawowe opóźnienie w ms
|
||||
* @param {string} context - Kontekst operacji
|
||||
* @returns {*} Wynik operacji
|
||||
* @returns {Promise<T>} Wynik operacji
|
||||
*/
|
||||
export async function retryWithBackoff(operation, maxRetries = 3, baseDelay = 1000, context = 'RetryOperation') {
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = baseDelay * Math.pow(2, attempt);
|
||||
log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`, {error: error.message, context});
|
||||
log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`, { error: lastError.message, context });
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw errorHandler.handle(lastError, context, {attempts: maxRetries + 1});
|
||||
throw errorHandler.handle(lastError, context, { attempts: maxRetries + 1 });
|
||||
}
|
||||
|
||||
export {errorHandler};
|
||||
export { errorHandler };
|
||||
export default errorHandler;
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('ImageCache');
|
||||
|
||||
export class ImageCache {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
set(key, imageData) {
|
||||
log.info("Caching image data for key:", key);
|
||||
this.cache.set(key, imageData);
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const data = this.cache.get(key);
|
||||
log.debug("Retrieved cached data for key:", key, !!data);
|
||||
return data;
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
log.info("Clearing image cache");
|
||||
this.cache.clear();
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
import {removeImage, getAllImageIds} from "./db.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
import { removeImage, getAllImageIds } from "./db.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('ImageReferenceManager');
|
||||
|
||||
export class ImageReferenceManager {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.imageReferences = new Map(); // imageId -> count
|
||||
this.imageLastUsed = new Map(); // imageId -> timestamp
|
||||
this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane)
|
||||
this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia
|
||||
this.imageLastUsed = new Map(); // imageId -> timestamp
|
||||
this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane)
|
||||
this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia
|
||||
this.gcTimer = null;
|
||||
this.isGcRunning = false;
|
||||
|
||||
this.operationCount = 0;
|
||||
this.operationThreshold = 500; // Uruchom GC po 500 operacjach
|
||||
|
||||
|
||||
this.operationThreshold = 500; // Uruchom GC po 500 operacjach
|
||||
}
|
||||
|
||||
/**
|
||||
* Uruchamia automatyczne garbage collection
|
||||
*/
|
||||
@@ -26,14 +20,11 @@ export class ImageReferenceManager {
|
||||
if (this.gcTimer) {
|
||||
clearInterval(this.gcTimer);
|
||||
}
|
||||
|
||||
this.gcTimer = setInterval(() => {
|
||||
this.gcTimer = window.setInterval(() => {
|
||||
this.performGarbageCollection();
|
||||
}, this.gcInterval);
|
||||
|
||||
log.info("Garbage collection started with interval:", this.gcInterval / 1000, "seconds");
|
||||
}
|
||||
|
||||
/**
|
||||
* Zatrzymuje automatyczne garbage collection
|
||||
*/
|
||||
@@ -44,38 +35,35 @@ export class ImageReferenceManager {
|
||||
}
|
||||
log.info("Garbage collection stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dodaje referencję do obrazu
|
||||
* @param {string} imageId - ID obrazu
|
||||
*/
|
||||
addReference(imageId) {
|
||||
if (!imageId) return;
|
||||
|
||||
if (!imageId)
|
||||
return;
|
||||
const currentCount = this.imageReferences.get(imageId) || 0;
|
||||
this.imageReferences.set(imageId, currentCount + 1);
|
||||
this.imageLastUsed.set(imageId, Date.now());
|
||||
|
||||
log.debug(`Added reference to image ${imageId}, count: ${currentCount + 1}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuwa referencję do obrazu
|
||||
* @param {string} imageId - ID obrazu
|
||||
*/
|
||||
removeReference(imageId) {
|
||||
if (!imageId) return;
|
||||
|
||||
if (!imageId)
|
||||
return;
|
||||
const currentCount = this.imageReferences.get(imageId) || 0;
|
||||
if (currentCount <= 1) {
|
||||
this.imageReferences.delete(imageId);
|
||||
log.debug(`Removed last reference to image ${imageId}`);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
this.imageReferences.set(imageId, currentCount - 1);
|
||||
log.debug(`Removed reference to image ${imageId}, count: ${currentCount - 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje referencje na podstawie aktualnego stanu canvas
|
||||
*/
|
||||
@@ -86,117 +74,100 @@ export class ImageReferenceManager {
|
||||
usedImageIds.forEach(imageId => {
|
||||
this.addReference(imageId);
|
||||
});
|
||||
|
||||
log.info(`Updated references for ${usedImageIds.size} unique images`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zbiera wszystkie używane imageId z różnych źródeł
|
||||
* @returns {Set<string>} Zbiór używanych imageId
|
||||
*/
|
||||
collectAllUsedImageIds() {
|
||||
const usedImageIds = new Set();
|
||||
this.canvas.layers.forEach(layer => {
|
||||
this.canvas.layers.forEach((layer) => {
|
||||
if (layer.imageId) {
|
||||
usedImageIds.add(layer.imageId);
|
||||
}
|
||||
});
|
||||
if (this.canvas.canvasState && this.canvas.canvasState.layersUndoStack) {
|
||||
this.canvas.canvasState.layersUndoStack.forEach(layersState => {
|
||||
layersState.forEach(layer => {
|
||||
this.canvas.canvasState.layersUndoStack.forEach((layersState) => {
|
||||
layersState.forEach((layer) => {
|
||||
if (layer.imageId) {
|
||||
usedImageIds.add(layer.imageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.canvas.canvasState && this.canvas.canvasState.layersRedoStack) {
|
||||
this.canvas.canvasState.layersRedoStack.forEach(layersState => {
|
||||
layersState.forEach(layer => {
|
||||
this.canvas.canvasState.layersRedoStack.forEach((layersState) => {
|
||||
layersState.forEach((layer) => {
|
||||
if (layer.imageId) {
|
||||
usedImageIds.add(layer.imageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
log.debug(`Collected ${usedImageIds.size} used image IDs`);
|
||||
return usedImageIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Znajduje nieużywane obrazy
|
||||
* @param {Set<string>} usedImageIds - Zbiór używanych imageId
|
||||
* @returns {Array<string>} Lista nieużywanych imageId
|
||||
* @returns {Promise<string[]>} Lista nieużywanych imageId
|
||||
*/
|
||||
async findUnusedImages(usedImageIds) {
|
||||
try {
|
||||
|
||||
const allImageIds = await getAllImageIds();
|
||||
const unusedImages = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const imageId of allImageIds) {
|
||||
|
||||
if (!usedImageIds.has(imageId)) {
|
||||
const lastUsed = this.imageLastUsed.get(imageId) || 0;
|
||||
const age = now - lastUsed;
|
||||
|
||||
if (age > this.maxAge) {
|
||||
unusedImages.push(imageId);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.debug(`Image ${imageId} is unused but too young (age: ${Math.round(age / 1000)}s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`Found ${unusedImages.length} unused images ready for cleanup`);
|
||||
return unusedImages;
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error finding unused images:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści nieużywane obrazy
|
||||
* @param {Array<string>} unusedImages - Lista nieużywanych imageId
|
||||
* @param {string[]} unusedImages - Lista nieużywanych imageId
|
||||
*/
|
||||
async cleanupUnusedImages(unusedImages) {
|
||||
if (unusedImages.length === 0) {
|
||||
log.debug("No unused images to cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Starting cleanup of ${unusedImages.length} unused images`);
|
||||
let cleanedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const imageId of unusedImages) {
|
||||
try {
|
||||
|
||||
await removeImage(imageId);
|
||||
|
||||
if (this.canvas.imageCache && this.canvas.imageCache.has(imageId)) {
|
||||
this.canvas.imageCache.delete(imageId);
|
||||
}
|
||||
|
||||
this.imageReferences.delete(imageId);
|
||||
this.imageLastUsed.delete(imageId);
|
||||
|
||||
cleanedCount++;
|
||||
log.debug(`Cleaned up image: ${imageId}`);
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
errorCount++;
|
||||
log.error(`Error cleaning up image ${imageId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Garbage collection completed: ${cleanedCount} images cleaned, ${errorCount} errors`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wykonuje pełne garbage collection
|
||||
*/
|
||||
@@ -205,44 +176,35 @@ export class ImageReferenceManager {
|
||||
log.debug("Garbage collection already running, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGcRunning = true;
|
||||
log.info("Starting garbage collection...");
|
||||
|
||||
try {
|
||||
|
||||
this.updateReferences();
|
||||
|
||||
const usedImageIds = this.collectAllUsedImageIds();
|
||||
|
||||
const unusedImages = await this.findUnusedImages(usedImageIds);
|
||||
|
||||
await this.cleanupUnusedImages(unusedImages);
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error during garbage collection:", error);
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
this.isGcRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwiększa licznik operacji i sprawdza czy uruchomić GC
|
||||
*/
|
||||
incrementOperationCount() {
|
||||
this.operationCount++;
|
||||
log.debug(`Operation count: ${this.operationCount}/${this.operationThreshold}`);
|
||||
|
||||
if (this.operationCount >= this.operationThreshold) {
|
||||
log.info(`Operation threshold reached (${this.operationThreshold}), triggering garbage collection`);
|
||||
this.operationCount = 0; // Reset counter
|
||||
|
||||
setTimeout(() => {
|
||||
this.performGarbageCollection();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resetuje licznik operacji
|
||||
*/
|
||||
@@ -250,7 +212,6 @@ export class ImageReferenceManager {
|
||||
this.operationCount = 0;
|
||||
log.debug("Operation count reset");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustawia próg operacji dla automatycznego GC
|
||||
* @param {number} threshold - Nowy próg operacji
|
||||
@@ -259,7 +220,6 @@ export class ImageReferenceManager {
|
||||
this.operationThreshold = Math.max(1, threshold);
|
||||
log.info(`Operation threshold set to: ${this.operationThreshold}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ręczne uruchomienie garbage collection
|
||||
*/
|
||||
@@ -267,10 +227,9 @@ export class ImageReferenceManager {
|
||||
log.info("Manual garbage collection triggered");
|
||||
await this.performGarbageCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca statystyki garbage collection
|
||||
* @returns {Object} Statystyki
|
||||
* @returns {GarbageCollectionStats} Statystyki
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
@@ -281,7 +240,6 @@ export class ImageReferenceManager {
|
||||
maxAge: this.maxAge
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści wszystkie dane (przy usuwaniu canvas)
|
||||
*/
|
||||
|
||||
131
js/MaskTool.js
131
js/MaskTool.js
@@ -1,33 +1,35 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('Mask_tool');
|
||||
|
||||
export class MaskTool {
|
||||
constructor(canvasInstance, callbacks = {}) {
|
||||
this.canvasInstance = canvasInstance;
|
||||
this.mainCanvas = canvasInstance.canvas;
|
||||
this.onStateChange = callbacks.onStateChange || null;
|
||||
this.maskCanvas = document.createElement('canvas');
|
||||
this.maskCtx = this.maskCanvas.getContext('2d');
|
||||
|
||||
const maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!maskCtx) {
|
||||
throw new Error("Failed to get 2D context for mask canvas");
|
||||
}
|
||||
this.maskCtx = maskCtx;
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
|
||||
this.isOverlayVisible = true;
|
||||
this.isActive = false;
|
||||
this.brushSize = 20;
|
||||
this.brushStrength = 0.5;
|
||||
this.brushHardness = 0.5;
|
||||
this.isDrawing = false;
|
||||
this.lastPosition = null;
|
||||
|
||||
this.previewCanvas = document.createElement('canvas');
|
||||
this.previewCtx = this.previewCanvas.getContext('2d');
|
||||
const previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!previewCtx) {
|
||||
throw new Error("Failed to get 2D context for preview canvas");
|
||||
}
|
||||
this.previewCtx = previewCtx;
|
||||
this.previewVisible = false;
|
||||
this.previewCanvasInitialized = false;
|
||||
|
||||
this.initMaskCanvas();
|
||||
}
|
||||
|
||||
initPreviewCanvas() {
|
||||
if (this.previewCanvas.parentElement) {
|
||||
this.previewCanvas.parentElement.removeChild(this.previewCanvas);
|
||||
@@ -39,27 +41,22 @@ export class MaskTool {
|
||||
this.previewCanvas.style.top = `${this.canvasInstance.canvas.offsetTop}px`;
|
||||
this.previewCanvas.style.pointerEvents = 'none';
|
||||
this.previewCanvas.style.zIndex = '10';
|
||||
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
|
||||
if (this.canvasInstance.canvas.parentElement) {
|
||||
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
setBrushHardness(hardness) {
|
||||
this.brushHardness = Math.max(0, Math.min(1, hardness));
|
||||
}
|
||||
|
||||
initMaskCanvas() {
|
||||
|
||||
const extraSpace = 2000; // Allow for a generous drawing area outside the output area
|
||||
this.maskCanvas.width = this.canvasInstance.width + extraSpace;
|
||||
this.maskCanvas.height = this.canvasInstance.height + extraSpace;
|
||||
|
||||
|
||||
this.x = -extraSpace / 2;
|
||||
this.y = -extraSpace / 2;
|
||||
|
||||
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
|
||||
log.info(`Initialized mask canvas with extended size: ${this.maskCanvas.width}x${this.maskCanvas.height}, origin at (${this.x}, ${this.y})`);
|
||||
}
|
||||
|
||||
activate() {
|
||||
if (!this.previewCanvasInitialized) {
|
||||
this.initPreviewCanvas();
|
||||
@@ -68,131 +65,108 @@ export class MaskTool {
|
||||
this.isActive = true;
|
||||
this.previewCanvas.style.display = 'block';
|
||||
this.canvasInstance.interaction.mode = 'drawingMask';
|
||||
if (this.canvasInstance.canvasState && this.canvasInstance.canvasState.maskUndoStack.length === 0) {
|
||||
if (this.canvasInstance.canvasState.maskUndoStack.length === 0) {
|
||||
this.canvasInstance.canvasState.saveMaskState();
|
||||
}
|
||||
this.canvasInstance.updateHistoryButtons();
|
||||
|
||||
log.info("Mask tool activated");
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.isActive = false;
|
||||
this.previewCanvas.style.display = 'none';
|
||||
this.canvasInstance.interaction.mode = 'none';
|
||||
this.canvasInstance.updateHistoryButtons();
|
||||
|
||||
log.info("Mask tool deactivated");
|
||||
}
|
||||
|
||||
setBrushSize(size) {
|
||||
this.brushSize = Math.max(1, size);
|
||||
}
|
||||
|
||||
setBrushStrength(strength) {
|
||||
this.brushStrength = Math.max(0, Math.min(1, strength));
|
||||
}
|
||||
|
||||
handleMouseDown(worldCoords, viewCoords) {
|
||||
if (!this.isActive) return;
|
||||
if (!this.isActive)
|
||||
return;
|
||||
this.isDrawing = true;
|
||||
this.lastPosition = worldCoords;
|
||||
this.draw(worldCoords);
|
||||
this.clearPreview();
|
||||
}
|
||||
|
||||
handleMouseMove(worldCoords, viewCoords) {
|
||||
if (this.isActive) {
|
||||
this.drawBrushPreview(viewCoords);
|
||||
}
|
||||
if (!this.isActive || !this.isDrawing) return;
|
||||
if (!this.isActive || !this.isDrawing)
|
||||
return;
|
||||
this.draw(worldCoords);
|
||||
this.lastPosition = worldCoords;
|
||||
}
|
||||
|
||||
handleMouseLeave() {
|
||||
this.previewVisible = false;
|
||||
this.clearPreview();
|
||||
}
|
||||
|
||||
handleMouseEnter() {
|
||||
this.previewVisible = true;
|
||||
}
|
||||
|
||||
handleMouseUp(viewCoords) {
|
||||
if (!this.isActive) return;
|
||||
if (!this.isActive)
|
||||
return;
|
||||
if (this.isDrawing) {
|
||||
this.isDrawing = false;
|
||||
this.lastPosition = null;
|
||||
if (this.canvasInstance.canvasState) {
|
||||
this.canvasInstance.canvasState.saveMaskState();
|
||||
}
|
||||
this.canvasInstance.canvasState.saveMaskState();
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
}
|
||||
this.drawBrushPreview(viewCoords);
|
||||
}
|
||||
}
|
||||
|
||||
draw(worldCoords) {
|
||||
if (!this.lastPosition) {
|
||||
this.lastPosition = worldCoords;
|
||||
}
|
||||
|
||||
|
||||
const canvasLastX = this.lastPosition.x - this.x;
|
||||
const canvasLastY = this.lastPosition.y - this.y;
|
||||
const canvasX = worldCoords.x - this.x;
|
||||
const canvasY = worldCoords.y - this.y;
|
||||
|
||||
|
||||
const canvasWidth = this.maskCanvas.width;
|
||||
const canvasHeight = this.maskCanvas.height;
|
||||
|
||||
if (canvasX >= 0 && canvasX < canvasWidth &&
|
||||
canvasY >= 0 && canvasY < canvasHeight &&
|
||||
canvasLastX >= 0 && canvasLastX < canvasWidth &&
|
||||
canvasLastY >= 0 && canvasLastY < canvasHeight) {
|
||||
|
||||
this.maskCtx.beginPath();
|
||||
this.maskCtx.moveTo(canvasLastX, canvasLastY);
|
||||
this.maskCtx.lineTo(canvasX, canvasY);
|
||||
const gradientRadius = this.brushSize / 2;
|
||||
|
||||
if (this.brushHardness === 1) {
|
||||
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
||||
} else {
|
||||
// hardness: 1 = hard edge, 0 = soft edge
|
||||
}
|
||||
else {
|
||||
const innerRadius = gradientRadius * this.brushHardness;
|
||||
const gradient = this.maskCtx.createRadialGradient(
|
||||
canvasX, canvasY, innerRadius,
|
||||
canvasX, canvasY, gradientRadius
|
||||
);
|
||||
const gradient = this.maskCtx.createRadialGradient(canvasX, canvasY, innerRadius, canvasX, canvasY, gradientRadius);
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||
this.maskCtx.strokeStyle = gradient;
|
||||
}
|
||||
|
||||
this.maskCtx.lineWidth = this.brushSize;
|
||||
this.maskCtx.lineCap = 'round';
|
||||
this.maskCtx.lineJoin = 'round';
|
||||
this.maskCtx.globalCompositeOperation = 'source-over';
|
||||
this.maskCtx.stroke();
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.debug(`Drawing outside mask canvas bounds: (${canvasX}, ${canvasY})`);
|
||||
}
|
||||
}
|
||||
|
||||
drawBrushPreview(viewCoords) {
|
||||
if (!this.previewVisible || this.isDrawing) {
|
||||
this.clearPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearPreview();
|
||||
const zoom = this.canvasInstance.viewport.zoom;
|
||||
const radius = (this.brushSize / 2) * zoom;
|
||||
|
||||
this.previewCtx.beginPath();
|
||||
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
|
||||
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
@@ -200,27 +174,26 @@ export class MaskTool {
|
||||
this.previewCtx.setLineDash([2, 4]);
|
||||
this.previewCtx.stroke();
|
||||
}
|
||||
|
||||
clearPreview() {
|
||||
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
|
||||
if (this.isActive && this.canvasInstance.canvasState) {
|
||||
if (this.isActive) {
|
||||
this.canvasInstance.canvasState.saveMaskState();
|
||||
}
|
||||
}
|
||||
|
||||
getMask() {
|
||||
return this.maskCanvas;
|
||||
}
|
||||
|
||||
getMaskImageWithAlpha() {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = this.maskCanvas.width;
|
||||
tempCanvas.height = this.maskCanvas.height;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!tempCtx) {
|
||||
throw new Error("Failed to get 2D context for temporary canvas");
|
||||
}
|
||||
tempCtx.drawImage(this.maskCanvas, 0, 0);
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
const data = imageData.data;
|
||||
@@ -236,7 +209,6 @@ export class MaskTool {
|
||||
maskImage.src = tempCanvas.toDataURL();
|
||||
return maskImage;
|
||||
}
|
||||
|
||||
resize(width, height) {
|
||||
this.initPreviewCanvas();
|
||||
const oldMask = this.maskCanvas;
|
||||
@@ -244,39 +216,46 @@ export class MaskTool {
|
||||
const oldY = this.y;
|
||||
const oldWidth = oldMask.width;
|
||||
const oldHeight = oldMask.height;
|
||||
|
||||
const isIncreasingWidth = width > (this.canvasInstance.width);
|
||||
const isIncreasingHeight = height > (this.canvasInstance.height);
|
||||
|
||||
const isIncreasingWidth = width > this.canvasInstance.width;
|
||||
const isIncreasingHeight = height > this.canvasInstance.height;
|
||||
this.maskCanvas = document.createElement('canvas');
|
||||
|
||||
const extraSpace = 2000;
|
||||
|
||||
|
||||
const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace);
|
||||
const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace);
|
||||
|
||||
this.maskCanvas.width = newWidth;
|
||||
this.maskCanvas.height = newHeight;
|
||||
this.maskCtx = this.maskCanvas.getContext('2d');
|
||||
|
||||
const newMaskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!newMaskCtx) {
|
||||
throw new Error("Failed to get 2D context for new mask canvas");
|
||||
}
|
||||
this.maskCtx = newMaskCtx;
|
||||
if (oldMask.width > 0 && oldMask.height > 0) {
|
||||
|
||||
const offsetX = this.x - oldX;
|
||||
const offsetY = this.y - oldY;
|
||||
|
||||
this.maskCtx.drawImage(oldMask, offsetX, offsetY);
|
||||
|
||||
log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`);
|
||||
}
|
||||
|
||||
log.info(`Mask canvas resized to ${this.maskCanvas.width}x${this.maskCanvas.height}, position (${this.x}, ${this.y})`);
|
||||
log.info(`Canvas size change: width ${isIncreasingWidth ? 'increased' : 'decreased'}, height ${isIncreasingHeight ? 'increased' : 'decreased'}`);
|
||||
}
|
||||
|
||||
updatePosition(dx, dy) {
|
||||
this.x += dx;
|
||||
this.y += dy;
|
||||
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();
|
||||
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';
|
||||
405
js/css/canvas_view.css
Normal file
405
js/css/canvas_view.css
Normal file
@@ -0,0 +1,405 @@
|
||||
.painter-button {
|
||||
background: linear-gradient(to bottom, #4a4a4a, #3a3a3a);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
margin: 2px;
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.painter-button:hover {
|
||||
background: linear-gradient(to bottom, #5a5a5a, #4a4a4a);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.painter-button:active {
|
||||
background: linear-gradient(to bottom, #3a3a3a, #4a4a4a);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.painter-button:disabled,
|
||||
.painter-button:disabled:hover {
|
||||
background: #555;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.painter-button.primary {
|
||||
background: linear-gradient(to bottom, #4a6cd4, #3a5cc4);
|
||||
border-color: #2a4cb4;
|
||||
}
|
||||
|
||||
.painter-button.primary:hover {
|
||||
background: linear-gradient(to bottom, #5a7ce4, #4a6cd4);
|
||||
}
|
||||
|
||||
.painter-controls {
|
||||
background: linear-gradient(to bottom, #404040, #383838);
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.painter-slider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.painter-slider-container input[type="range"] {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
|
||||
.painter-button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.painter-clipboard-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background-color: rgba(0,0,0,0.15);
|
||||
padding: 3px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.painter-clipboard-group::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(74, 108, 212, 0.6), transparent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.painter-clipboard-group .painter-button {
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
.painter-separator {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background-color: #2a2a2a;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.painter-container {
|
||||
background: #607080; /* 带蓝色的灰色背景 */
|
||||
border: 1px solid #4a5a6a;
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
|
||||
transition: border-color 0.3s ease; /* Dodano dla płynnej zmiany ramki */
|
||||
}
|
||||
|
||||
.painter-container.drag-over {
|
||||
border-color: #00ff00; /* Zielona ramka podczas przeciągania */
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.painter-dialog {
|
||||
background: #404040;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
padding: 20px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.painter-dialog input {
|
||||
background: #303030;
|
||||
border: 1px solid #505050;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
margin: 4px;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.painter-dialog button {
|
||||
background: #505050;
|
||||
border: 1px solid #606060;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 4px 12px;
|
||||
margin: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.painter-dialog button:hover {
|
||||
background: #606060;
|
||||
}
|
||||
|
||||
.blend-opacity-slider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blend-mode-active .blend-opacity-slider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blend-mode-item {
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.blend-mode-item.active {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.blend-mode-item.active {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.painter-tooltip {
|
||||
position: fixed;
|
||||
display: none;
|
||||
background: #3a3a3a;
|
||||
color: #f0f0f0;
|
||||
border: 1px solid #555;
|
||||
border-radius: 8px;
|
||||
padding: 12px 18px;
|
||||
z-index: 9999;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
width: auto;
|
||||
max-width: min(500px, calc(100vw - 40px));
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
pointer-events: none;
|
||||
transform-origin: top left;
|
||||
transition: transform 0.2s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.painter-tooltip.scale-down {
|
||||
transform: scale(0.9);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.painter-tooltip.scale-down-more {
|
||||
transform: scale(0.8);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.painter-tooltip table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.painter-tooltip table td {
|
||||
padding: 2px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.painter-tooltip table td:first-child {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.painter-tooltip table td:last-child {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.painter-tooltip table tr:nth-child(odd) td {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.painter-tooltip {
|
||||
font-size: 11px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.painter-tooltip table td {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.painter-tooltip kbd {
|
||||
padding: 1px 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.painter-tooltip table td:first-child {
|
||||
width: 40%;
|
||||
}
|
||||
.painter-tooltip table td:last-child {
|
||||
width: 60%;
|
||||
}
|
||||
.painter-tooltip h4 {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.painter-tooltip {
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.painter-tooltip table td {
|
||||
padding: 1px 3px;
|
||||
}
|
||||
.painter-tooltip kbd {
|
||||
padding: 0px 3px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.painter-tooltip table td:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
.painter-tooltip table td:last-child {
|
||||
width: 65%;
|
||||
}
|
||||
.painter-tooltip h4 {
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.painter-tooltip h4 {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
color: #4a90e2; /* Jasnoniebieski akcent */
|
||||
border-bottom: 1px solid #555;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.painter-tooltip ul {
|
||||
list-style: none;
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.painter-tooltip kbd {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.painter-container.has-focus {
|
||||
/* Używamy box-shadow, aby stworzyć efekt zewnętrznej ramki,
|
||||
która nie wpłynie na rozmiar ani pozycję elementu. */
|
||||
box-shadow: 0 0 0 2px white;
|
||||
/* Możesz też zmienić kolor istniejącej ramki, ale box-shadow jest bardziej wyrazisty */
|
||||
/* border-color: white; */
|
||||
}
|
||||
|
||||
.painter-button.matting-button {
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.painter-button.matting-button.loading {
|
||||
padding-right: 36px; /* Make space for spinner */
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.painter-button.matting-button .matting-spinner {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: matting-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.painter-button.matting-button.loading .matting-spinner {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes matting-spin {
|
||||
to {
|
||||
transform: translateY(-50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
.painter-modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 111;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.painter-modal-content {
|
||||
width: 90vw;
|
||||
height: 90vh;
|
||||
background-color: #353535;
|
||||
border: 1px solid #222;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 5px 25px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.painterMainContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.painterCanvasContainer {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
53
js/db.js
53
js/db.js
@@ -1,21 +1,17 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
const log = createModuleLogger('db');
|
||||
|
||||
const DB_NAME = 'CanvasNodeDB';
|
||||
const STATE_STORE_NAME = 'CanvasState';
|
||||
const IMAGE_STORE_NAME = 'CanvasImages';
|
||||
const DB_VERSION = 3;
|
||||
|
||||
let db;
|
||||
|
||||
let db = null;
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów
|
||||
* @param {IDBObjectStore} store - Store IndexedDB
|
||||
* @param {string} operation - Nazwa operacji (get, put, delete, clear)
|
||||
* @param {*} data - Dane dla operacji (opcjonalne)
|
||||
* @param {DBRequestOperation} operation - Nazwa operacji (get, put, delete, clear)
|
||||
* @param {any} data - Dane dla operacji (opcjonalne)
|
||||
* @param {string} errorMessage - Wiadomość błędu
|
||||
* @returns {Promise} Promise z wynikiem operacji
|
||||
* @returns {Promise<any>} Promise z wynikiem operacji
|
||||
*/
|
||||
function createDBRequest(store, operation, data, errorMessage) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -37,130 +33,107 @@ function createDBRequest(store, operation, data, errorMessage) {
|
||||
reject(new Error(`Unknown operation: ${operation}`));
|
||||
return;
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
log.error(errorMessage, event.target.error);
|
||||
reject(errorMessage);
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
resolve(event.target.result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (db) {
|
||||
resolve(db);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Opening IndexedDB...");
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = (event) => {
|
||||
log.error("IndexedDB error:", event.target.error);
|
||||
reject("Error opening IndexedDB.");
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
db = event.target.result;
|
||||
log.info("IndexedDB opened successfully.");
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
log.info("Upgrading IndexedDB...");
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(STATE_STORE_NAME)) {
|
||||
db.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'});
|
||||
const dbInstance = event.target.result;
|
||||
if (!dbInstance.objectStoreNames.contains(STATE_STORE_NAME)) {
|
||||
dbInstance.createObjectStore(STATE_STORE_NAME, { keyPath: 'id' });
|
||||
log.info("Object store created:", STATE_STORE_NAME);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(IMAGE_STORE_NAME)) {
|
||||
db.createObjectStore(IMAGE_STORE_NAME, {keyPath: 'imageId'});
|
||||
if (!dbInstance.objectStoreNames.contains(IMAGE_STORE_NAME)) {
|
||||
dbInstance.createObjectStore(IMAGE_STORE_NAME, { keyPath: 'imageId' });
|
||||
log.info("Object store created:", IMAGE_STORE_NAME);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCanvasState(id) {
|
||||
log.info(`Getting state for id: ${id}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
const result = await createDBRequest(store, 'get', id, "Error getting canvas state");
|
||||
log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found');
|
||||
return result ? result.state : null;
|
||||
}
|
||||
|
||||
export async function setCanvasState(id, state) {
|
||||
log.info(`Setting state for id: ${id}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'put', {id, state}, "Error setting canvas state");
|
||||
await createDBRequest(store, 'put', { id, state }, "Error setting canvas state");
|
||||
log.debug(`Set success for id: ${id}`);
|
||||
}
|
||||
|
||||
export async function removeCanvasState(id) {
|
||||
log.info(`Removing state for id: ${id}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'delete', id, "Error removing canvas state");
|
||||
log.debug(`Remove success for id: ${id}`);
|
||||
}
|
||||
|
||||
export async function saveImage(imageId, imageSrc) {
|
||||
log.info(`Saving image with id: ${imageId}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'put', {imageId, imageSrc}, "Error saving image");
|
||||
await createDBRequest(store, 'put', { imageId, imageSrc }, "Error saving image");
|
||||
log.debug(`Image saved successfully for id: ${imageId}`);
|
||||
}
|
||||
|
||||
export async function getImage(imageId) {
|
||||
log.info(`Getting image with id: ${imageId}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
const result = await createDBRequest(store, 'get', imageId, "Error getting image");
|
||||
log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found');
|
||||
return result ? result.imageSrc : null;
|
||||
}
|
||||
|
||||
export async function removeImage(imageId) {
|
||||
log.info(`Removing image with id: ${imageId}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'delete', imageId, "Error removing image");
|
||||
log.debug(`Remove image success for id: ${imageId}`);
|
||||
}
|
||||
|
||||
export async function getAllImageIds() {
|
||||
log.info("Getting all image IDs...");
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAllKeys();
|
||||
|
||||
request.onerror = (event) => {
|
||||
log.error("Error getting all image IDs:", event.target.error);
|
||||
reject("Error getting all image IDs");
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const imageIds = event.target.result;
|
||||
log.debug(`Found ${imageIds.length} image IDs in database`);
|
||||
@@ -168,13 +141,11 @@ export async function getAllImageIds() {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearAllCanvasStates() {
|
||||
log.info("Clearing all canvas states...");
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'clear', null, "Error clearing canvas states");
|
||||
log.info("All canvas states cleared successfully.");
|
||||
}
|
||||
|
||||
115
js/logger.js
115
js/logger.js
@@ -8,6 +8,20 @@
|
||||
* - Możliwość zapisywania logów do localStorage
|
||||
* - Możliwość eksportu logów
|
||||
*/
|
||||
function padStart(str, targetLength, padString) {
|
||||
targetLength = targetLength >> 0;
|
||||
padString = String(padString || ' ');
|
||||
if (str.length > targetLength) {
|
||||
return String(str);
|
||||
}
|
||||
else {
|
||||
targetLength = targetLength - str.length;
|
||||
if (targetLength > padString.length) {
|
||||
padString += padString.repeat(targetLength / padString.length);
|
||||
}
|
||||
return padString.slice(0, targetLength) + String(str);
|
||||
}
|
||||
}
|
||||
export const LogLevel = {
|
||||
DEBUG: 0,
|
||||
INFO: 1,
|
||||
@@ -36,25 +50,22 @@ const LEVEL_NAMES = {
|
||||
[LogLevel.WARN]: 'WARN',
|
||||
[LogLevel.ERROR]: 'ERROR',
|
||||
};
|
||||
|
||||
class Logger {
|
||||
constructor() {
|
||||
this.config = {...DEFAULT_CONFIG};
|
||||
this.config = { ...DEFAULT_CONFIG };
|
||||
this.logs = [];
|
||||
this.enabled = true;
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfiguracja loggera
|
||||
* @param {Object} config - Obiekt konfiguracyjny
|
||||
* @param {Partial<LoggerConfig>} config - Obiekt konfiguracyjny
|
||||
*/
|
||||
configure(config) {
|
||||
this.config = {...this.config, ...config};
|
||||
this.config = { ...this.config, ...config };
|
||||
this.saveConfig();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Włącz/wyłącz logger globalnie
|
||||
* @param {boolean} enabled - Czy logger ma być włączony
|
||||
@@ -63,42 +74,39 @@ class Logger {
|
||||
this.enabled = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustaw globalny poziom logowania
|
||||
* @param {LogLevel} level - Poziom logowania
|
||||
* @param {LogLevels} level - Poziom logowania
|
||||
*/
|
||||
setGlobalLevel(level) {
|
||||
this.config.globalLevel = level;
|
||||
this.saveConfig();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustaw poziom logowania dla konkretnego modułu
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {LogLevel} level - Poziom logowania
|
||||
* @param {LogLevels} level - Poziom logowania
|
||||
*/
|
||||
setModuleLevel(module, level) {
|
||||
this.config.moduleSettings[module] = level;
|
||||
this.saveConfig();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdź, czy dany poziom logowania jest aktywny dla modułu
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {LogLevel} level - Poziom logowania do sprawdzenia
|
||||
* @param {LogLevels} level - Poziom logowania do sprawdzenia
|
||||
* @returns {boolean} - Czy poziom jest aktywny
|
||||
*/
|
||||
isLevelEnabled(module, level) {
|
||||
if (!this.enabled) return false;
|
||||
if (!this.enabled)
|
||||
return false;
|
||||
if (this.config.moduleSettings[module] !== undefined) {
|
||||
return level >= this.config.moduleSettings[module];
|
||||
}
|
||||
return level >= this.config.globalLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatuj znacznik czasu
|
||||
* @returns {string} - Sformatowany znacznik czasu
|
||||
@@ -107,21 +115,20 @@ class Logger {
|
||||
const now = new Date();
|
||||
const format = this.config.timestampFormat;
|
||||
return format
|
||||
.replace('HH', String(now.getHours()).padStart(2, '0'))
|
||||
.replace('mm', String(now.getMinutes()).padStart(2, '0'))
|
||||
.replace('ss', String(now.getSeconds()).padStart(2, '0'))
|
||||
.replace('SSS', String(now.getMilliseconds()).padStart(3, '0'));
|
||||
.replace('HH', padStart(String(now.getHours()), 2, '0'))
|
||||
.replace('mm', padStart(String(now.getMinutes()), 2, '0'))
|
||||
.replace('ss', padStart(String(now.getSeconds()), 2, '0'))
|
||||
.replace('SSS', padStart(String(now.getMilliseconds()), 3, '0'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisz log
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {LogLevel} level - Poziom logowania
|
||||
* @param {Array} args - Argumenty do zalogowania
|
||||
* @param {LogLevels} level - Poziom logowania
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
log(module, level, ...args) {
|
||||
if (!this.isLevelEnabled(module, level)) return;
|
||||
|
||||
if (!this.isLevelEnabled(module, level))
|
||||
return;
|
||||
const timestamp = this.formatTimestamp();
|
||||
const levelName = LEVEL_NAMES[level];
|
||||
const logData = {
|
||||
@@ -141,13 +148,12 @@ class Logger {
|
||||
}
|
||||
this.printToConsole(logData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wyświetl log w konsoli
|
||||
* @param {Object} logData - Dane logu
|
||||
* @param {LogData} logData - Dane logu
|
||||
*/
|
||||
printToConsole(logData) {
|
||||
const {timestamp, module, level, levelName, args} = logData;
|
||||
const { timestamp, module, level, levelName, args } = logData;
|
||||
const prefix = `[${timestamp}] [${module}] [${levelName}]`;
|
||||
if (this.config.useColors && typeof console.log === 'function') {
|
||||
const color = COLORS[level] || '#000000';
|
||||
@@ -156,36 +162,35 @@ class Logger {
|
||||
}
|
||||
console.log(prefix, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisz logi do localStorage
|
||||
*/
|
||||
saveLogs() {
|
||||
if (typeof localStorage !== 'undefined' && this.config.saveToStorage) {
|
||||
try {
|
||||
const simplifiedLogs = this.logs.map(log => ({
|
||||
const simplifiedLogs = this.logs.map((log) => ({
|
||||
t: log.timestamp,
|
||||
m: log.module,
|
||||
l: log.level,
|
||||
a: log.args.map(arg => {
|
||||
a: log.args.map((arg) => {
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return arg;
|
||||
})
|
||||
}));
|
||||
|
||||
localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs));
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to save logs to localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Załaduj logi z localStorage
|
||||
*/
|
||||
@@ -196,12 +201,12 @@ class Logger {
|
||||
if (storedLogs) {
|
||||
this.logs = JSON.parse(storedLogs);
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to load logs from localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisz konfigurację do localStorage
|
||||
*/
|
||||
@@ -209,12 +214,12 @@ class Logger {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config));
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to save logger config to localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Załaduj konfigurację z localStorage
|
||||
*/
|
||||
@@ -223,14 +228,14 @@ class Logger {
|
||||
try {
|
||||
const storedConfig = localStorage.getItem('layerforge_logger_config');
|
||||
if (storedConfig) {
|
||||
this.config = {...this.config, ...JSON.parse(storedConfig)};
|
||||
this.config = { ...this.config, ...JSON.parse(storedConfig) };
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Failed to load logger config from localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wyczyść wszystkie logi
|
||||
*/
|
||||
@@ -241,33 +246,29 @@ class Logger {
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eksportuj logi do pliku
|
||||
* @param {string} format - Format eksportu ('json' lub 'txt')
|
||||
* @param {'json' | 'txt'} format - Format eksportu
|
||||
*/
|
||||
exportLogs(format = 'json') {
|
||||
if (this.logs.length === 0) {
|
||||
console.warn('No logs to export');
|
||||
return;
|
||||
}
|
||||
|
||||
let content;
|
||||
let mimeType;
|
||||
let extension;
|
||||
|
||||
if (format === 'json') {
|
||||
content = JSON.stringify(this.logs, null, 2);
|
||||
mimeType = 'application/json';
|
||||
extension = 'json';
|
||||
} else {
|
||||
content = this.logs.map(log =>
|
||||
`[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`
|
||||
).join('\n');
|
||||
}
|
||||
else {
|
||||
content = this.logs.map((log) => `[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`).join('\n');
|
||||
mimeType = 'text/plain';
|
||||
extension = 'txt';
|
||||
}
|
||||
const blob = new Blob([content], {type: mimeType});
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -277,44 +278,39 @@ class Logger {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie DEBUG
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {...any} args - Argumenty do zalogowania
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
debug(module, ...args) {
|
||||
this.log(module, LogLevel.DEBUG, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie INFO
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {...any} args - Argumenty do zalogowania
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
info(module, ...args) {
|
||||
this.log(module, LogLevel.INFO, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie WARN
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {...any} args - Argumenty do zalogowania
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
warn(module, ...args) {
|
||||
this.log(module, LogLevel.WARN, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie ERROR
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {...any} args - Argumenty do zalogowania
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
error(module, ...args) {
|
||||
this.log(module, LogLevel.ERROR, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
export const debug = (module, ...args) => logger.debug(module, ...args);
|
||||
export const info = (module, ...args) => logger.info(module, ...args);
|
||||
@@ -323,5 +319,4 @@ export const error = (module, ...args) => logger.error(module, ...args);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.LayerForgeLogger = logger;
|
||||
}
|
||||
|
||||
export default logger;
|
||||
export default logger;
|
||||
|
||||
79
js/state-saver.worker.js
Normal file
79
js/state-saver.worker.js
Normal file
@@ -0,0 +1,79 @@
|
||||
"use strict";
|
||||
console.log('[StateWorker] Worker script loaded and running.');
|
||||
const DB_NAME = 'CanvasNodeDB';
|
||||
const STATE_STORE_NAME = 'CanvasState';
|
||||
const DB_VERSION = 3;
|
||||
let db;
|
||||
function log(...args) {
|
||||
console.log('[StateWorker]', ...args);
|
||||
}
|
||||
function error(...args) {
|
||||
console.error('[StateWorker]', ...args);
|
||||
}
|
||||
function createDBRequest(store, operation, data, errorMessage) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
switch (operation) {
|
||||
case 'put':
|
||||
request = store.put(data);
|
||||
break;
|
||||
default:
|
||||
reject(new Error(`Unknown operation: ${operation}`));
|
||||
return;
|
||||
}
|
||||
request.onerror = (event) => {
|
||||
error(errorMessage, event.target.error);
|
||||
reject(errorMessage);
|
||||
};
|
||||
request.onsuccess = (event) => {
|
||||
resolve(event.target.result);
|
||||
};
|
||||
});
|
||||
}
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (db) {
|
||||
resolve(db);
|
||||
return;
|
||||
}
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = (event) => {
|
||||
error("IndexedDB error:", event.target.error);
|
||||
reject("Error opening IndexedDB.");
|
||||
};
|
||||
request.onsuccess = (event) => {
|
||||
db = event.target.result;
|
||||
log("IndexedDB opened successfully in worker.");
|
||||
resolve(db);
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
log("Upgrading IndexedDB in worker...");
|
||||
const tempDb = event.target.result;
|
||||
if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) {
|
||||
tempDb.createObjectStore(STATE_STORE_NAME, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
async function setCanvasState(id, state) {
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
await createDBRequest(store, 'put', { id, state }, "Error setting canvas state");
|
||||
}
|
||||
self.onmessage = async function (e) {
|
||||
log('Message received from main thread:', e.data ? 'data received' : 'no data');
|
||||
const { state, nodeId } = e.data;
|
||||
if (!state || !nodeId) {
|
||||
error('Invalid data received from main thread');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
log(`Saving state for node: ${nodeId}`);
|
||||
await setCanvasState(nodeId, state);
|
||||
log(`State saved successfully for node: ${nodeId}`);
|
||||
}
|
||||
catch (err) {
|
||||
error(`Failed to save state for node: ${nodeId}`, err);
|
||||
}
|
||||
};
|
||||
13
js/templates/clipspace_clipboard_tooltip.html
Normal file
13
js/templates/clipspace_clipboard_tooltip.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<h4>📋 ComfyUI Clipspace Mode</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>ComfyUI Clipspace</strong> as flattened image</td></tr>
|
||||
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
|
||||
<tr><td></td><td>1️⃣ Internal clipboard (copied layers)</td></tr>
|
||||
<tr><td></td><td>2️⃣ ComfyUI Clipspace (workflow images)</td></tr>
|
||||
<tr><td></td><td>3️⃣ System clipboard (fallback)</td></tr>
|
||||
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
|
||||
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
|
||||
</table>
|
||||
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
|
||||
💡 <strong>Bestt for:</strong> ComfyUI workflow integration and node-to-node image transfer
|
||||
</div>
|
||||
9
js/templates/mask_shortcuts.html
Normal file
9
js/templates/mask_shortcuts.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<h4>Mask Mode</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Click + Drag</kbd></td><td>Paint on the mask</td></tr>
|
||||
<tr><td><kbd>Middle Mouse Button + Drag</kbd></td><td>Pan canvas view</td></tr>
|
||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
|
||||
<tr><td><strong>Brush Controls</strong></td><td>Use sliders to control brush <strong>Size</strong>, <strong>Strength</strong>, and <strong>Hardness</strong></td></tr>
|
||||
<tr><td><strong>Clear Mask</strong></td><td>Remove the entire mask</td></tr>
|
||||
<tr><td><strong>Exit Mode</strong></td><td>Click the "Draw Mask" button again</td></tr>
|
||||
</table>
|
||||
40
js/templates/standard_shortcuts.html
Normal file
40
js/templates/standard_shortcuts.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<h4>Canvas Control</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Click + Drag</kbd></td><td>Pan canvas view</td></tr>
|
||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
|
||||
<tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr>
|
||||
<tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr>
|
||||
<tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Clipboard & I/O</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layer(s)</td></tr>
|
||||
<tr><td><kbd>Ctrl + V</kbd></td><td>Paste from clipboard (image or internal layers)</td></tr>
|
||||
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Layer Interaction</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
|
||||
<tr><td><kbd>Ctrl + Click</kbd></td><td>Add/Remove layer from selection</td></tr>
|
||||
<tr><td><kbd>Alt + Drag</kbd></td><td>Clone selected layer(s)</td></tr>
|
||||
<tr><td><kbd>Right Click</kbd></td><td>Show blend mode & opacity menu</td></tr>
|
||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Scale layer (snaps to grid)</td></tr>
|
||||
<tr><td><kbd>Ctrl + Mouse Wheel</kbd></td><td>Fine-scale layer</td></tr>
|
||||
<tr><td><kbd>Shift + Mouse Wheel</kbd></td><td>Rotate layer by 5° steps</td></tr>
|
||||
<tr><td><kbd>Shift + Ctrl + Mouse Wheel</kbd></td><td>Snap rotation to 5° increments</td></tr>
|
||||
<tr><td><kbd>Arrow Keys</kbd></td><td>Nudge layer by 1px</td></tr>
|
||||
<tr><td><kbd>Shift + Arrow Keys</kbd></td><td>Nudge layer by 10px</td></tr>
|
||||
<tr><td><kbd>[</kbd> or <kbd>]</kbd></td><td>Rotate by 1°</td></tr>
|
||||
<tr><td><kbd>Shift + [</kbd> or <kbd>]</kbd></td><td>Rotate by 10°</td></tr>
|
||||
<tr><td><kbd>Delete</kbd></td><td>Delete selected layer(s)</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Transform Handles (on selected layer)</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Drag Corner/Side</kbd></td><td>Resize layer</td></tr>
|
||||
<tr><td><kbd>Drag Rotation Handle</kbd></td><td>Rotate layer</td></tr>
|
||||
<tr><td><kbd>Hold Shift</kbd></td><td>Keep aspect ratio / Snap rotation to 15°</td></tr>
|
||||
<tr><td><kbd>Hold Ctrl</kbd></td><td>Snap to grid</td></tr>
|
||||
</table>
|
||||
16
js/templates/system_clipboard_tooltip.html
Normal file
16
js/templates/system_clipboard_tooltip.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<h4>📋 System Clipboard Mode</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>system clipboard</strong> as flattened image</td></tr>
|
||||
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
|
||||
<tr><td></td><td>1️⃣ Internal clipboard (copied layers)</td></tr>
|
||||
<tr><td></td><td>2️⃣ System clipboard (images, screenshots)</td></tr>
|
||||
<tr><td></td><td>3️⃣ System clipboard (file paths, URLs)</td></tr>
|
||||
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
|
||||
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
|
||||
</table>
|
||||
<div style="margin-top: 8px; padding: 6px; background: rgba(255,165,0,0.2); border: 1px solid rgba(255,165,0,0.4); border-radius: 4px; font-size: 11px;">
|
||||
⚠️ <strong>Security Note:</strong> "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop.
|
||||
</div>
|
||||
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
|
||||
💡 <strong>Best for:</strong> Working with screenshots, copied images, file paths, and urls.
|
||||
</div>
|
||||
1
js/types.js
Normal file
1
js/types.js
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
463
js/utils/ClipboardManager.js
Normal file
463
js/utils/ClipboardManager.js
Normal file
@@ -0,0 +1,463 @@
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
// @ts-ignore
|
||||
import { api } from "../../../scripts/api.js";
|
||||
// @ts-ignore
|
||||
import { ComfyApp } from "../../../scripts/app.js";
|
||||
const log = createModuleLogger('ClipboardManager');
|
||||
export class ClipboardManager {
|
||||
constructor(canvas) {
|
||||
this.canvas = canvas;
|
||||
this.clipboardPreference = 'system'; // 'system', 'clipspace'
|
||||
}
|
||||
/**
|
||||
* Main paste handler that delegates to appropriate methods
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @param {ClipboardPreference} preference - Clipboard preference ('system' or 'clipspace')
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async handlePaste(addMode = 'mouse', preference = 'system') {
|
||||
try {
|
||||
log.info(`ClipboardManager handling paste with preference: ${preference}`);
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
log.info("Found layers in internal clipboard, pasting layers");
|
||||
this.canvas.canvasLayers.pasteLayers();
|
||||
return true;
|
||||
}
|
||||
if (preference === 'clipspace') {
|
||||
log.info("Attempting paste from ComfyUI Clipspace");
|
||||
const success = await this.tryClipspacePaste(addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
log.info("No image found in ComfyUI Clipspace");
|
||||
}
|
||||
log.info("Attempting paste from system clipboard");
|
||||
return await this.trySystemClipboardPaste(addMode);
|
||||
}
|
||||
catch (err) {
|
||||
log.error("ClipboardManager paste operation failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Attempts to paste from ComfyUI Clipspace
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async tryClipspacePaste(addMode) {
|
||||
try {
|
||||
log.info("Attempting to paste from ComfyUI Clipspace");
|
||||
ComfyApp.pasteFromClipspace(this.canvas.node);
|
||||
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
||||
const clipspaceImage = this.canvas.node.imgs[0];
|
||||
if (clipspaceImage && clipspaceImage.src) {
|
||||
log.info("Successfully got image from ComfyUI Clipspace");
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
};
|
||||
img.src = clipspaceImage.src;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch (clipspaceError) {
|
||||
log.warn("ComfyUI Clipspace paste failed:", clipspaceError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* System clipboard paste - handles both image data and text paths
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async trySystemClipboardPaste(addMode) {
|
||||
log.info("ClipboardManager: Checking system clipboard for images and paths");
|
||||
if (navigator.clipboard?.read) {
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
for (const item of clipboardItems) {
|
||||
log.debug("Clipboard item types:", item.types);
|
||||
const imageType = item.types.find(type => type.startsWith('image/'));
|
||||
if (imageType) {
|
||||
try {
|
||||
const blob = await item.getType(imageType);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from system clipboard");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
};
|
||||
if (event.target?.result) {
|
||||
img.src = event.target.result;
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
log.info("Found image data in system clipboard");
|
||||
return true;
|
||||
}
|
||||
catch (error) {
|
||||
log.debug("Error reading image data:", error);
|
||||
}
|
||||
}
|
||||
const textTypes = ['text/plain', 'text/uri-list'];
|
||||
for (const textType of textTypes) {
|
||||
if (item.types.includes(textType)) {
|
||||
try {
|
||||
const textBlob = await item.getType(textType);
|
||||
const text = await textBlob.text();
|
||||
if (this.isValidImagePath(text)) {
|
||||
log.info("Found image path in clipboard:", text);
|
||||
const success = await this.loadImageFromPath(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.debug(`Error reading ${textType}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.debug("Modern clipboard API failed:", error);
|
||||
}
|
||||
}
|
||||
if (navigator.clipboard?.readText) {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
log.debug("Found text in clipboard:", text);
|
||||
if (text && this.isValidImagePath(text)) {
|
||||
log.info("Found valid image path in clipboard:", text);
|
||||
const success = await this.loadImageFromPath(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.debug("Could not read text from clipboard:", error);
|
||||
}
|
||||
}
|
||||
log.debug("No images or valid image paths found in system clipboard");
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Validates if a text string is a valid image file path or URL
|
||||
* @param {string} text - The text to validate
|
||||
* @returns {boolean} - True if the text appears to be a valid image file path or URL
|
||||
*/
|
||||
isValidImagePath(text) {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
text = text.trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) {
|
||||
try {
|
||||
new URL(text);
|
||||
log.debug("Detected valid URL:", text);
|
||||
return true;
|
||||
}
|
||||
catch (e) {
|
||||
log.debug("Invalid URL format:", text);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const imageExtensions = [
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp',
|
||||
'.svg', '.tiff', '.tif', '.ico', '.avif'
|
||||
];
|
||||
const hasImageExtension = imageExtensions.some(ext => text.toLowerCase().endsWith(ext));
|
||||
if (!hasImageExtension) {
|
||||
log.debug("No valid image extension found in:", text);
|
||||
return false;
|
||||
}
|
||||
const pathPatterns = [
|
||||
/^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...)
|
||||
/^[\\\/]/, // Unix absolute path (/...)
|
||||
/^\.{1,2}[\\\/]/, // Relative path (./... or ../...)
|
||||
/^[^\\\/]*[\\\/]/ // Contains path separators
|
||||
];
|
||||
const isValidPath = pathPatterns.some(pattern => pattern.test(text)) ||
|
||||
(!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename
|
||||
if (isValidPath) {
|
||||
log.debug("Detected valid local file path:", text);
|
||||
}
|
||||
else {
|
||||
log.debug("Invalid local file path format:", text);
|
||||
}
|
||||
return isValidPath;
|
||||
}
|
||||
/**
|
||||
* Attempts to load an image from a file path using simplified methods
|
||||
* @param {string} filePath - The file path to load
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async loadImageFromPath(filePath, addMode) {
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
return new Promise((resolve) => {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from URL");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load image from URL:", filePath);
|
||||
resolve(false);
|
||||
};
|
||||
img.src = filePath;
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
log.warn("Error loading image from URL:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
log.info("Attempting to load local file via backend");
|
||||
const success = await this.loadFileViaBackend(filePath, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.warn("Backend loading failed:", error);
|
||||
}
|
||||
try {
|
||||
log.info("Falling back to file picker");
|
||||
const success = await this.promptUserForFile(filePath, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.warn("File picker failed:", error);
|
||||
}
|
||||
this.showFilePathMessage(filePath);
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Loads a local file via the ComfyUI backend endpoint
|
||||
* @param {string} filePath - The file path to load
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async loadFileViaBackend(filePath, addMode) {
|
||||
try {
|
||||
log.info("Loading file via ComfyUI backend:", filePath);
|
||||
const response = await api.fetchApi("/ycnode/load_image_from_path", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
log.debug("Backend failed to load image:", errorData.error);
|
||||
return false;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
log.debug("Backend returned error:", data.error);
|
||||
return false;
|
||||
}
|
||||
log.info("Successfully loaded image via ComfyUI backend:", filePath);
|
||||
const img = new Image();
|
||||
const success = await new Promise((resolve) => {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from backend response");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load image from backend response");
|
||||
resolve(false);
|
||||
};
|
||||
img.src = data.image_data;
|
||||
});
|
||||
return success;
|
||||
}
|
||||
catch (error) {
|
||||
log.debug("Error loading file via ComfyUI backend:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Prompts the user to select a file when a local path is detected
|
||||
* @param {string} originalPath - The original file path from clipboard
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async promptUserForFile(originalPath, addMode) {
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
const fileName = originalPath.split(/[\\\/]/).pop();
|
||||
fileInput.onchange = async (event) => {
|
||||
const target = event.target;
|
||||
const file = target.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from file picker");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load selected image");
|
||||
resolve(false);
|
||||
};
|
||||
if (e.target?.result) {
|
||||
img.src = e.target.result;
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
log.warn("Failed to read selected file");
|
||||
resolve(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
catch (error) {
|
||||
log.warn("Error processing selected file:", error);
|
||||
resolve(false);
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.warn("Selected file is not an image");
|
||||
resolve(false);
|
||||
}
|
||||
document.body.removeChild(fileInput);
|
||||
};
|
||||
fileInput.oncancel = () => {
|
||||
log.info("File selection cancelled by user");
|
||||
document.body.removeChild(fileInput);
|
||||
resolve(false);
|
||||
};
|
||||
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 {AddMode} addMode - The mode for adding the layer
|
||||
*/
|
||||
showEmptyClipboardMessage(addMode) {
|
||||
const message = `Copied a file? Browser can't access file paths for security. Click here to select the file manually.`;
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #2d5aa0;
|
||||
color: white;
|
||||
padding: 14px 18px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
z-index: 10001;
|
||||
max-width: 320px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
border: 2px solid #4a7bc8;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
`;
|
||||
notification.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 18px;">📁</span>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; opacity: 0.9; margin-top: 4px;">
|
||||
💡 Tip: You can also drag & drop files directly onto the canvas
|
||||
</div>
|
||||
`;
|
||||
notification.onmouseenter = () => {
|
||||
notification.style.backgroundColor = '#3d6bb0';
|
||||
notification.style.borderColor = '#5a8bd8';
|
||||
notification.style.transform = 'translateY(-1px)';
|
||||
};
|
||||
notification.onmouseleave = () => {
|
||||
notification.style.backgroundColor = '#2d5aa0';
|
||||
notification.style.borderColor = '#4a7bc8';
|
||||
notification.style.transform = 'translateY(0)';
|
||||
};
|
||||
notification.onclick = async () => {
|
||||
document.body.removeChild(notification);
|
||||
try {
|
||||
const success = await this.promptUserForFile('image_file.jpg', addMode);
|
||||
if (success) {
|
||||
log.info("Successfully loaded image via empty clipboard file picker");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
log.warn("Error with empty clipboard file picker:", error);
|
||||
}
|
||||
};
|
||||
document.body.appendChild(notification);
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 12000);
|
||||
log.info("Showed enhanced empty clipboard message with file picker option");
|
||||
}
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
/**
|
||||
* CommonUtils - Wspólne funkcje pomocnicze
|
||||
* Eliminuje duplikację funkcji używanych w różnych modułach
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generuje unikalny identyfikator UUID
|
||||
* @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
*/
|
||||
export function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja snap do siatki
|
||||
* @param {number} value - Wartość do przyciągnięcia
|
||||
@@ -23,58 +17,48 @@ export function generateUUID() {
|
||||
export function snapToGrid(value, gridSize = 64) {
|
||||
return Math.round(value / gridSize) * gridSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Oblicza dostosowanie snap dla warstwy
|
||||
* @param {Object} layer - Obiekt warstwy
|
||||
* @param {number} gridSize - Rozmiar siatki
|
||||
* @param {number} snapThreshold - Próg przyciągania
|
||||
* @returns {Object} Obiekt z dx i dy
|
||||
* @returns {Point} Obiekt z dx i dy
|
||||
*/
|
||||
export function getSnapAdjustment(layer, gridSize = 64, snapThreshold = 10) {
|
||||
if (!layer) {
|
||||
return {dx: 0, dy: 0};
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
const layerEdges = {
|
||||
left: layer.x,
|
||||
right: layer.x + layer.width,
|
||||
top: layer.y,
|
||||
bottom: layer.y + layer.height
|
||||
};
|
||||
|
||||
const x_adjustments = [
|
||||
{type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left},
|
||||
{type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right}
|
||||
];
|
||||
|
||||
{ type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left },
|
||||
{ type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right }
|
||||
].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
|
||||
const y_adjustments = [
|
||||
{type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top},
|
||||
{type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom}
|
||||
];
|
||||
|
||||
x_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
|
||||
y_adjustments.forEach(adj => adj.abs = Math.abs(adj.delta));
|
||||
|
||||
{ type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top },
|
||||
{ type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom }
|
||||
].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
|
||||
const bestXSnap = x_adjustments
|
||||
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
||||
.sort((a, b) => a.abs - b.abs)[0];
|
||||
const bestYSnap = y_adjustments
|
||||
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
||||
.sort((a, b) => a.abs - b.abs)[0];
|
||||
|
||||
return {
|
||||
dx: bestXSnap ? bestXSnap.delta : 0,
|
||||
dy: bestYSnap ? bestYSnap.delta : 0
|
||||
x: bestXSnap ? bestXSnap.delta : 0,
|
||||
y: bestYSnap ? bestYSnap.delta : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje współrzędne świata na lokalne
|
||||
* @param {number} worldX - Współrzędna X w świecie
|
||||
* @param {number} worldY - Współrzędna Y w świecie
|
||||
* @param {Object} layerProps - Właściwości warstwy
|
||||
* @returns {Object} Lokalne współrzędne {x, y}
|
||||
* @param {any} layerProps - Właściwości warstwy
|
||||
* @returns {Point} Lokalne współrzędne {x, y}
|
||||
*/
|
||||
export function worldToLocal(worldX, worldY, layerProps) {
|
||||
const dx = worldX - layerProps.centerX;
|
||||
@@ -82,46 +66,38 @@ export function worldToLocal(worldX, worldY, layerProps) {
|
||||
const rad = -layerProps.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
return {
|
||||
x: dx * cos - dy * sin,
|
||||
y: dx * sin + dy * cos
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje współrzędne lokalne na świat
|
||||
* @param {number} localX - Lokalna współrzędna X
|
||||
* @param {number} localY - Lokalna współrzędna Y
|
||||
* @param {Object} layerProps - Właściwości warstwy
|
||||
* @returns {Object} Współrzędne świata {x, y}
|
||||
* @param {any} layerProps - Właściwości warstwy
|
||||
* @returns {Point} Współrzędne świata {x, y}
|
||||
*/
|
||||
export function localToWorld(localX, localY, layerProps) {
|
||||
const rad = layerProps.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
return {
|
||||
x: layerProps.centerX + localX * cos - localY * sin,
|
||||
y: layerProps.centerY + localX * sin + localY * cos
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Klonuje warstwy (bez klonowania obiektów Image dla oszczędności pamięci)
|
||||
* @param {Array} layers - Tablica warstw do sklonowania
|
||||
* @returns {Array} Sklonowane warstwy
|
||||
* @param {Layer[]} layers - Tablica warstw do sklonowania
|
||||
* @returns {Layer[]} Sklonowane warstwy
|
||||
*/
|
||||
export function cloneLayers(layers) {
|
||||
return layers.map(layer => {
|
||||
const newLayer = {...layer};
|
||||
return newLayer;
|
||||
});
|
||||
return layers.map(layer => ({ ...layer }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy sygnaturę stanu warstw (dla porównań)
|
||||
* @param {Array} layers - Tablica warstw
|
||||
* @param {Layer[]} layers - Tablica warstw
|
||||
* @returns {string} Sygnatura JSON
|
||||
*/
|
||||
export function getStateSignature(layers) {
|
||||
@@ -135,51 +111,51 @@ export function getStateSignature(layers) {
|
||||
rotation: Math.round((layer.rotation || 0) * 100) / 100,
|
||||
zIndex: layer.zIndex,
|
||||
blendMode: layer.blendMode || 'normal',
|
||||
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1
|
||||
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1,
|
||||
flipH: !!layer.flipH,
|
||||
flipV: !!layer.flipV
|
||||
};
|
||||
|
||||
if (layer.imageId) {
|
||||
sig.imageId = layer.imageId;
|
||||
}
|
||||
|
||||
if (layer.image && layer.image.src) {
|
||||
sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures
|
||||
}
|
||||
|
||||
return sig;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce funkcja - opóźnia wykonanie funkcji
|
||||
* @param {Function} func - Funkcja do wykonania
|
||||
* @param {number} wait - Czas oczekiwania w ms
|
||||
* @param {boolean} immediate - Czy wykonać natychmiast
|
||||
* @returns {Function} Funkcja z debounce
|
||||
* @returns {(...args: any[]) => void} Funkcja z debounce
|
||||
*/
|
||||
export function debounce(func, wait, immediate) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
if (!immediate) func(...args);
|
||||
if (!immediate)
|
||||
func.apply(this, args);
|
||||
};
|
||||
const callNow = immediate && !timeout;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
if (callNow) func(...args);
|
||||
if (timeout)
|
||||
clearTimeout(timeout);
|
||||
timeout = window.setTimeout(later, wait);
|
||||
if (callNow)
|
||||
func.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle funkcja - ogranicza częstotliwość wykonania
|
||||
* @param {Function} func - Funkcja do wykonania
|
||||
* @param {number} limit - Limit czasu w ms
|
||||
* @returns {Function} Funkcja z throttle
|
||||
* @returns {(...args: any[]) => void} Funkcja z throttle
|
||||
*/
|
||||
export function throttle(func, limit) {
|
||||
let inThrottle;
|
||||
return function(...args) {
|
||||
return function (...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
@@ -187,7 +163,6 @@ export function throttle(func, limit) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ogranicza wartość do zakresu
|
||||
* @param {number} value - Wartość do ograniczenia
|
||||
@@ -198,7 +173,6 @@ export function throttle(func, limit) {
|
||||
export function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolacja liniowa między dwoma wartościami
|
||||
* @param {number} start - Wartość początkowa
|
||||
@@ -209,7 +183,6 @@ export function clamp(value, min, max) {
|
||||
export function lerp(start, end, factor) {
|
||||
return start + (end - start) * factor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje stopnie na radiany
|
||||
* @param {number} degrees - Stopnie
|
||||
@@ -218,7 +191,6 @@ export function lerp(start, end, factor) {
|
||||
export function degreesToRadians(degrees) {
|
||||
return degrees * Math.PI / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje radiany na stopnie
|
||||
* @param {number} radians - Radiany
|
||||
@@ -227,23 +199,23 @@ export function degreesToRadians(degrees) {
|
||||
export function radiansToDegrees(radians) {
|
||||
return radians * 180 / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy canvas z kontekstem - eliminuje duplikaty w kodzie
|
||||
* @param {number} width - Szerokość canvas
|
||||
* @param {number} height - Wysokość canvas
|
||||
* @param {string} contextType - Typ kontekstu (domyślnie '2d')
|
||||
* @param {Object} contextOptions - Opcje kontekstu
|
||||
* @returns {Object} Obiekt z canvas i ctx
|
||||
* @param {object} contextOptions - Opcje kontekstu
|
||||
* @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null}} Obiekt z canvas i ctx
|
||||
*/
|
||||
export function createCanvas(width, height, contextType = '2d', contextOptions = {}) {
|
||||
const canvas = document.createElement('canvas');
|
||||
if (width) canvas.width = width;
|
||||
if (height) canvas.height = height;
|
||||
if (width)
|
||||
canvas.width = width;
|
||||
if (height)
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext(contextType, contextOptions);
|
||||
return { canvas, ctx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizuje wartość do zakresu Uint8 (0-255)
|
||||
* @param {number} value - Wartość do znormalizowania (0-1)
|
||||
@@ -252,11 +224,10 @@ export function createCanvas(width, height, contextType = '2d', contextOptions =
|
||||
export function normalizeToUint8(value) {
|
||||
return Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generuje unikalną nazwę pliku z identyfikatorem node-a
|
||||
* @param {string} baseName - Podstawowa nazwa pliku
|
||||
* @param {string|number} nodeId - Identyfikator node-a
|
||||
* @param {string | number} nodeId - Identyfikator node-a
|
||||
* @returns {string} Unikalna nazwa pliku
|
||||
*/
|
||||
export function generateUniqueFileName(baseName, nodeId) {
|
||||
@@ -271,7 +242,6 @@ export function generateUniqueFileName(baseName, nodeId) {
|
||||
const nameWithoutExt = baseName.replace(`.${extension}`, '');
|
||||
return `${nameWithoutExt}_node_${nodeId}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdza czy punkt jest w prostokącie
|
||||
* @param {number} pointX - X punktu
|
||||
@@ -284,5 +254,5 @@ export function generateUniqueFileName(baseName, nodeId) {
|
||||
*/
|
||||
export function isPointInRect(pointX, pointY, rectX, rectY, rectWidth, rectHeight) {
|
||||
return pointX >= rectX && pointX <= rectX + rectWidth &&
|
||||
pointY >= rectY && pointY <= rectY + rectHeight;
|
||||
pointY >= rectY && pointY <= rectY + rectHeight;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
|
||||
const log = createModuleLogger('ImageUtils');
|
||||
|
||||
export function validateImageData(data) {
|
||||
log.debug("Validating data structure:", {
|
||||
hasData: !!data,
|
||||
@@ -12,306 +11,222 @@ export function validateImageData(data) {
|
||||
dataType: data?.data ? data.data.constructor.name : null,
|
||||
fullData: data
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
log.info("Data is null or undefined");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
log.debug("Data is array, getting first element");
|
||||
data = data[0];
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
log.info("Invalid data type");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.data) {
|
||||
log.info("Missing data property");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(data.data instanceof Float32Array)) {
|
||||
try {
|
||||
data.data = new Float32Array(data.data);
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
log.error("Failed to convert data to Float32Array:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function convertImageData(data) {
|
||||
log.info("Converting image data:", data);
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
data = data[0];
|
||||
}
|
||||
|
||||
const shape = data.shape;
|
||||
const height = shape[1];
|
||||
const width = shape[2];
|
||||
const channels = shape[3];
|
||||
const floatData = new Float32Array(data.data);
|
||||
|
||||
log.debug("Processing dimensions:", {height, width, channels});
|
||||
|
||||
log.debug("Processing dimensions:", { height, width, channels });
|
||||
const rgbaData = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
for (let h = 0; h < height; h++) {
|
||||
for (let w = 0; w < width; w++) {
|
||||
const pixelIndex = (h * width + w) * 4;
|
||||
const tensorIndex = (h * width + w) * channels;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const value = floatData[tensorIndex + c];
|
||||
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
rgbaData[pixelIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: rgbaData,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMaskToImageData(imageData, maskData) {
|
||||
log.info("Applying mask to image data");
|
||||
|
||||
const rgbaData = new Uint8ClampedArray(imageData.data);
|
||||
const width = imageData.width;
|
||||
const height = imageData.height;
|
||||
|
||||
const maskShape = maskData.shape;
|
||||
const maskFloatData = new Float32Array(maskData.data);
|
||||
|
||||
log.debug(`Applying mask of shape: ${maskShape}`);
|
||||
|
||||
for (let h = 0; h < height; h++) {
|
||||
for (let w = 0; w < width; w++) {
|
||||
const pixelIndex = (h * width + w) * 4;
|
||||
const maskIndex = h * width + w;
|
||||
|
||||
const alpha = maskFloatData[maskIndex];
|
||||
rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255)));
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Mask application completed");
|
||||
|
||||
return {
|
||||
data: rgbaData,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
|
||||
export const prepareImageForCanvas = withErrorHandling(function(inputImage) {
|
||||
export const prepareImageForCanvas = withErrorHandling(function (inputImage) {
|
||||
log.info("Preparing image for canvas:", inputImage);
|
||||
|
||||
if (Array.isArray(inputImage)) {
|
||||
inputImage = inputImage[0];
|
||||
}
|
||||
|
||||
if (!inputImage || !inputImage.shape || !inputImage.data) {
|
||||
throw createValidationError("Invalid input image format", { inputImage });
|
||||
}
|
||||
|
||||
const shape = inputImage.shape;
|
||||
const height = shape[1];
|
||||
const width = shape[2];
|
||||
const channels = shape[3];
|
||||
const floatData = new Float32Array(inputImage.data);
|
||||
|
||||
log.debug("Image dimensions:", {height, width, channels});
|
||||
|
||||
log.debug("Image dimensions:", { height, width, channels });
|
||||
const rgbaData = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
for (let h = 0; h < height; h++) {
|
||||
for (let w = 0; w < width; w++) {
|
||||
const pixelIndex = (h * width + w) * 4;
|
||||
const tensorIndex = (h * width + w) * channels;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const value = floatData[tensorIndex + c];
|
||||
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
rgbaData[pixelIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: rgbaData,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}, 'prepareImageForCanvas');
|
||||
|
||||
/**
|
||||
* Konwertuje obraz PIL/Canvas na tensor
|
||||
* @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji
|
||||
* @returns {Promise<Object>} Tensor z danymi obrazu
|
||||
*/
|
||||
export const imageToTensor = withErrorHandling(async function(image) {
|
||||
export const imageToTensor = withErrorHandling(async function (image) {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = image.width || image.naturalWidth;
|
||||
canvas.height = image.height || image.naturalHeight;
|
||||
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = new Float32Array(canvas.width * canvas.height * 3);
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
const pixelIndex = i / 4;
|
||||
data[pixelIndex * 3] = imageData.data[i] / 255;
|
||||
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
|
||||
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = new Float32Array(canvas.width * canvas.height * 3);
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
const pixelIndex = i / 4;
|
||||
data[pixelIndex * 3] = imageData.data[i] / 255;
|
||||
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
|
||||
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
|
||||
}
|
||||
return {
|
||||
data: data,
|
||||
shape: [1, canvas.height, canvas.width, 3],
|
||||
width: canvas.width,
|
||||
height: canvas.height
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: data,
|
||||
shape: [1, canvas.height, canvas.width, 3],
|
||||
width: canvas.width,
|
||||
height: canvas.height
|
||||
};
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'imageToTensor');
|
||||
|
||||
/**
|
||||
* Konwertuje tensor na obraz HTML
|
||||
* @param {Object} tensor - Tensor z danymi obrazu
|
||||
* @returns {Promise<HTMLImageElement>} Obraz HTML
|
||||
*/
|
||||
export const tensorToImage = withErrorHandling(async function(tensor) {
|
||||
export const tensorToImage = withErrorHandling(async function (tensor) {
|
||||
if (!tensor || !tensor.data || !tensor.shape) {
|
||||
throw createValidationError("Invalid tensor format", { tensor });
|
||||
}
|
||||
|
||||
const [, height, width, channels] = tensor.shape;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = tensor.data;
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
const tensorIndex = i * channels;
|
||||
|
||||
imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255);
|
||||
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255);
|
||||
imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
|
||||
imageData.data[pixelIndex + 3] = 255;
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = tensor.data;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
const tensorIndex = i * channels;
|
||||
imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255);
|
||||
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255);
|
||||
imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
|
||||
imageData.data[pixelIndex + 3] = 255;
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'tensorToImage');
|
||||
|
||||
/**
|
||||
* Zmienia rozmiar obrazu z zachowaniem proporcji
|
||||
* @param {HTMLImageElement} image - Obraz do przeskalowania
|
||||
* @param {number} maxWidth - Maksymalna szerokość
|
||||
* @param {number} maxHeight - Maksymalna wysokość
|
||||
* @returns {Promise<HTMLImageElement>} Przeskalowany obraz
|
||||
*/
|
||||
export const resizeImage = withErrorHandling(async function(image, maxWidth, maxHeight) {
|
||||
export const resizeImage = withErrorHandling(async function (image, maxWidth, maxHeight) {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const originalWidth = image.width || image.naturalWidth;
|
||||
const originalHeight = image.height || image.naturalHeight;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const originalWidth = image.width;
|
||||
const originalHeight = image.height;
|
||||
const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
|
||||
const newWidth = Math.round(originalWidth * scale);
|
||||
const newHeight = Math.round(originalHeight * scale);
|
||||
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
|
||||
ctx.drawImage(image, 0, 0, newWidth, newHeight);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
if (ctx) {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(image, 0, 0, newWidth, newHeight);
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'resizeImage');
|
||||
|
||||
/**
|
||||
* Tworzy miniaturę obrazu
|
||||
* @param {HTMLImageElement} image - Obraz źródłowy
|
||||
* @param {number} size - Rozmiar miniatury (kwadrat)
|
||||
* @returns {Promise<HTMLImageElement>} Miniatura
|
||||
*/
|
||||
export const createThumbnail = withErrorHandling(async function(image, size = 128) {
|
||||
export const createThumbnail = withErrorHandling(async function (image, size = 128) {
|
||||
return resizeImage(image, size, size);
|
||||
}, 'createThumbnail');
|
||||
|
||||
/**
|
||||
* Konwertuje obraz na base64
|
||||
* @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji
|
||||
* @param {string} format - Format obrazu (png, jpeg, webp)
|
||||
* @param {number} quality - Jakość (0-1) dla formatów stratnych
|
||||
* @returns {string} Base64 string
|
||||
*/
|
||||
export const imageToBase64 = withErrorHandling(function(image, format = 'png', quality = 0.9) {
|
||||
export const imageToBase64 = withErrorHandling(function (image, format = 'png', quality = 0.9) {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
canvas.width = image.width || image.naturalWidth;
|
||||
canvas.height = image.height || image.naturalHeight;
|
||||
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
const mimeType = `image/${format}`;
|
||||
return canvas.toDataURL(mimeType, quality);
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
canvas.width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
|
||||
canvas.height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const mimeType = `image/${format}`;
|
||||
return canvas.toDataURL(mimeType, quality);
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'imageToBase64');
|
||||
|
||||
/**
|
||||
* Konwertuje base64 na obraz
|
||||
* @param {string} base64 - Base64 string
|
||||
* @returns {Promise<HTMLImageElement>} Obraz
|
||||
*/
|
||||
export const base64ToImage = withErrorHandling(function(base64) {
|
||||
export const base64ToImage = withErrorHandling(function (base64) {
|
||||
if (!base64) {
|
||||
throw createValidationError("Base64 string is required");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
@@ -319,74 +234,49 @@ export const base64ToImage = withErrorHandling(function(base64) {
|
||||
img.src = base64;
|
||||
});
|
||||
}, 'base64ToImage');
|
||||
|
||||
/**
|
||||
* Sprawdza czy obraz jest prawidłowy
|
||||
* @param {HTMLImageElement} image - Obraz do sprawdzenia
|
||||
* @returns {boolean} Czy obraz jest prawidłowy
|
||||
*/
|
||||
export function isValidImage(image) {
|
||||
return image &&
|
||||
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
|
||||
image.width > 0 &&
|
||||
image.height > 0;
|
||||
return image &&
|
||||
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
|
||||
image.width > 0 &&
|
||||
image.height > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pobiera informacje o obrazie
|
||||
* @param {HTMLImageElement} image - Obraz
|
||||
* @returns {Object} Informacje o obrazie
|
||||
*/
|
||||
export function getImageInfo(image) {
|
||||
if (!isValidImage(image)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
|
||||
const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
|
||||
return {
|
||||
width: image.width || image.naturalWidth,
|
||||
height: image.height || image.naturalHeight,
|
||||
aspectRatio: (image.width || image.naturalWidth) / (image.height || image.naturalHeight),
|
||||
area: (image.width || image.naturalWidth) * (image.height || image.naturalHeight)
|
||||
width,
|
||||
height,
|
||||
aspectRatio: width / height,
|
||||
area: width * height
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy obraz z podanego źródła - eliminuje duplikaty w kodzie
|
||||
* @param {string} source - Źródło obrazu (URL, data URL, etc.)
|
||||
* @returns {Promise<HTMLImageElement>} Promise z obrazem
|
||||
*/
|
||||
export function createImageFromSource(source) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy pusty obraz o podanych wymiarach
|
||||
* @param {number} width - Szerokość
|
||||
* @param {number} height - Wysokość
|
||||
* @param {string} color - Kolor tła (CSS color)
|
||||
* @returns {Promise<HTMLImageElement>} Pusty obraz
|
||||
*/
|
||||
export const createEmptyImage = withErrorHandling(function(width, height, color = 'transparent') {
|
||||
export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
if (color !== 'transparent') {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
if (ctx) {
|
||||
if (color !== 'transparent') {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'createEmptyImage');
|
||||
|
||||
@@ -2,18 +2,15 @@
|
||||
* LoggerUtils - Centralizacja inicjalizacji loggerów
|
||||
* Eliminuje powtarzalny kod inicjalizacji loggera w każdym module
|
||||
*/
|
||||
|
||||
import {logger, LogLevel} from "../logger.js";
|
||||
|
||||
import { logger, LogLevel } from "../logger.js";
|
||||
import { LOG_LEVEL } from '../config.js';
|
||||
/**
|
||||
* Tworzy obiekt loggera dla modułu z predefiniowanymi metodami
|
||||
* @param {string} moduleName - Nazwa modułu
|
||||
* @param {LogLevel} level - Poziom logowania (domyślnie DEBUG)
|
||||
* @returns {Object} Obiekt z metodami logowania
|
||||
* @returns {Logger} Obiekt z metodami logowania
|
||||
*/
|
||||
export function createModuleLogger(moduleName, level = LogLevel.NONE) {
|
||||
logger.setModuleLevel(moduleName, level);
|
||||
|
||||
export function createModuleLogger(moduleName) {
|
||||
logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL]);
|
||||
return {
|
||||
debug: (...args) => logger.debug(moduleName, ...args),
|
||||
info: (...args) => logger.info(moduleName, ...args),
|
||||
@@ -21,62 +18,57 @@ export function createModuleLogger(moduleName, level = LogLevel.NONE) {
|
||||
error: (...args) => logger.error(moduleName, ...args)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL
|
||||
* @param {LogLevel} level - Poziom logowania
|
||||
* @returns {Object} Obiekt z metodami logowania
|
||||
* @returns {Logger} Obiekt z metodami logowania
|
||||
*/
|
||||
export function createAutoLogger(level = LogLevel.DEBUG) {
|
||||
export function createAutoLogger() {
|
||||
const stack = new Error().stack;
|
||||
const match = stack.match(/\/([^\/]+)\.js/);
|
||||
const match = stack?.match(/\/([^\/]+)\.js/);
|
||||
const moduleName = match ? match[1] : 'Unknown';
|
||||
|
||||
return createModuleLogger(moduleName, level);
|
||||
return createModuleLogger(moduleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper dla operacji z automatycznym logowaniem błędów
|
||||
* @param {Function} operation - Operacja do wykonania
|
||||
* @param {Object} log - Obiekt loggera
|
||||
* @param {Logger} log - Obiekt loggera
|
||||
* @param {string} operationName - Nazwa operacji (dla logów)
|
||||
* @returns {Function} Opakowana funkcja
|
||||
*/
|
||||
export function withErrorLogging(operation, log, operationName) {
|
||||
return async function(...args) {
|
||||
return async function (...args) {
|
||||
try {
|
||||
log.debug(`Starting ${operationName}`);
|
||||
const result = await operation.apply(this, args);
|
||||
log.debug(`Completed ${operationName}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`Error in ${operationName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator dla metod klasy z automatycznym logowaniem
|
||||
* @param {Object} log - Obiekt loggera
|
||||
* @param {Logger} log - Obiekt loggera
|
||||
* @param {string} methodName - Nazwa metody
|
||||
*/
|
||||
export function logMethod(log, methodName) {
|
||||
return function(target, propertyKey, descriptor) {
|
||||
return function (target, propertyKey, descriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function(...args) {
|
||||
descriptor.value = async function (...args) {
|
||||
try {
|
||||
log.debug(`${methodName || propertyKey} started`);
|
||||
const result = await originalMethod.apply(this, args);
|
||||
log.debug(`${methodName || propertyKey} completed`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error(`${methodName || propertyKey} failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
30
js/utils/ResourceManager.js
Normal file
30
js/utils/ResourceManager.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// @ts-ignore
|
||||
import { $el } from "../../../scripts/ui.js";
|
||||
export function addStylesheet(url) {
|
||||
if (url.endsWith(".js")) {
|
||||
url = url.substr(0, url.length - 2) + "css";
|
||||
}
|
||||
$el("link", {
|
||||
parent: document.head,
|
||||
rel: "stylesheet",
|
||||
type: "text/css",
|
||||
href: url.startsWith("http") ? url : getUrl(url),
|
||||
});
|
||||
}
|
||||
export function getUrl(path, baseUrl) {
|
||||
if (baseUrl) {
|
||||
return new URL(path, baseUrl).toString();
|
||||
}
|
||||
else {
|
||||
// @ts-ignore
|
||||
return new URL("../" + path, import.meta.url).toString();
|
||||
}
|
||||
}
|
||||
export async function loadTemplate(path, baseUrl) {
|
||||
const url = getUrl(path, baseUrl);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load template: ${url}`);
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
|
||||
import { createModuleLogger } from "./LoggerUtils.js";
|
||||
const log = createModuleLogger('WebSocketManager');
|
||||
|
||||
class WebSocketManager {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
@@ -11,41 +9,33 @@ class WebSocketManager {
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectInterval = 5000; // 5 seconds
|
||||
this.ackCallbacks = new Map(); // Store callbacks for messages awaiting ACK
|
||||
this.ackCallbacks = new Map();
|
||||
this.messageIdCounter = 0;
|
||||
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
log.debug("WebSocket is already open.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isConnecting) {
|
||||
log.debug("Connection attempt already in progress.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
log.info(`Connecting to WebSocket at ${this.url}...`);
|
||||
|
||||
try {
|
||||
this.socket = new WebSocket(this.url);
|
||||
|
||||
this.socket.onopen = () => {
|
||||
this.isConnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
log.info("WebSocket connection established.");
|
||||
this.flushMessageQueue();
|
||||
};
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
log.debug("Received message:", data);
|
||||
|
||||
if (data.type === 'ack' && data.nodeId) {
|
||||
const callback = this.ackCallbacks.get(data.nodeId);
|
||||
if (callback) {
|
||||
@@ -54,65 +44,59 @@ class WebSocketManager {
|
||||
this.ackCallbacks.delete(data.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
log.error("Error parsing incoming WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
this.isConnecting = false;
|
||||
if (event.wasClean) {
|
||||
log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.warn("WebSocket connection died. Attempting to reconnect...");
|
||||
this.handleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
this.isConnecting = false;
|
||||
log.error("WebSocket error:", error);
|
||||
|
||||
};
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
this.isConnecting = false;
|
||||
log.error("Failed to create WebSocket connection:", error);
|
||||
this.handleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
handleReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`);
|
||||
setTimeout(() => this.connect(), this.reconnectInterval);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.error("Max reconnect attempts reached. Giving up.");
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(data, requiresAck = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const nodeId = data.nodeId;
|
||||
if (requiresAck && !nodeId) {
|
||||
return reject(new Error("A nodeId is required for messages that need acknowledgment."));
|
||||
}
|
||||
|
||||
const message = JSON.stringify(data);
|
||||
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(message);
|
||||
log.debug("Sent message:", data);
|
||||
if (requiresAck) {
|
||||
if (requiresAck && nodeId) {
|
||||
log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.ackCallbacks.delete(nodeId);
|
||||
reject(new Error(`ACK timeout for nodeId ${nodeId}`));
|
||||
log.warn(`ACK timeout for nodeId ${nodeId}.`);
|
||||
}, 10000); // 10-second timeout
|
||||
|
||||
this.ackCallbacks.set(nodeId, {
|
||||
resolve: (responseData) => {
|
||||
clearTimeout(timeout);
|
||||
@@ -123,37 +107,35 @@ class WebSocketManager {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
resolve(); // Resolve immediately if no ACK is needed
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
log.warn("WebSocket not open. Queuing message.");
|
||||
|
||||
|
||||
|
||||
this.messageQueue.push(message);
|
||||
if (!this.isConnecting) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
if (requiresAck) {
|
||||
reject(new Error("Cannot send message with ACK required while disconnected."));
|
||||
}
|
||||
else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
flushMessageQueue() {
|
||||
log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
|
||||
|
||||
|
||||
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift();
|
||||
this.socket.send(message);
|
||||
if (this.socket && message) {
|
||||
this.socket.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`;
|
||||
export const webSocketManager = new WebSocketManager(wsUrl);
|
||||
|
||||
171
js/utils/mask_utils.js
Normal file
171
js/utils/mask_utils.js
Normal file
@@ -0,0 +1,171 @@
|
||||
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 ?? null;
|
||||
}
|
||||
export function mask_editor_showing(app) {
|
||||
const editor = get_mask_editor_element(app);
|
||||
return !!editor && editor.style.display !== "none";
|
||||
}
|
||||
export function hide_mask_editor(app) {
|
||||
if (mask_editor_showing(app)) {
|
||||
const editor = document.getElementById('maskEditor');
|
||||
if (editor) {
|
||||
editor.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
function get_mask_editor_cancel_button(app) {
|
||||
const cancelButton = document.getElementById("maskEditor_topBarCancelButton");
|
||||
if (cancelButton) {
|
||||
log.debug("Found cancel button by ID: maskEditor_topBarCancelButton");
|
||||
return cancelButton;
|
||||
}
|
||||
const cancelSelectors = [
|
||||
'button[onclick*="cancel"]',
|
||||
'button[onclick*="Cancel"]',
|
||||
'input[value="Cancel"]'
|
||||
];
|
||||
for (const selector of cancelSelectors) {
|
||||
try {
|
||||
const button = document.querySelector(selector);
|
||||
if (button) {
|
||||
log.debug("Found cancel button with selector:", selector);
|
||||
return button;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
log.warn("Invalid selector:", selector, e);
|
||||
}
|
||||
}
|
||||
const allButtons = document.querySelectorAll('button, input[type="button"]');
|
||||
for (const button of allButtons) {
|
||||
const text = button.textContent || button.value || '';
|
||||
if (text.toLowerCase().includes('cancel')) {
|
||||
log.debug("Found cancel button by text content:", text);
|
||||
return button;
|
||||
}
|
||||
}
|
||||
const editorElement = get_mask_editor_element(app);
|
||||
if (editorElement) {
|
||||
const childNodes = editorElement?.parentElement?.lastChild?.childNodes;
|
||||
if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) {
|
||||
return childNodes[2];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function get_mask_editor_save_button(app) {
|
||||
const saveButton = document.getElementById("maskEditor_topBarSaveButton");
|
||||
if (saveButton) {
|
||||
return saveButton;
|
||||
}
|
||||
const editorElement = get_mask_editor_element(app);
|
||||
if (editorElement) {
|
||||
const childNodes = editorElement?.parentElement?.lastChild?.childNodes;
|
||||
if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) {
|
||||
return childNodes[2];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
export function mask_editor_listen_for_cancel(app, callback) {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 sekund
|
||||
const findAndAttachListener = () => {
|
||||
attempts++;
|
||||
const cancel_button = get_mask_editor_cancel_button(app);
|
||||
if (cancel_button instanceof HTMLElement && !cancel_button.filter_listener_added) {
|
||||
log.info("Cancel button found, attaching listener");
|
||||
cancel_button.addEventListener('click', callback);
|
||||
cancel_button.filter_listener_added = true;
|
||||
}
|
||||
else if (attempts < maxAttempts) {
|
||||
setTimeout(findAndAttachListener, 100);
|
||||
}
|
||||
else {
|
||||
log.warn("Could not find cancel button after", maxAttempts, "attempts");
|
||||
const globalClickHandler = (event) => {
|
||||
const target = event.target;
|
||||
const text = target.textContent || target.value || '';
|
||||
if (target && (text.toLowerCase().includes('cancel') ||
|
||||
target.id.toLowerCase().includes('cancel') ||
|
||||
target.className.toLowerCase().includes('cancel'))) {
|
||||
log.info("Cancel detected via global click handler");
|
||||
callback();
|
||||
document.removeEventListener('click', globalClickHandler);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', globalClickHandler);
|
||||
log.debug("Added global click handler for cancel detection");
|
||||
}
|
||||
};
|
||||
findAndAttachListener();
|
||||
}
|
||||
export function press_maskeditor_save(app) {
|
||||
const button = get_mask_editor_save_button(app);
|
||||
if (button instanceof HTMLElement) {
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
export function press_maskeditor_cancel(app) {
|
||||
const button = get_mask_editor_cancel_button(app);
|
||||
if (button instanceof HTMLElement) {
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Uruchamia mask editor z predefiniowaną maską
|
||||
* @param {Canvas} canvasInstance - Instancja Canvas
|
||||
* @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia
|
||||
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
|
||||
*/
|
||||
export 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 {Canvas} canvasInstance - Instancja Canvas
|
||||
*/
|
||||
export function start_mask_editor_auto(canvasInstance) {
|
||||
if (!canvasInstance) {
|
||||
log.error('Canvas instance is required');
|
||||
return;
|
||||
}
|
||||
canvasInstance.startMaskEditor(null, true);
|
||||
}
|
||||
/**
|
||||
* Tworzy maskę z obrazu dla użycia w mask editorze
|
||||
* @param {string} imageSrc - Źródło obrazu (URL lub data URL)
|
||||
* @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
|
||||
*/
|
||||
export function create_mask_from_image_src(imageSrc) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = imageSrc;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Konwertuje canvas do Image dla użycia jako maska
|
||||
* @param {HTMLCanvasElement} canvas - Canvas do konwersji
|
||||
* @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
|
||||
*/
|
||||
export function canvas_to_mask_image(canvas) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "layerforge"
|
||||
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
|
||||
version = "1.2.4"
|
||||
version = "1.3.8"
|
||||
license = {file = "LICENSE"}
|
||||
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'
|
||||
277
src/BatchPreviewManager.ts
Normal file
277
src/BatchPreviewManager.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer, Point } from './types';
|
||||
|
||||
const log = createModuleLogger('BatchPreviewManager');
|
||||
|
||||
interface GenerationArea {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class BatchPreviewManager {
|
||||
public active: boolean;
|
||||
private canvas: Canvas;
|
||||
private counterElement: HTMLSpanElement | null;
|
||||
private currentIndex: number;
|
||||
private element: HTMLDivElement | null;
|
||||
public generationArea: GenerationArea | null;
|
||||
private isDragging: boolean;
|
||||
private layers: Layer[];
|
||||
private maskWasVisible: boolean;
|
||||
private uiInitialized: boolean;
|
||||
private worldX: number;
|
||||
private worldY: number;
|
||||
|
||||
constructor(canvas: Canvas, initialPosition: Point = { x: 0, y: 0 }, generationArea: GenerationArea | null = null) {
|
||||
this.canvas = canvas;
|
||||
this.active = false;
|
||||
this.layers = [];
|
||||
this.currentIndex = 0;
|
||||
this.element = null;
|
||||
this.counterElement = null;
|
||||
this.uiInitialized = false;
|
||||
this.maskWasVisible = false;
|
||||
|
||||
this.worldX = initialPosition.x;
|
||||
this.worldY = initialPosition.y;
|
||||
this.isDragging = false;
|
||||
this.generationArea = generationArea;
|
||||
}
|
||||
|
||||
updateScreenPosition(viewport: { x: number, y: number, zoom: number }): void {
|
||||
if (!this.active || !this.element) return;
|
||||
|
||||
const screenX = (this.worldX - viewport.x) * viewport.zoom;
|
||||
const screenY = (this.worldY - viewport.y) * viewport.zoom;
|
||||
|
||||
const scale = 1;
|
||||
|
||||
this.element.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`;
|
||||
}
|
||||
|
||||
private _createUI(): void {
|
||||
if (this.uiInitialized) return;
|
||||
|
||||
this.element = document.createElement('div');
|
||||
this.element.id = 'layerforge-batch-preview';
|
||||
this.element.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #333;
|
||||
color: white;
|
||||
padding: 8px 15px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
font-family: sans-serif;
|
||||
z-index: 1001;
|
||||
border: 1px solid #555;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
this.element.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).tagName === 'BUTTON') return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.isDragging = true;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (this.isDragging) {
|
||||
const deltaX = moveEvent.movementX / this.canvas.viewport.zoom;
|
||||
const deltaY = moveEvent.movementY / this.canvas.viewport.zoom;
|
||||
|
||||
this.worldX += deltaX;
|
||||
this.worldY += deltaY;
|
||||
|
||||
// The render loop will handle updating the screen position, but we need to trigger it.
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
this.isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
const prevButton = this._createButton('◀', 'Previous'); // Left arrow
|
||||
const nextButton = this._createButton('▶', 'Next'); // Right arrow
|
||||
const confirmButton = this._createButton('✔', 'Confirm'); // Checkmark
|
||||
const cancelButton = this._createButton('✖', 'Cancel All');
|
||||
const closeButton = this._createButton('➲', 'Close');
|
||||
|
||||
this.counterElement = document.createElement('span');
|
||||
this.counterElement.style.minWidth = '40px';
|
||||
this.counterElement.style.textAlign = 'center';
|
||||
this.counterElement.style.fontWeight = 'bold';
|
||||
|
||||
prevButton.onclick = () => this.navigate(-1);
|
||||
nextButton.onclick = () => this.navigate(1);
|
||||
confirmButton.onclick = () => this.confirm();
|
||||
cancelButton.onclick = () => this.cancelAndRemoveAll();
|
||||
closeButton.onclick = () => this.hide();
|
||||
|
||||
this.element.append(prevButton, this.counterElement, nextButton, confirmButton, cancelButton, closeButton);
|
||||
if (this.canvas.canvas.parentElement) {
|
||||
this.canvas.canvas.parentElement.appendChild(this.element);
|
||||
} else {
|
||||
log.error("Could not find parent node to attach batch preview UI.");
|
||||
}
|
||||
this.uiInitialized = true;
|
||||
}
|
||||
|
||||
private _createButton(innerHTML: string, title: string): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.innerHTML = innerHTML;
|
||||
button.title = title;
|
||||
button.style.cssText = `
|
||||
background: #555;
|
||||
color: white;
|
||||
border: 1px solid #777;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
button.onmouseover = () => button.style.background = '#666';
|
||||
button.onmouseout = () => button.style.background = '#555';
|
||||
return button;
|
||||
}
|
||||
|
||||
show(layers: Layer[]): void {
|
||||
if (!layers || layers.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._createUI();
|
||||
|
||||
// Auto-hide mask logic
|
||||
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
|
||||
if (this.maskWasVisible) {
|
||||
this.canvas.maskTool.toggleOverlayVisibility();
|
||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.remove('primary');
|
||||
toggleBtn.textContent = "Hide Mask";
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
log.info(`Showing batch preview for ${layers.length} layers.`);
|
||||
this.layers = layers;
|
||||
this.currentIndex = 0;
|
||||
|
||||
if (this.element) {
|
||||
this.element.style.display = 'flex';
|
||||
}
|
||||
this.active = true;
|
||||
|
||||
if (this.element) {
|
||||
const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom;
|
||||
const paddingInWorld = 20 / this.canvas.viewport.zoom;
|
||||
|
||||
this.worldX -= menuWidthInWorld / 2;
|
||||
this.worldY += paddingInWorld;
|
||||
}
|
||||
|
||||
this._update();
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
log.info('Hiding batch preview.');
|
||||
if (this.element) {
|
||||
this.element.remove();
|
||||
}
|
||||
this.active = false;
|
||||
|
||||
const index = this.canvas.batchPreviewManagers.indexOf(this);
|
||||
if (index > -1) {
|
||||
this.canvas.batchPreviewManagers.splice(index, 1);
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
|
||||
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
|
||||
this.canvas.maskTool.toggleOverlayVisibility();
|
||||
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`);
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.add('primary');
|
||||
toggleBtn.textContent = "Show Mask";
|
||||
}
|
||||
}
|
||||
this.maskWasVisible = false;
|
||||
|
||||
this.canvas.layers.forEach((l: Layer) => (l as any).visible = true);
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
navigate(direction: number): void {
|
||||
this.currentIndex += direction;
|
||||
if (this.currentIndex < 0) {
|
||||
this.currentIndex = this.layers.length - 1;
|
||||
} else if (this.currentIndex >= this.layers.length) {
|
||||
this.currentIndex = 0;
|
||||
}
|
||||
this._update();
|
||||
}
|
||||
|
||||
confirm(): void {
|
||||
const layerToKeep = this.layers[this.currentIndex];
|
||||
log.info(`Confirming selection: Keeping layer ${layerToKeep.id}.`);
|
||||
|
||||
const layersToDelete = this.layers.filter((l: Layer) => l.id !== layerToKeep.id);
|
||||
const layerIdsToDelete = layersToDelete.map((l: Layer) => l.id);
|
||||
|
||||
this.canvas.removeLayersByIds(layerIdsToDelete);
|
||||
log.info(`Deleted ${layersToDelete.length} other layers.`);
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
cancelAndRemoveAll(): void {
|
||||
log.info('Cancel clicked. Removing all new layers.');
|
||||
|
||||
const layerIdsToDelete = this.layers.map((l: Layer) => l.id);
|
||||
this.canvas.removeLayersByIds(layerIdsToDelete);
|
||||
log.info(`Deleted all ${layerIdsToDelete.length} new layers.`);
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private _update(): void {
|
||||
if (this.counterElement) {
|
||||
this.counterElement.textContent = `${this.currentIndex + 1} / ${this.layers.length}`;
|
||||
}
|
||||
this._focusOnLayer(this.layers[this.currentIndex]);
|
||||
}
|
||||
|
||||
private _focusOnLayer(layer: Layer): void {
|
||||
if (!layer) return;
|
||||
log.debug(`Focusing on layer ${layer.id}`);
|
||||
|
||||
// Move the selected layer to the top of the layer stack
|
||||
this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 });
|
||||
|
||||
this.canvas.updateSelection([layer]);
|
||||
|
||||
// Render is called by moveLayers, but we call it again to be safe
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
605
src/Canvas.ts
Normal file
605
src/Canvas.ts
Normal file
@@ -0,0 +1,605 @@
|
||||
// @ts-ignore
|
||||
import {api} from "../../scripts/api.js";
|
||||
// @ts-ignore
|
||||
import {app} from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import {ComfyApp} from "../../scripts/app.js";
|
||||
|
||||
import {removeImage} from "./db.js";
|
||||
import {MaskTool} from "./MaskTool.js";
|
||||
import {CanvasState} from "./CanvasState.js";
|
||||
import {CanvasInteractions} from "./CanvasInteractions.js";
|
||||
import {CanvasLayers} from "./CanvasLayers.js";
|
||||
import {CanvasLayersPanel} from "./CanvasLayersPanel.js";
|
||||
import {CanvasRenderer} from "./CanvasRenderer.js";
|
||||
import {CanvasIO} from "./CanvasIO.js";
|
||||
import {ImageReferenceManager} from "./ImageReferenceManager.js";
|
||||
import {BatchPreviewManager} from "./BatchPreviewManager.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import { debounce } from "./utils/CommonUtils.js";
|
||||
import {CanvasMask} from "./CanvasMask.js";
|
||||
import {CanvasSelection} from "./CanvasSelection.js";
|
||||
import type { ComfyNode, Layer, Viewport, Point, AddMode } from './types';
|
||||
|
||||
const useChainCallback = (original: any, next: any) => {
|
||||
if (original === undefined || original === null) {
|
||||
return next;
|
||||
}
|
||||
return function(this: any, ...args: any[]) {
|
||||
const originalReturn = original.apply(this, args);
|
||||
const nextReturn = next.apply(this, args);
|
||||
return nextReturn === undefined ? originalReturn : nextReturn;
|
||||
};
|
||||
};
|
||||
|
||||
const log = createModuleLogger('Canvas');
|
||||
|
||||
/**
|
||||
* Canvas - Fasada dla systemu rysowania
|
||||
*
|
||||
* Klasa Canvas pełni rolę fasady, oferując uproszczony interfejs wysokiego poziomu
|
||||
* dla złożonego systemu rysowania. Zamiast eksponować wszystkie metody modułów,
|
||||
* udostępnia tylko kluczowe operacje i umożliwia bezpośredni dostęp do modułów
|
||||
* gdy potrzebna jest bardziej szczegółowa kontrola.
|
||||
*/
|
||||
export class Canvas {
|
||||
batchPreviewManagers: BatchPreviewManager[];
|
||||
canvas: HTMLCanvasElement;
|
||||
canvasIO: CanvasIO;
|
||||
canvasInteractions: CanvasInteractions;
|
||||
canvasLayers: CanvasLayers;
|
||||
canvasLayersPanel: CanvasLayersPanel;
|
||||
canvasMask: CanvasMask;
|
||||
canvasRenderer: CanvasRenderer;
|
||||
canvasSelection: CanvasSelection;
|
||||
canvasState: CanvasState;
|
||||
ctx: CanvasRenderingContext2D;
|
||||
dataInitialized: boolean;
|
||||
height: number;
|
||||
imageCache: Map<string, any>;
|
||||
imageReferenceManager: ImageReferenceManager;
|
||||
interaction: any;
|
||||
isMouseOver: boolean;
|
||||
lastMousePosition: Point;
|
||||
layers: Layer[];
|
||||
maskTool: MaskTool;
|
||||
node: ComfyNode;
|
||||
offscreenCanvas: HTMLCanvasElement;
|
||||
offscreenCtx: CanvasRenderingContext2D | null;
|
||||
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
|
||||
onStateChange: (() => void) | undefined;
|
||||
pendingBatchContext: any;
|
||||
pendingDataCheck: number | null;
|
||||
previewVisible: boolean;
|
||||
requestSaveState: () => void;
|
||||
viewport: Viewport;
|
||||
widget: any;
|
||||
width: number;
|
||||
|
||||
constructor(node: ComfyNode, widget: any, callbacks: { onStateChange?: () => void, onHistoryChange?: (historyInfo: { canUndo: boolean; canRedo: boolean; }) => void } = {}) {
|
||||
this.node = node;
|
||||
this.widget = widget;
|
||||
this.canvas = document.createElement('canvas');
|
||||
const ctx = this.canvas.getContext('2d', {willReadFrequently: true});
|
||||
if (!ctx) throw new Error("Could not create canvas context");
|
||||
this.ctx = ctx;
|
||||
this.width = 512;
|
||||
this.height = 512;
|
||||
this.layers = [];
|
||||
this.onStateChange = callbacks.onStateChange;
|
||||
this.onHistoryChange = callbacks.onHistoryChange;
|
||||
this.lastMousePosition = {x: 0, y: 0};
|
||||
|
||||
this.viewport = {
|
||||
x: -(this.width / 4),
|
||||
y: -(this.height / 4),
|
||||
zoom: 0.8,
|
||||
};
|
||||
|
||||
this.offscreenCanvas = document.createElement('canvas');
|
||||
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
|
||||
alpha: false
|
||||
});
|
||||
|
||||
this.dataInitialized = false;
|
||||
this.pendingDataCheck = null;
|
||||
this.imageCache = new Map();
|
||||
|
||||
this.requestSaveState = () => {};
|
||||
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
|
||||
this.canvasMask = new CanvasMask(this);
|
||||
this.canvasState = new CanvasState(this);
|
||||
this.canvasSelection = new CanvasSelection(this);
|
||||
this.canvasInteractions = new CanvasInteractions(this);
|
||||
this.canvasLayers = new CanvasLayers(this);
|
||||
this.canvasLayersPanel = new CanvasLayersPanel(this);
|
||||
this.canvasRenderer = new CanvasRenderer(this);
|
||||
this.canvasIO = new CanvasIO(this);
|
||||
this.imageReferenceManager = new ImageReferenceManager(this);
|
||||
this.batchPreviewManagers = [];
|
||||
this.pendingBatchContext = null;
|
||||
this.interaction = this.canvasInteractions.interaction;
|
||||
this.previewVisible = false;
|
||||
this.isMouseOver = false;
|
||||
|
||||
this._initializeModules();
|
||||
this._setupCanvas();
|
||||
|
||||
log.debug('Canvas widget element:', this.node);
|
||||
log.info('Canvas initialized', {
|
||||
nodeId: this.node.id,
|
||||
dimensions: {width: this.width, height: this.height},
|
||||
viewport: this.viewport
|
||||
});
|
||||
|
||||
this.previewVisible = false;
|
||||
}
|
||||
|
||||
|
||||
async waitForWidget(name: any, node: any, interval = 100, timeout = 20000) {
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const check = () => {
|
||||
const widget = node.widgets.find((w: any) => w.name === name);
|
||||
if (widget) {
|
||||
resolve(widget);
|
||||
} else if (Date.now() - startTime > timeout) {
|
||||
reject(new Error(`Widget "${name}" not found within timeout.`));
|
||||
} else {
|
||||
setTimeout(check, interval);
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Kontroluje widoczność podglądu canvas
|
||||
* @param {boolean} visible - Czy podgląd ma być widoczny
|
||||
*/
|
||||
async setPreviewVisibility(visible: boolean) {
|
||||
this.previewVisible = visible;
|
||||
log.info("Canvas preview visibility set to:", visible);
|
||||
|
||||
const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node) as any;
|
||||
if (imagePreviewWidget) {
|
||||
log.debug("Found $$canvas-image-preview widget, controlling visibility");
|
||||
|
||||
if (visible) {
|
||||
if (imagePreviewWidget.options) {
|
||||
imagePreviewWidget.options.hidden = false;
|
||||
}
|
||||
if ('visible' in imagePreviewWidget) {
|
||||
imagePreviewWidget.visible = true;
|
||||
}
|
||||
if ('hidden' in imagePreviewWidget) {
|
||||
imagePreviewWidget.hidden = false;
|
||||
}
|
||||
imagePreviewWidget.computeSize = function () {
|
||||
return [0, 250]; // Szerokość 0 (auto), wysokość 250
|
||||
};
|
||||
} else {
|
||||
if (imagePreviewWidget.options) {
|
||||
imagePreviewWidget.options.hidden = true;
|
||||
}
|
||||
if ('visible' in imagePreviewWidget) {
|
||||
imagePreviewWidget.visible = false;
|
||||
}
|
||||
if ('hidden' in imagePreviewWidget) {
|
||||
imagePreviewWidget.hidden = true;
|
||||
}
|
||||
|
||||
imagePreviewWidget.computeSize = function () {
|
||||
return [0, 0]; // Szerokość 0, wysokość 0
|
||||
};
|
||||
}
|
||||
this.render()
|
||||
} else {
|
||||
log.warn("$$canvas-image-preview widget not found in Canvas.js");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicjalizuje moduły systemu canvas
|
||||
* @private
|
||||
*/
|
||||
_initializeModules() {
|
||||
log.debug('Initializing Canvas modules...');
|
||||
|
||||
// Stwórz opóźnioną wersję funkcji zapisu stanu
|
||||
this.requestSaveState = debounce(() => this.saveState(), 500);
|
||||
|
||||
this._setupAutoRefreshHandlers();
|
||||
|
||||
log.debug('Canvas modules initialized successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfiguruje podstawowe właściwości canvas
|
||||
* @private
|
||||
*/
|
||||
_setupCanvas() {
|
||||
this.initCanvas();
|
||||
this.canvasInteractions.setupEventListeners();
|
||||
this.canvasIO.initNodeData();
|
||||
|
||||
this.layers = this.layers.map((layer: Layer) => ({
|
||||
...layer,
|
||||
opacity: 1
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Ładuje stan canvas z bazy danych
|
||||
*/
|
||||
async loadInitialState() {
|
||||
log.info("Loading initial state for node:", this.node.id);
|
||||
const loaded = await this.canvasState.loadStateFromDB();
|
||||
if (!loaded) {
|
||||
log.info("No saved state found, initializing from node data.");
|
||||
await this.canvasIO.initNodeData();
|
||||
}
|
||||
this.saveState();
|
||||
this.render();
|
||||
|
||||
// Dodaj to wywołanie, aby panel renderował się po załadowaniu stanu
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisuje obecny stan
|
||||
* @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii
|
||||
*/
|
||||
saveState(replaceLast = false) {
|
||||
log.debug('Saving canvas state', {replaceLast, layersCount: this.layers.length});
|
||||
this.canvasState.saveState(replaceLast);
|
||||
this.incrementOperationCount();
|
||||
this._notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cofnij ostatnią operację
|
||||
*/
|
||||
undo() {
|
||||
log.info('Performing undo operation');
|
||||
const historyInfo = this.canvasState.getHistoryInfo();
|
||||
log.debug('History state before undo:', historyInfo);
|
||||
|
||||
this.canvasState.undo();
|
||||
this.incrementOperationCount();
|
||||
this._notifyStateChange();
|
||||
|
||||
// Powiadom panel warstw o zmianie stanu warstw
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
this.canvasLayersPanel.onSelectionChanged();
|
||||
}
|
||||
|
||||
log.debug('Undo completed, layers count:', this.layers.length);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Ponów cofniętą operację
|
||||
*/
|
||||
redo() {
|
||||
log.info('Performing redo operation');
|
||||
const historyInfo = this.canvasState.getHistoryInfo();
|
||||
log.debug('History state before redo:', historyInfo);
|
||||
|
||||
this.canvasState.redo();
|
||||
this.incrementOperationCount();
|
||||
this._notifyStateChange();
|
||||
|
||||
// Powiadom panel warstw o zmianie stanu warstw
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
this.canvasLayersPanel.onSelectionChanged();
|
||||
}
|
||||
|
||||
log.debug('Redo completed, layers count:', this.layers.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderuje canvas
|
||||
*/
|
||||
render() {
|
||||
this.canvasRenderer.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dodaje warstwę z obrazem
|
||||
* @param {Image} image - Obraz do dodania
|
||||
* @param {Object} layerProps - Właściwości warstwy
|
||||
* @param {string} addMode - Tryb dodawania
|
||||
*/
|
||||
async addLayer(image: HTMLImageElement, layerProps = {}, addMode: AddMode = 'default') {
|
||||
const result = await this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
|
||||
|
||||
// Powiadom panel warstw o dodaniu nowej warstwy
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuwa wybrane warstwy
|
||||
*/
|
||||
removeLayersByIds(layerIds: string[]) {
|
||||
if (!layerIds || layerIds.length === 0) return;
|
||||
|
||||
const initialCount = this.layers.length;
|
||||
this.saveState();
|
||||
this.layers = this.layers.filter((l: Layer) => !layerIds.includes(l.id));
|
||||
|
||||
// If the current selection was part of the removal, clear it
|
||||
const newSelection = this.canvasSelection.selectedLayers.filter((l: Layer) => !layerIds.includes(l.id));
|
||||
this.canvasSelection.updateSelection(newSelection);
|
||||
|
||||
this.render();
|
||||
this.saveState();
|
||||
|
||||
if (this.canvasLayersPanel) {
|
||||
this.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
log.info(`Removed ${initialCount - this.layers.length} layers by ID.`);
|
||||
}
|
||||
|
||||
removeSelectedLayers() {
|
||||
return this.canvasSelection.removeSelectedLayers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
|
||||
*/
|
||||
duplicateSelectedLayers() {
|
||||
return this.canvasSelection.duplicateSelectedLayers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
|
||||
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
|
||||
* @param {Array} newSelection - Nowa lista zaznaczonych warstw
|
||||
*/
|
||||
updateSelection(newSelection: any) {
|
||||
return this.canvasSelection.updateSelection(newSelection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
|
||||
*/
|
||||
updateSelectionLogic(layer: Layer, isCtrlPressed: boolean, isShiftPressed: boolean, index: number) {
|
||||
return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zmienia rozmiar obszaru wyjściowego
|
||||
* @param {number} width - Nowa szerokość
|
||||
* @param {number} height - Nowa wysokość
|
||||
* @param {boolean} saveHistory - Czy zapisać w historii
|
||||
*/
|
||||
updateOutputAreaSize(width: number, height: number, saveHistory = true) {
|
||||
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eksportuje spłaszczony canvas jako blob
|
||||
*/
|
||||
async getFlattenedCanvasAsBlob() {
|
||||
return this.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Eksportuje spłaszczony canvas z maską jako kanałem alpha
|
||||
*/
|
||||
async getFlattenedCanvasWithMaskAsBlob() {
|
||||
return this.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Importuje najnowszy obraz
|
||||
*/
|
||||
async importLatestImage() {
|
||||
return this.canvasIO.importLatestImage();
|
||||
}
|
||||
|
||||
_setupAutoRefreshHandlers() {
|
||||
let lastExecutionStartTime = 0;
|
||||
|
||||
// Helper function to get auto-refresh value from node widget
|
||||
const getAutoRefreshValue = (): boolean => {
|
||||
const widget = this.node.widgets.find((w: any) => w.name === 'auto_refresh_after_generation');
|
||||
return widget ? widget.value : false;
|
||||
};
|
||||
|
||||
const handleExecutionStart = () => {
|
||||
if (getAutoRefreshValue()) {
|
||||
lastExecutionStartTime = Date.now();
|
||||
// Store a snapshot of the context for the upcoming batch
|
||||
this.pendingBatchContext = {
|
||||
// For the menu position
|
||||
spawnPosition: {
|
||||
x: this.width / 2,
|
||||
y: this.height
|
||||
},
|
||||
// For the image placement
|
||||
outputArea: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: this.width,
|
||||
height: this.height
|
||||
}
|
||||
};
|
||||
log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext);
|
||||
this.render(); // Trigger render to show the pending outline immediately
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecutionSuccess = async () => {
|
||||
if (getAutoRefreshValue()) {
|
||||
log.info('Auto-refresh triggered, importing latest images.');
|
||||
|
||||
if (!this.pendingBatchContext) {
|
||||
log.warn("execution_start did not fire, cannot process batch. Awaiting next execution.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the captured output area for image import
|
||||
const newLayers = await this.canvasIO.importLatestImages(
|
||||
lastExecutionStartTime,
|
||||
this.pendingBatchContext.outputArea
|
||||
);
|
||||
|
||||
if (newLayers && newLayers.length > 1) {
|
||||
const newManager = new BatchPreviewManager(
|
||||
this,
|
||||
this.pendingBatchContext.spawnPosition,
|
||||
this.pendingBatchContext.outputArea
|
||||
);
|
||||
this.batchPreviewManagers.push(newManager);
|
||||
newManager.show(newLayers);
|
||||
}
|
||||
|
||||
// Consume the context
|
||||
this.pendingBatchContext = null;
|
||||
// Final render to clear the outline if it was the last one
|
||||
this.render();
|
||||
}
|
||||
};
|
||||
|
||||
api.addEventListener('execution_start', handleExecutionStart);
|
||||
api.addEventListener('execution_success', handleExecutionSuccess);
|
||||
|
||||
(this.node as any).onRemoved = useChainCallback((this.node as any).onRemoved, () => {
|
||||
log.info('Node removed, cleaning up auto-refresh listeners.');
|
||||
api.removeEventListener('execution_start', handleExecutionStart);
|
||||
api.removeEventListener('execution_success', handleExecutionSuccess);
|
||||
});
|
||||
|
||||
log.debug('Auto-refresh handlers setup complete, reading from node widget: auto_refresh_after_generation');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Uruchamia edytor masek
|
||||
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora
|
||||
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
|
||||
*/
|
||||
async startMaskEditor(predefinedMask: HTMLImageElement | HTMLCanvasElement | null = null, sendCleanImage: boolean = true) {
|
||||
return this.canvasMask.startMaskEditor(predefinedMask as any, sendCleanImage);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Inicjalizuje podstawowe właściwości canvas
|
||||
*/
|
||||
initCanvas() {
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
this.canvas.style.border = '1px solid black';
|
||||
this.canvas.style.maxWidth = '100%';
|
||||
this.canvas.style.backgroundColor = '#606060';
|
||||
this.canvas.style.width = '100%';
|
||||
this.canvas.style.height = '100%';
|
||||
this.canvas.tabIndex = 0;
|
||||
this.canvas.style.outline = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pobiera współrzędne myszy w układzie świata
|
||||
* @param {MouseEvent} e - Zdarzenie myszy
|
||||
*/
|
||||
getMouseWorldCoordinates(e: any) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
|
||||
const mouseX_DOM = e.clientX - rect.left;
|
||||
const mouseY_DOM = e.clientY - rect.top;
|
||||
|
||||
if (!this.offscreenCanvas) throw new Error("Offscreen canvas not initialized");
|
||||
const scaleX = this.offscreenCanvas.width / rect.width;
|
||||
const scaleY = this.offscreenCanvas.height / rect.height;
|
||||
|
||||
const mouseX_Buffer = mouseX_DOM * scaleX;
|
||||
const mouseY_Buffer = mouseY_DOM * scaleY;
|
||||
|
||||
const worldX = (mouseX_Buffer / this.viewport.zoom) + this.viewport.x;
|
||||
const worldY = (mouseY_Buffer / this.viewport.zoom) + this.viewport.y;
|
||||
|
||||
return {x: worldX, y: worldY};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pobiera współrzędne myszy w układzie widoku
|
||||
* @param {MouseEvent} e - Zdarzenie myszy
|
||||
*/
|
||||
getMouseViewCoordinates(e: any) {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const mouseX_DOM = e.clientX - rect.left;
|
||||
const mouseY_DOM = e.clientY - rect.top;
|
||||
|
||||
const scaleX = this.canvas.width / rect.width;
|
||||
const scaleY = this.canvas.height / rect.height;
|
||||
|
||||
const mouseX_Canvas = mouseX_DOM * scaleX;
|
||||
const mouseY_Canvas = mouseY_DOM * scaleY;
|
||||
|
||||
return {x: mouseX_Canvas, y: mouseY_Canvas};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje zaznaczenie po operacji historii
|
||||
*/
|
||||
updateSelectionAfterHistory() {
|
||||
return this.canvasSelection.updateSelectionAfterHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje przyciski historii
|
||||
*/
|
||||
updateHistoryButtons() {
|
||||
if (this.onHistoryChange) {
|
||||
const historyInfo = this.canvasState.getHistoryInfo();
|
||||
this.onHistoryChange({
|
||||
canUndo: historyInfo.canUndo,
|
||||
canRedo: historyInfo.canRedo
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwiększa licznik operacji (dla garbage collection)
|
||||
*/
|
||||
incrementOperationCount() {
|
||||
if (this.imageReferenceManager) {
|
||||
this.imageReferenceManager.incrementOperationCount();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści zasoby canvas
|
||||
*/
|
||||
destroy() {
|
||||
if (this.imageReferenceManager) {
|
||||
this.imageReferenceManager.destroy();
|
||||
}
|
||||
log.info("Canvas destroyed");
|
||||
}
|
||||
|
||||
/**
|
||||
* Powiadamia o zmianie stanu
|
||||
* @private
|
||||
*/
|
||||
_notifyStateChange() {
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
}
|
||||
}
|
||||
}
|
||||
796
src/CanvasIO.ts
Normal file
796
src/CanvasIO.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
import { createCanvas } from "./utils/CommonUtils.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { webSocketManager } from "./utils/WebSocketManager.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer } from './types';
|
||||
|
||||
const log = createModuleLogger('CanvasIO');
|
||||
|
||||
export class CanvasIO {
|
||||
private _saveInProgress: Promise<any> | null;
|
||||
private canvas: Canvas;
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
this.canvas = canvas;
|
||||
this._saveInProgress = null;
|
||||
}
|
||||
|
||||
async saveToServer(fileName: string, outputMode = 'disk'): Promise<any> {
|
||||
if (outputMode === 'disk') {
|
||||
if (!(window as any).canvasSaveStates) {
|
||||
(window as any).canvasSaveStates = new Map();
|
||||
}
|
||||
|
||||
const nodeId = this.canvas.node.id;
|
||||
const saveKey = `${nodeId}_${fileName}`;
|
||||
if (this._saveInProgress || (window as any).canvasSaveStates.get(saveKey)) {
|
||||
log.warn(`Save already in progress for node ${nodeId}, waiting...`);
|
||||
return this._saveInProgress || (window as any).canvasSaveStates.get(saveKey);
|
||||
}
|
||||
|
||||
log.info(`Starting saveToServer (disk) with fileName: ${fileName} for node: ${nodeId}`);
|
||||
this._saveInProgress = this._performSave(fileName, outputMode);
|
||||
(window as any).canvasSaveStates.set(saveKey, this._saveInProgress);
|
||||
|
||||
try {
|
||||
return await this._saveInProgress;
|
||||
} finally {
|
||||
this._saveInProgress = null;
|
||||
(window as any).canvasSaveStates.delete(saveKey);
|
||||
log.debug(`Save completed for node ${nodeId}, lock released`);
|
||||
}
|
||||
} else {
|
||||
log.info(`Starting saveToServer (RAM) for node: ${this.canvas.node.id}`);
|
||||
return this._performSave(fileName, outputMode);
|
||||
}
|
||||
}
|
||||
|
||||
async _performSave(fileName: string, outputMode: string): Promise<any> {
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
await this.canvas.canvasState.saveStateToDB();
|
||||
const nodeId = this.canvas.node.id;
|
||||
const delay = (nodeId % 10) * 50;
|
||||
if (delay > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const {canvas: tempCanvas, ctx: tempCtx} = createCanvas(this.canvas.width, this.canvas.height);
|
||||
const {canvas: maskCanvas, ctx: maskCtx} = createCanvas(this.canvas.width, this.canvas.height);
|
||||
|
||||
const visibilityCanvas = document.createElement('canvas');
|
||||
visibilityCanvas.width = this.canvas.width;
|
||||
visibilityCanvas.height = this.canvas.height;
|
||||
const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
|
||||
if (!visibilityCtx) throw new Error("Could not create visibility context");
|
||||
if (!maskCtx) throw new Error("Could not create mask context");
|
||||
if (!tempCtx) throw new Error("Could not create temp context");
|
||||
maskCtx.fillStyle = '#ffffff';
|
||||
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
log.debug(`Canvas contexts created, starting layer rendering`);
|
||||
|
||||
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
|
||||
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
|
||||
|
||||
log.debug(`Finished rendering layers`);
|
||||
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
||||
const alpha = visibilityData.data[i + 3];
|
||||
const maskValue = 255 - alpha;
|
||||
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
|
||||
maskData.data[i + 3] = 255;
|
||||
}
|
||||
|
||||
maskCtx.putImageData(maskData, 0, 0);
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||
if (toolMaskCanvas) {
|
||||
|
||||
const tempMaskCanvas = document.createElement('canvas');
|
||||
tempMaskCanvas.width = this.canvas.width;
|
||||
tempMaskCanvas.height = this.canvas.height;
|
||||
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!tempMaskCtx) throw new Error("Could not create temp mask context");
|
||||
|
||||
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||
|
||||
|
||||
const maskX = this.canvas.maskTool.x;
|
||||
const maskY = this.canvas.maskTool.y;
|
||||
|
||||
log.debug(`Extracting mask from world position (${maskX}, ${maskY}) for output area (0,0) to (${this.canvas.width}, ${this.canvas.height})`);
|
||||
|
||||
const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading
|
||||
const sourceY = Math.max(0, -maskY);
|
||||
const destX = Math.max(0, maskX); // Where in the output canvas to start writing
|
||||
const destY = Math.max(0, maskY);
|
||||
|
||||
const copyWidth = Math.min(
|
||||
toolMaskCanvas.width - sourceX, // Available width in source
|
||||
this.canvas.width - destX // Available width in destination
|
||||
);
|
||||
const copyHeight = Math.min(
|
||||
toolMaskCanvas.height - sourceY, // Available height in source
|
||||
this.canvas.height - destY // Available height in destination
|
||||
);
|
||||
|
||||
if (copyWidth > 0 && copyHeight > 0) {
|
||||
log.debug(`Copying mask region: source(${sourceX}, ${sourceY}) to dest(${destX}, ${destY}) size(${copyWidth}, ${copyHeight})`);
|
||||
|
||||
tempMaskCtx.drawImage(
|
||||
toolMaskCanvas,
|
||||
sourceX, sourceY, copyWidth, copyHeight, // Source rectangle
|
||||
destX, destY, copyWidth, copyHeight // Destination rectangle
|
||||
);
|
||||
}
|
||||
|
||||
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < tempMaskData.data.length; i += 4) {
|
||||
const alpha = tempMaskData.data[i + 3];
|
||||
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
|
||||
tempMaskData.data[i + 3] = alpha;
|
||||
}
|
||||
tempMaskCtx.putImageData(tempMaskData, 0, 0);
|
||||
|
||||
maskCtx.globalCompositeOperation = 'source-over';
|
||||
maskCtx.drawImage(tempMaskCanvas, 0, 0);
|
||||
}
|
||||
if (outputMode === 'ram') {
|
||||
const imageData = tempCanvas.toDataURL('image/png');
|
||||
const maskData = maskCanvas.toDataURL('image/png');
|
||||
log.info("Returning image and mask data as base64 for RAM mode.");
|
||||
resolve({image: imageData, mask: maskData});
|
||||
return;
|
||||
}
|
||||
|
||||
const fileNameWithoutMask = fileName.replace('.png', '_without_mask.png');
|
||||
log.info(`Saving image without mask as: ${fileNameWithoutMask}`);
|
||||
|
||||
tempCanvas.toBlob(async (blobWithoutMask) => {
|
||||
if (!blobWithoutMask) return;
|
||||
log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`);
|
||||
const formDataWithoutMask = new FormData();
|
||||
formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask);
|
||||
formDataWithoutMask.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const response = await fetch("/upload/image", {
|
||||
method: "POST",
|
||||
body: formDataWithoutMask,
|
||||
});
|
||||
log.debug(`Image without mask upload response: ${response.status}`);
|
||||
} catch (error) {
|
||||
log.error(`Error uploading image without mask:`, error);
|
||||
}
|
||||
}, "image/png");
|
||||
log.info(`Saving main image as: ${fileName}`);
|
||||
tempCanvas.toBlob(async (blob) => {
|
||||
if (!blob) return;
|
||||
log.debug(`Created blob for main image, size: ${blob.size} bytes`);
|
||||
const formData = new FormData();
|
||||
formData.append("image", blob, fileName);
|
||||
formData.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const resp = await fetch("/upload/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
log.debug(`Main image upload response: ${resp.status}`);
|
||||
|
||||
if (resp.status === 200) {
|
||||
const maskFileName = fileName.replace('.png', '_mask.png');
|
||||
log.info(`Saving mask as: ${maskFileName}`);
|
||||
|
||||
maskCanvas.toBlob(async (maskBlob) => {
|
||||
if (!maskBlob) return;
|
||||
log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`);
|
||||
const maskFormData = new FormData();
|
||||
maskFormData.append("image", maskBlob, maskFileName);
|
||||
maskFormData.append("overwrite", "true");
|
||||
|
||||
try {
|
||||
const maskResp = await fetch("/upload/image", {
|
||||
method: "POST",
|
||||
body: maskFormData,
|
||||
});
|
||||
log.debug(`Mask upload response: ${maskResp.status}`);
|
||||
|
||||
if (maskResp.status === 200) {
|
||||
const data = await resp.json();
|
||||
if (this.canvas.widget) {
|
||||
this.canvas.widget.value = fileName;
|
||||
}
|
||||
log.info(`All files saved successfully, widget value set to: ${fileName}`);
|
||||
resolve(true);
|
||||
} else {
|
||||
log.error(`Error saving mask: ${maskResp.status}`);
|
||||
resolve(false);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Error saving mask:`, error);
|
||||
resolve(false);
|
||||
}
|
||||
}, "image/png");
|
||||
} else {
|
||||
log.error(`Main image upload failed: ${resp.status} - ${resp.statusText}`);
|
||||
resolve(false);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Error uploading main image:`, error);
|
||||
resolve(false);
|
||||
}
|
||||
}, "image/png");
|
||||
});
|
||||
}
|
||||
|
||||
async _renderOutputData(): Promise<{ image: string, mask: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height);
|
||||
const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height);
|
||||
|
||||
const visibilityCanvas = document.createElement('canvas');
|
||||
visibilityCanvas.width = this.canvas.width;
|
||||
visibilityCanvas.height = this.canvas.height;
|
||||
const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
|
||||
if (!visibilityCtx) throw new Error("Could not create visibility context");
|
||||
if (!maskCtx) throw new Error("Could not create mask context");
|
||||
if (!tempCtx) throw new Error("Could not create temp context");
|
||||
maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
|
||||
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
|
||||
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
|
||||
|
||||
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < visibilityData.data.length; i += 4) {
|
||||
const alpha = visibilityData.data[i + 3];
|
||||
const maskValue = 255 - alpha; // Invert alpha to create the mask
|
||||
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
|
||||
maskData.data[i + 3] = 255; // Solid mask
|
||||
}
|
||||
maskCtx.putImageData(maskData, 0, 0);
|
||||
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||
if (toolMaskCanvas) {
|
||||
|
||||
const tempMaskCanvas = document.createElement('canvas');
|
||||
tempMaskCanvas.width = this.canvas.width;
|
||||
tempMaskCanvas.height = this.canvas.height;
|
||||
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!tempMaskCtx) throw new Error("Could not create temp mask context");
|
||||
|
||||
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||
|
||||
const maskX = this.canvas.maskTool.x;
|
||||
const maskY = this.canvas.maskTool.y;
|
||||
|
||||
log.debug(`[renderOutputData] Extracting mask from world position (${maskX}, ${maskY})`);
|
||||
|
||||
const sourceX = Math.max(0, -maskX);
|
||||
const sourceY = Math.max(0, -maskY);
|
||||
const destX = Math.max(0, maskX);
|
||||
const destY = Math.max(0, maskY);
|
||||
|
||||
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
|
||||
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
|
||||
|
||||
if (copyWidth > 0 && copyHeight > 0) {
|
||||
tempMaskCtx.drawImage(
|
||||
toolMaskCanvas,
|
||||
sourceX, sourceY, copyWidth, copyHeight,
|
||||
destX, destY, copyWidth, copyHeight
|
||||
);
|
||||
}
|
||||
|
||||
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < tempMaskData.data.length; i += 4) {
|
||||
const alpha = tempMaskData.data[i + 3];
|
||||
|
||||
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha;
|
||||
tempMaskData.data[i + 3] = 255; // Solid alpha
|
||||
}
|
||||
tempMaskCtx.putImageData(tempMaskData, 0, 0);
|
||||
|
||||
|
||||
maskCtx.globalCompositeOperation = 'screen';
|
||||
maskCtx.drawImage(tempMaskCanvas, 0, 0);
|
||||
}
|
||||
|
||||
const imageDataUrl = tempCanvas.toDataURL('image/png');
|
||||
const maskDataUrl = maskCanvas.toDataURL('image/png');
|
||||
|
||||
resolve({image: imageDataUrl, mask: maskDataUrl});
|
||||
});
|
||||
}
|
||||
|
||||
async sendDataViaWebSocket(nodeId: number): Promise<boolean> {
|
||||
log.info(`Preparing to send data for node ${nodeId} via WebSocket.`);
|
||||
|
||||
const { image, mask } = await this._renderOutputData();
|
||||
|
||||
try {
|
||||
log.info(`Sending data for node ${nodeId}...`);
|
||||
await webSocketManager.sendMessage({
|
||||
type: 'canvas_data',
|
||||
nodeId: String(nodeId),
|
||||
image: image,
|
||||
mask: mask,
|
||||
}, true); // `true` requires an acknowledgment
|
||||
|
||||
log.info(`Data for node ${nodeId} has been sent and acknowledged by the server.`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error(`Failed to send data for node ${nodeId}:`, error);
|
||||
|
||||
|
||||
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`);
|
||||
}
|
||||
}
|
||||
|
||||
async addInputToCanvas(inputImage: any, inputMask: any): Promise<boolean> {
|
||||
try {
|
||||
log.debug("Adding input to canvas:", { inputImage });
|
||||
|
||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height);
|
||||
if (!tempCtx) throw new Error("Could not create temp context");
|
||||
|
||||
const imgData = new ImageData(
|
||||
new Uint8ClampedArray(inputImage.data),
|
||||
inputImage.width,
|
||||
inputImage.height
|
||||
);
|
||||
tempCtx.putImageData(imgData, 0, 0);
|
||||
|
||||
const image = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve;
|
||||
image.onerror = reject;
|
||||
image.src = tempCanvas.toDataURL();
|
||||
});
|
||||
|
||||
const scale = Math.min(
|
||||
this.canvas.width / inputImage.width * 0.8,
|
||||
this.canvas.height / inputImage.height * 0.8
|
||||
);
|
||||
|
||||
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
|
||||
x: (this.canvas.width - inputImage.width * scale) / 2,
|
||||
y: (this.canvas.height - inputImage.height * scale) / 2,
|
||||
width: inputImage.width * scale,
|
||||
height: inputImage.height * scale,
|
||||
});
|
||||
|
||||
if (inputMask && layer) {
|
||||
(layer as any).mask = inputMask.data;
|
||||
}
|
||||
|
||||
log.info("Layer added successfully");
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
log.error("Error in addInputToCanvas:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async convertTensorToImage(tensor: any): Promise<HTMLImageElement> {
|
||||
try {
|
||||
log.debug("Converting tensor to image:", tensor);
|
||||
|
||||
if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
|
||||
throw new Error("Invalid tensor data");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) throw new Error("Could not create canvas context");
|
||||
canvas.width = tensor.width;
|
||||
canvas.height = tensor.height;
|
||||
|
||||
const imageData = new ImageData(
|
||||
new Uint8ClampedArray(tensor.data),
|
||||
tensor.width,
|
||||
tensor.height
|
||||
);
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Error converting tensor to image:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async convertTensorToMask(tensor: any): Promise<Float32Array> {
|
||||
if (!tensor || !tensor.data) {
|
||||
throw new Error("Invalid mask tensor");
|
||||
}
|
||||
|
||||
try {
|
||||
return new Float32Array(tensor.data);
|
||||
} catch (error: any) {
|
||||
throw new Error(`Mask conversion failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async initNodeData(): Promise<void> {
|
||||
try {
|
||||
log.info("Starting node data initialization...");
|
||||
|
||||
if (!this.canvas.node || !(this.canvas.node as any).inputs) {
|
||||
log.debug("Node or inputs not ready");
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
|
||||
if ((this.canvas.node as any).inputs[0] && (this.canvas.node as any).inputs[0].link) {
|
||||
const imageLinkId = (this.canvas.node as any).inputs[0].link;
|
||||
const imageData = (window as any).app.nodeOutputs[imageLinkId];
|
||||
|
||||
if (imageData) {
|
||||
log.debug("Found image data:", imageData);
|
||||
await this.processImageData(imageData);
|
||||
this.canvas.dataInitialized = true;
|
||||
} else {
|
||||
log.debug("Image data not available yet");
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
}
|
||||
|
||||
if ((this.canvas.node as any).inputs[1] && (this.canvas.node as any).inputs[1].link) {
|
||||
const maskLinkId = (this.canvas.node as any).inputs[1].link;
|
||||
const maskData = (window as any).app.nodeOutputs[maskLinkId];
|
||||
|
||||
if (maskData) {
|
||||
log.debug("Found mask data:", maskData);
|
||||
await this.processMaskData(maskData);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log.error("Error in initNodeData:", error);
|
||||
return this.scheduleDataCheck();
|
||||
}
|
||||
}
|
||||
|
||||
scheduleDataCheck(): void {
|
||||
if (this.canvas.pendingDataCheck) {
|
||||
clearTimeout(this.canvas.pendingDataCheck);
|
||||
}
|
||||
|
||||
this.canvas.pendingDataCheck = window.setTimeout(() => {
|
||||
this.canvas.pendingDataCheck = null;
|
||||
if (!this.canvas.dataInitialized) {
|
||||
this.initNodeData();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async processImageData(imageData: any): Promise<void> {
|
||||
try {
|
||||
if (!imageData) return;
|
||||
|
||||
log.debug("Processing image data:", {
|
||||
type: typeof imageData,
|
||||
isArray: Array.isArray(imageData),
|
||||
shape: imageData.shape,
|
||||
hasData: !!imageData.data
|
||||
});
|
||||
|
||||
if (Array.isArray(imageData)) {
|
||||
imageData = imageData[0];
|
||||
}
|
||||
|
||||
if (!imageData.shape || !imageData.data) {
|
||||
throw new Error("Invalid image data format");
|
||||
}
|
||||
|
||||
const originalWidth = imageData.shape[2];
|
||||
const originalHeight = imageData.shape[1];
|
||||
|
||||
const scale = Math.min(
|
||||
this.canvas.width / originalWidth * 0.8,
|
||||
this.canvas.height / originalHeight * 0.8
|
||||
);
|
||||
|
||||
const convertedData = this.convertTensorToImageData(imageData);
|
||||
if (convertedData) {
|
||||
const image = await this.createImageFromData(convertedData);
|
||||
|
||||
this.addScaledLayer(image, scale);
|
||||
log.info("Image layer added successfully with scale:", scale);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Error processing image data:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
addScaledLayer(image: HTMLImageElement, scale: number): void {
|
||||
try {
|
||||
const scaledWidth = image.width * scale;
|
||||
const scaledHeight = image.height * scale;
|
||||
|
||||
const layer: Layer = {
|
||||
id: '', // This will be set in addLayerWithImage
|
||||
imageId: '', // This will be set in addLayerWithImage
|
||||
name: 'Layer',
|
||||
image: image,
|
||||
x: (this.canvas.width - scaledWidth) / 2,
|
||||
y: (this.canvas.height - scaledHeight) / 2,
|
||||
width: scaledWidth,
|
||||
height: scaledHeight,
|
||||
rotation: 0,
|
||||
zIndex: this.canvas.layers.length,
|
||||
originalWidth: image.width,
|
||||
originalHeight: image.height,
|
||||
blendMode: 'normal',
|
||||
opacity: 1
|
||||
};
|
||||
|
||||
this.canvas.layers.push(layer);
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.canvas.render();
|
||||
|
||||
log.debug("Scaled layer added:", {
|
||||
originalSize: `${image.width}x${image.height}`,
|
||||
scaledSize: `${scaledWidth}x${scaledHeight}`,
|
||||
scale: scale
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Error adding scaled layer:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
convertTensorToImageData(tensor: any): ImageData | null {
|
||||
try {
|
||||
const shape = tensor.shape;
|
||||
const height = shape[1];
|
||||
const width = shape[2];
|
||||
const channels = shape[3];
|
||||
|
||||
log.debug("Converting tensor:", {
|
||||
shape: shape,
|
||||
dataRange: {
|
||||
min: tensor.min_val,
|
||||
max: tensor.max_val
|
||||
}
|
||||
});
|
||||
|
||||
const imageData = new ImageData(width, height);
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
const flatData = tensor.data;
|
||||
const pixelCount = width * height;
|
||||
|
||||
for (let i = 0; i < pixelCount; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
const tensorIndex = i * channels;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const value = flatData[tensorIndex + c];
|
||||
|
||||
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
|
||||
data[pixelIndex + c] = Math.round(normalizedValue * 255);
|
||||
}
|
||||
|
||||
data[pixelIndex + 3] = 255;
|
||||
}
|
||||
|
||||
imageData.data.set(data);
|
||||
return imageData;
|
||||
} catch (error) {
|
||||
log.error("Error converting tensor:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createImageFromData(imageData: ImageData): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) throw new Error("Could not create canvas context");
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
|
||||
async retryDataLoad(maxRetries = 3, delay = 1000): Promise<void> {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
await this.initNodeData();
|
||||
return;
|
||||
} catch (error) {
|
||||
log.warn(`Retry ${i + 1}/${maxRetries} failed:`, error);
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
log.error("Failed to load data after", maxRetries, "retries");
|
||||
}
|
||||
|
||||
async processMaskData(maskData: any): Promise<void> {
|
||||
try {
|
||||
if (!maskData) return;
|
||||
|
||||
log.debug("Processing mask data:", maskData);
|
||||
|
||||
if (Array.isArray(maskData)) {
|
||||
maskData = maskData[0];
|
||||
}
|
||||
|
||||
if (!maskData.shape || !maskData.data) {
|
||||
throw new Error("Invalid mask data format");
|
||||
}
|
||||
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
const maskTensor = await this.convertTensorToMask(maskData);
|
||||
(this.canvas.canvasSelection.selectedLayers[0] as any).mask = maskTensor;
|
||||
this.canvas.render();
|
||||
log.info("Mask applied to selected layer");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Error processing mask data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadImageFromCache(base64Data: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = base64Data;
|
||||
});
|
||||
}
|
||||
|
||||
async importImage(cacheData: { image: string, mask?: string }): Promise<void> {
|
||||
try {
|
||||
log.info("Starting image import with cache data");
|
||||
const img = await this.loadImageFromCache(cacheData.image);
|
||||
const mask = cacheData.mask ? await this.loadImageFromCache(cacheData.mask) : null;
|
||||
|
||||
const scale = Math.min(
|
||||
this.canvas.width / img.width * 0.8,
|
||||
this.canvas.height / img.height * 0.8
|
||||
);
|
||||
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = img.width;
|
||||
tempCanvas.height = img.height;
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!tempCtx) throw new Error("Could not create temp context");
|
||||
|
||||
tempCtx.drawImage(img, 0, 0);
|
||||
|
||||
if (mask) {
|
||||
const imageData = tempCtx.getImageData(0, 0, img.width, img.height);
|
||||
const maskCanvas = document.createElement('canvas');
|
||||
maskCanvas.width = img.width;
|
||||
maskCanvas.height = img.height;
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!maskCtx) throw new Error("Could not create mask context");
|
||||
maskCtx.drawImage(mask, 0, 0);
|
||||
const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
imageData.data[i + 3] = maskData.data[i];
|
||||
}
|
||||
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
const finalImage = new Image();
|
||||
await new Promise((resolve) => {
|
||||
finalImage.onload = resolve;
|
||||
finalImage.src = tempCanvas.toDataURL();
|
||||
});
|
||||
|
||||
const layer: Layer = {
|
||||
id: '', // This will be set in addLayerWithImage
|
||||
imageId: '', // This will be set in addLayerWithImage
|
||||
name: 'Layer',
|
||||
image: finalImage,
|
||||
x: (this.canvas.width - img.width * scale) / 2,
|
||||
y: (this.canvas.height - img.height * scale) / 2,
|
||||
width: img.width * scale,
|
||||
height: img.height * scale,
|
||||
originalWidth: img.width,
|
||||
originalHeight: img.height,
|
||||
rotation: 0,
|
||||
zIndex: this.canvas.layers.length,
|
||||
blendMode: 'normal',
|
||||
opacity: 1,
|
||||
};
|
||||
|
||||
this.canvas.layers.push(layer);
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
} catch (error) {
|
||||
log.error('Error importing image:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async importLatestImage(): Promise<boolean> {
|
||||
try {
|
||||
log.info("Fetching latest image from server...");
|
||||
const response = await fetch('/ycnode/get_latest_image');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.image_data) {
|
||||
log.info("Latest image received, adding to canvas.");
|
||||
const img = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = result.image_data;
|
||||
});
|
||||
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit');
|
||||
log.info("Latest image imported and placed on canvas successfully.");
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.error || "Failed to fetch the latest image.");
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error("Error importing latest image:", error);
|
||||
alert(`Failed to import latest image: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async importLatestImages(sinceTimestamp: number, targetArea: { x: number, y: number, width: number, height: number } | null = null): Promise<Layer[]> {
|
||||
try {
|
||||
log.info(`Fetching latest images since ${sinceTimestamp}...`);
|
||||
const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.images && result.images.length > 0) {
|
||||
log.info(`Received ${result.images.length} new images, adding to canvas.`);
|
||||
const newLayers: (Layer | null)[] = [];
|
||||
|
||||
for (const imageData of result.images) {
|
||||
const img = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
img.src = imageData;
|
||||
});
|
||||
const newLayer = await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit', targetArea);
|
||||
newLayers.push(newLayer);
|
||||
}
|
||||
log.info("All new images imported and placed on canvas successfully.");
|
||||
return newLayers.filter(l => l !== null) as Layer[];
|
||||
|
||||
} else if (result.success) {
|
||||
log.info("No new images found since last generation.");
|
||||
return [];
|
||||
} else {
|
||||
throw new Error(result.error || "Failed to fetch latest images.");
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error("Error importing latest images:", error);
|
||||
alert(`Failed to import latest images: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
923
src/CanvasInteractions.ts
Normal file
923
src/CanvasInteractions.ts
Normal file
@@ -0,0 +1,923 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { snapToGrid, getSnapAdjustment } from "./utils/CommonUtils.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer, Point } from './types';
|
||||
|
||||
const log = createModuleLogger('CanvasInteractions');
|
||||
|
||||
interface InteractionState {
|
||||
mode: 'none' | 'panning' | 'dragging' | 'resizing' | 'rotating' | 'drawingMask' | 'resizingCanvas' | 'movingCanvas' | 'potential-drag';
|
||||
panStart: Point;
|
||||
dragStart: Point;
|
||||
transformOrigin: Partial<Layer> & { centerX?: number, centerY?: number };
|
||||
resizeHandle: string | null;
|
||||
resizeAnchor: Point;
|
||||
canvasResizeStart: Point;
|
||||
isCtrlPressed: boolean;
|
||||
isAltPressed: boolean;
|
||||
hasClonedInDrag: boolean;
|
||||
lastClickTime: number;
|
||||
transformingLayer: Layer | null;
|
||||
keyMovementInProgress: boolean;
|
||||
canvasResizeRect: { x: number, y: number, width: number, height: number } | null;
|
||||
canvasMoveRect: { x: number, y: number, width: number, height: number } | null;
|
||||
}
|
||||
|
||||
export class CanvasInteractions {
|
||||
private canvas: Canvas;
|
||||
public interaction: InteractionState;
|
||||
private originalLayerPositions: Map<Layer, Point>;
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
this.canvas = canvas;
|
||||
this.interaction = {
|
||||
mode: 'none',
|
||||
panStart: { x: 0, y: 0 },
|
||||
dragStart: { x: 0, y: 0 },
|
||||
transformOrigin: {},
|
||||
resizeHandle: null,
|
||||
resizeAnchor: { x: 0, y: 0 },
|
||||
canvasResizeStart: { x: 0, y: 0 },
|
||||
isCtrlPressed: false,
|
||||
isAltPressed: false,
|
||||
hasClonedInDrag: false,
|
||||
lastClickTime: 0,
|
||||
transformingLayer: null,
|
||||
keyMovementInProgress: false,
|
||||
canvasResizeRect: null,
|
||||
canvasMoveRect: null,
|
||||
};
|
||||
this.originalLayerPositions = new Map();
|
||||
}
|
||||
|
||||
setupEventListeners(): void {
|
||||
this.canvas.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('mouseleave', this.handleMouseLeave.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('wheel', this.handleWheel.bind(this) as EventListener, { passive: false });
|
||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this) as EventListener);
|
||||
|
||||
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
||||
|
||||
this.canvas.canvas.addEventListener('mouseenter', (e: MouseEvent) => {
|
||||
this.canvas.isMouseOver = true;
|
||||
this.handleMouseEnter(e);
|
||||
});
|
||||
this.canvas.canvas.addEventListener('mouseleave', (e: MouseEvent) => {
|
||||
this.canvas.isMouseOver = false;
|
||||
this.handleMouseLeave(e);
|
||||
});
|
||||
|
||||
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this) as EventListener);
|
||||
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this) as unknown as EventListener);
|
||||
|
||||
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this) as EventListener);
|
||||
}
|
||||
|
||||
resetInteractionState(): void {
|
||||
this.interaction.mode = 'none';
|
||||
this.interaction.resizeHandle = null;
|
||||
this.originalLayerPositions.clear();
|
||||
this.interaction.canvasResizeRect = null;
|
||||
this.interaction.canvasMoveRect = null;
|
||||
this.interaction.hasClonedInDrag = false;
|
||||
this.interaction.transformingLayer = null;
|
||||
this.canvas.canvas.style.cursor = 'default';
|
||||
}
|
||||
|
||||
handleMouseDown(e: MouseEvent): void {
|
||||
this.canvas.canvas.focus();
|
||||
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Ostateczna, poprawna kolejność sprawdzania ---
|
||||
|
||||
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
|
||||
if (e.shiftKey && e.ctrlKey) {
|
||||
this.startCanvasMove(worldCoords);
|
||||
return;
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
this.startCanvasResize(worldCoords);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Inne przyciski myszy
|
||||
if (e.button === 2) { // Prawy przycisk myszy
|
||||
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
||||
if (clickedLayerResult && this.canvas.canvasSelection.selectedLayers.includes(clickedLayerResult.layer)) {
|
||||
e.preventDefault();
|
||||
this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x, viewCoords.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) { // Środkowy przycisk
|
||||
this.startPanning(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Interakcje z elementami na płótnie (lewy przycisk)
|
||||
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||
if (transformTarget) {
|
||||
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
|
||||
return;
|
||||
}
|
||||
|
||||
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
||||
if (clickedLayerResult) {
|
||||
this.prepareForDrag(clickedLayerResult.layer, worldCoords);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Domyślna akcja na tle (lewy przycisk bez modyfikatorów)
|
||||
this.startPanningOrClearSelection(e);
|
||||
}
|
||||
|
||||
handleMouseMove(e: MouseEvent): void {
|
||||
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||
this.canvas.lastMousePosition = worldCoords; // Zawsze aktualizuj ostatnią pozycję myszy
|
||||
|
||||
// Sprawdź, czy rozpocząć przeciąganie
|
||||
if (this.interaction.mode === 'potential-drag') {
|
||||
const dx = worldCoords.x - this.interaction.dragStart.x;
|
||||
const dy = worldCoords.y - this.interaction.dragStart.y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) > 3) { // Próg 3 pikseli
|
||||
this.interaction.mode = 'dragging';
|
||||
this.originalLayerPositions.clear();
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => {
|
||||
this.originalLayerPositions.set(l, { x: l.x, y: l.y });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
switch (this.interaction.mode) {
|
||||
case 'drawingMask':
|
||||
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
|
||||
this.canvas.render();
|
||||
break;
|
||||
case 'panning':
|
||||
this.panViewport(e);
|
||||
break;
|
||||
case 'dragging':
|
||||
this.dragLayers(worldCoords);
|
||||
break;
|
||||
case 'resizing':
|
||||
this.resizeLayerFromHandle(worldCoords, e.shiftKey);
|
||||
break;
|
||||
case 'rotating':
|
||||
this.rotateLayerFromHandle(worldCoords, e.shiftKey);
|
||||
break;
|
||||
case 'resizingCanvas':
|
||||
this.updateCanvasResize(worldCoords);
|
||||
break;
|
||||
case 'movingCanvas':
|
||||
this.updateCanvasMove(worldCoords);
|
||||
break;
|
||||
default:
|
||||
this.updateCursor(worldCoords);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseUp(e: MouseEvent): void {
|
||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||
if (this.interaction.mode === 'drawingMask') {
|
||||
this.canvas.maskTool.handleMouseUp(viewCoords);
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.interaction.mode === 'resizingCanvas') {
|
||||
this.finalizeCanvasResize();
|
||||
}
|
||||
if (this.interaction.mode === 'movingCanvas') {
|
||||
this.finalizeCanvasMove();
|
||||
}
|
||||
|
||||
// Zapisz stan tylko, jeśli faktycznie doszło do zmiany (przeciąganie, transformacja, duplikacja)
|
||||
const stateChangingInteraction = ['dragging', 'resizing', 'rotating'].includes(this.interaction.mode);
|
||||
const duplicatedInDrag = this.interaction.hasClonedInDrag;
|
||||
|
||||
if (stateChangingInteraction || duplicatedInDrag) {
|
||||
this.canvas.saveState();
|
||||
this.canvas.canvasState.saveStateToDB();
|
||||
}
|
||||
|
||||
this.resetInteractionState();
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
handleMouseLeave(e: MouseEvent): void {
|
||||
const viewCoords = this.canvas.getMouseViewCoordinates(e);
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
this.canvas.maskTool.handleMouseLeave();
|
||||
if (this.canvas.maskTool.isDrawing) {
|
||||
this.canvas.maskTool.handleMouseUp(viewCoords);
|
||||
}
|
||||
this.canvas.render();
|
||||
return;
|
||||
}
|
||||
if (this.interaction.mode !== 'none') {
|
||||
this.resetInteractionState();
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
this.canvas.canvasLayers.internalClipboard = [];
|
||||
log.info("Internal clipboard cleared - mouse left canvas");
|
||||
}
|
||||
}
|
||||
|
||||
handleMouseEnter(e: MouseEvent): void {
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
this.canvas.maskTool.handleMouseEnter();
|
||||
}
|
||||
}
|
||||
|
||||
handleContextMenu(e: MouseEvent): void {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
handleWheel(e: WheelEvent): void {
|
||||
e.preventDefault();
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||
const rect = this.canvas.canvas.getBoundingClientRect();
|
||||
const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width);
|
||||
const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height);
|
||||
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
const newZoom = this.canvas.viewport.zoom * zoomFactor;
|
||||
|
||||
this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom));
|
||||
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
||||
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
|
||||
} else if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
const rotationStep = 5 * (e.deltaY > 0 ? -1 : 1);
|
||||
const direction = e.deltaY < 0 ? 1 : -1; // 1 = up/right, -1 = down/left
|
||||
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||
if (e.shiftKey) {
|
||||
// Nowy skrót: Shift + Ctrl + Kółko do przyciągania do absolutnych wartości
|
||||
if (e.ctrlKey) {
|
||||
const snapAngle = 5;
|
||||
if (direction > 0) { // Obrót w górę/prawo
|
||||
layer.rotation = Math.ceil((layer.rotation + 0.1) / snapAngle) * snapAngle;
|
||||
} else { // Obrót w dół/lewo
|
||||
layer.rotation = Math.floor((layer.rotation - 0.1) / snapAngle) * snapAngle;
|
||||
}
|
||||
} else {
|
||||
// Stara funkcjonalność: Shift + Kółko obraca o stały krok
|
||||
layer.rotation += rotationStep;
|
||||
}
|
||||
} else {
|
||||
const oldWidth = layer.width;
|
||||
const oldHeight = layer.height;
|
||||
let scaleFactor;
|
||||
|
||||
if (e.ctrlKey) {
|
||||
const direction = e.deltaY > 0 ? -1 : 1;
|
||||
const baseDimension = Math.max(layer.width, layer.height);
|
||||
const newBaseDimension = baseDimension + direction;
|
||||
if (newBaseDimension < 10) {
|
||||
return;
|
||||
}
|
||||
scaleFactor = newBaseDimension / baseDimension;
|
||||
} else {
|
||||
const gridSize = 64;
|
||||
const direction = e.deltaY > 0 ? -1 : 1;
|
||||
let targetHeight;
|
||||
|
||||
if (direction > 0) {
|
||||
targetHeight = (Math.floor(oldHeight / gridSize) + 1) * gridSize;
|
||||
} else {
|
||||
targetHeight = (Math.ceil(oldHeight / gridSize) - 1) * gridSize;
|
||||
}
|
||||
if (targetHeight < gridSize / 2) {
|
||||
targetHeight = gridSize / 2;
|
||||
}
|
||||
if (Math.abs(oldHeight - targetHeight) < 1) {
|
||||
if (direction > 0) targetHeight += gridSize;
|
||||
else targetHeight -= gridSize;
|
||||
|
||||
if (targetHeight < gridSize / 2) return;
|
||||
}
|
||||
|
||||
scaleFactor = targetHeight / oldHeight;
|
||||
}
|
||||
if (scaleFactor && isFinite(scaleFactor)) {
|
||||
layer.width *= scaleFactor;
|
||||
layer.height *= scaleFactor;
|
||||
layer.x += (oldWidth - layer.width) / 2;
|
||||
layer.y += (oldHeight - layer.height) / 2;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||
const rect = this.canvas.canvas.getBoundingClientRect();
|
||||
const mouseBufferX = (e.clientX - rect.left) * (this.canvas.offscreenCanvas.width / rect.width);
|
||||
const mouseBufferY = (e.clientY - rect.top) * (this.canvas.offscreenCanvas.height / rect.height);
|
||||
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
const newZoom = this.canvas.viewport.zoom * zoomFactor;
|
||||
|
||||
this.canvas.viewport.zoom = Math.max(0.1, Math.min(10, newZoom));
|
||||
this.canvas.viewport.x = worldCoords.x - (mouseBufferX / this.canvas.viewport.zoom);
|
||||
this.canvas.viewport.y = worldCoords.y - (mouseBufferY / this.canvas.viewport.zoom);
|
||||
}
|
||||
this.canvas.render();
|
||||
if (!this.canvas.maskTool.isActive) {
|
||||
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Control') this.interaction.isCtrlPressed = true;
|
||||
if (e.key === 'Alt') {
|
||||
this.interaction.isAltPressed = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Globalne skróty (Undo/Redo/Copy/Paste)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
let handled = true;
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'z':
|
||||
if (e.shiftKey) {
|
||||
this.canvas.redo();
|
||||
} else {
|
||||
this.canvas.undo();
|
||||
}
|
||||
break;
|
||||
case 'y':
|
||||
this.canvas.redo();
|
||||
break;
|
||||
case 'c':
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
this.canvas.canvasLayers.copySelectedLayers();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
break;
|
||||
}
|
||||
if (handled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Skróty kontekstowe (zależne od zaznaczenia)
|
||||
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
let needsRender = false;
|
||||
|
||||
// Używamy e.code dla spójności i niezależności od układu klawiatury
|
||||
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
||||
if (movementKeys.includes(e.code)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.interaction.keyMovementInProgress = true;
|
||||
|
||||
if (e.code === 'ArrowLeft') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.x -= step);
|
||||
if (e.code === 'ArrowRight') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.x += step);
|
||||
if (e.code === 'ArrowUp') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.y -= step);
|
||||
if (e.code === 'ArrowDown') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.y += step);
|
||||
if (e.code === 'BracketLeft') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.rotation -= step);
|
||||
if (e.code === 'BracketRight') this.canvas.canvasSelection.selectedLayers.forEach((l: Layer) => l.rotation += step);
|
||||
|
||||
needsRender = true;
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.canvas.canvasSelection.removeSelectedLayers();
|
||||
return;
|
||||
}
|
||||
|
||||
if (needsRender) {
|
||||
this.canvas.render();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyUp(e: KeyboardEvent): void {
|
||||
if (e.key === 'Control') this.interaction.isCtrlPressed = false;
|
||||
if (e.key === 'Alt') this.interaction.isAltPressed = false;
|
||||
|
||||
const movementKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'BracketLeft', 'BracketRight'];
|
||||
if (movementKeys.includes(e.code) && this.interaction.keyMovementInProgress) {
|
||||
this.canvas.requestSaveState(); // Użyj opóźnionego zapisu
|
||||
this.interaction.keyMovementInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateCursor(worldCoords: Point): void {
|
||||
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||
|
||||
if (transformTarget) {
|
||||
const handleName = transformTarget.handle;
|
||||
const cursorMap: { [key: string]: string } = {
|
||||
'n': 'ns-resize', 's': 'ns-resize', 'e': 'ew-resize', 'w': 'ew-resize',
|
||||
'nw': 'nwse-resize', 'se': 'nwse-resize', 'ne': 'nesw-resize', 'sw': 'nesw-resize',
|
||||
'rot': 'grab'
|
||||
};
|
||||
this.canvas.canvas.style.cursor = cursorMap[handleName];
|
||||
} else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) {
|
||||
this.canvas.canvas.style.cursor = 'move';
|
||||
} else {
|
||||
this.canvas.canvas.style.cursor = 'default';
|
||||
}
|
||||
}
|
||||
|
||||
startLayerTransform(layer: Layer, handle: string, worldCoords: Point): void {
|
||||
this.interaction.transformingLayer = layer;
|
||||
this.interaction.transformOrigin = {
|
||||
x: layer.x, y: layer.y,
|
||||
width: layer.width, height: layer.height,
|
||||
rotation: layer.rotation,
|
||||
centerX: layer.x + layer.width / 2,
|
||||
centerY: layer.y + layer.height / 2
|
||||
};
|
||||
this.interaction.dragStart = {...worldCoords};
|
||||
|
||||
if (handle === 'rot') {
|
||||
this.interaction.mode = 'rotating';
|
||||
} else {
|
||||
this.interaction.mode = 'resizing';
|
||||
this.interaction.resizeHandle = handle;
|
||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||
const oppositeHandleKey: { [key: string]: string } = {
|
||||
'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
|
||||
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
|
||||
};
|
||||
this.interaction.resizeAnchor = handles[oppositeHandleKey[handle]];
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
prepareForDrag(layer: Layer, worldCoords: Point): void {
|
||||
// Zaktualizuj zaznaczenie, ale nie zapisuj stanu
|
||||
if (this.interaction.isCtrlPressed) {
|
||||
const index = this.canvas.canvasSelection.selectedLayers.indexOf(layer);
|
||||
if (index === -1) {
|
||||
this.canvas.canvasSelection.updateSelection([...this.canvas.canvasSelection.selectedLayers, layer]);
|
||||
} else {
|
||||
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
|
||||
this.canvas.canvasSelection.updateSelection(newSelection);
|
||||
}
|
||||
} else {
|
||||
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
this.canvas.canvasSelection.updateSelection([layer]);
|
||||
}
|
||||
}
|
||||
|
||||
this.interaction.mode = 'potential-drag';
|
||||
this.interaction.dragStart = {...worldCoords};
|
||||
}
|
||||
|
||||
startPanningOrClearSelection(e: MouseEvent): void {
|
||||
// Ta funkcja jest teraz wywoływana tylko gdy kliknięto na tło bez modyfikatorów.
|
||||
// Domyślna akcja: wyczyść zaznaczenie i rozpocznij panoramowanie.
|
||||
if (!this.interaction.isCtrlPressed) {
|
||||
this.canvas.canvasSelection.updateSelection([]);
|
||||
}
|
||||
this.interaction.mode = 'panning';
|
||||
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
||||
}
|
||||
|
||||
startCanvasResize(worldCoords: Point): void {
|
||||
this.interaction.mode = 'resizingCanvas';
|
||||
const startX = snapToGrid(worldCoords.x);
|
||||
const startY = snapToGrid(worldCoords.y);
|
||||
this.interaction.canvasResizeStart = {x: startX, y: startY};
|
||||
this.interaction.canvasResizeRect = {x: startX, y: startY, width: 0, height: 0};
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
startCanvasMove(worldCoords: Point): void {
|
||||
this.interaction.mode = 'movingCanvas';
|
||||
this.interaction.dragStart = { ...worldCoords };
|
||||
const initialX = snapToGrid(worldCoords.x - this.canvas.width / 2);
|
||||
const initialY = snapToGrid(worldCoords.y - this.canvas.height / 2);
|
||||
|
||||
this.interaction.canvasMoveRect = {
|
||||
x: initialX,
|
||||
y: initialY,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height
|
||||
};
|
||||
|
||||
this.canvas.canvas.style.cursor = 'grabbing';
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
updateCanvasMove(worldCoords: Point): void {
|
||||
if (!this.interaction.canvasMoveRect) return;
|
||||
const dx = worldCoords.x - this.interaction.dragStart.x;
|
||||
const dy = worldCoords.y - this.interaction.dragStart.y;
|
||||
const initialRectX = snapToGrid(this.interaction.dragStart.x - this.canvas.width / 2);
|
||||
const initialRectY = snapToGrid(this.interaction.dragStart.y - this.canvas.height / 2);
|
||||
this.interaction.canvasMoveRect.x = snapToGrid(initialRectX + dx);
|
||||
this.interaction.canvasMoveRect.y = snapToGrid(initialRectY + dy);
|
||||
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
finalizeCanvasMove(): void {
|
||||
const moveRect = this.interaction.canvasMoveRect;
|
||||
|
||||
if (moveRect && (moveRect.x !== 0 || moveRect.y !== 0)) {
|
||||
const finalX = moveRect.x;
|
||||
const finalY = moveRect.y;
|
||||
|
||||
this.canvas.layers.forEach((layer: Layer) => {
|
||||
layer.x -= finalX;
|
||||
layer.y -= finalY;
|
||||
});
|
||||
|
||||
this.canvas.maskTool.updatePosition(-finalX, -finalY);
|
||||
|
||||
// If a batch generation is in progress, update the captured context as well
|
||||
if (this.canvas.pendingBatchContext) {
|
||||
this.canvas.pendingBatchContext.outputArea.x -= finalX;
|
||||
this.canvas.pendingBatchContext.outputArea.y -= finalY;
|
||||
|
||||
// Also update the menu spawn position to keep it relative
|
||||
this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
|
||||
this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
|
||||
log.debug("Updated pending batch context during canvas move:", this.canvas.pendingBatchContext);
|
||||
}
|
||||
|
||||
// Also move any active batch preview menus
|
||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||
this.canvas.batchPreviewManagers.forEach((manager: any) => { // TODO: Type for manager
|
||||
manager.worldX -= finalX;
|
||||
manager.worldY -= finalY;
|
||||
if (manager.generationArea) {
|
||||
manager.generationArea.x -= finalX;
|
||||
manager.generationArea.y -= finalY;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.canvas.viewport.x -= finalX;
|
||||
this.canvas.viewport.y -= finalY;
|
||||
}
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
}
|
||||
|
||||
startPanning(e: MouseEvent): void {
|
||||
if (!this.interaction.isCtrlPressed) {
|
||||
this.canvas.canvasSelection.updateSelection([]);
|
||||
}
|
||||
this.interaction.mode = 'panning';
|
||||
this.interaction.panStart = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
panViewport(e: MouseEvent): void {
|
||||
const dx = e.clientX - this.interaction.panStart.x;
|
||||
const dy = e.clientY - this.interaction.panStart.y;
|
||||
this.canvas.viewport.x -= dx / this.canvas.viewport.zoom;
|
||||
this.canvas.viewport.y -= dy / this.canvas.viewport.zoom;
|
||||
this.interaction.panStart = {x: e.clientX, y: e.clientY};
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
dragLayers(worldCoords: Point): void {
|
||||
if (this.interaction.isAltPressed && !this.interaction.hasClonedInDrag && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
// Scentralizowana logika duplikowania
|
||||
const newLayers = this.canvas.canvasSelection.duplicateSelectedLayers();
|
||||
|
||||
// Zresetuj pozycje przeciągania dla nowych, zduplikowanych warstw
|
||||
this.originalLayerPositions.clear();
|
||||
newLayers.forEach((l: Layer) => {
|
||||
this.originalLayerPositions.set(l, { x: l.x, y: l.y });
|
||||
});
|
||||
this.interaction.hasClonedInDrag = true;
|
||||
}
|
||||
const totalDx = worldCoords.x - this.interaction.dragStart.x;
|
||||
const totalDy = worldCoords.y - this.interaction.dragStart.y;
|
||||
let finalDx = totalDx, finalDy = totalDy;
|
||||
|
||||
if (this.interaction.isCtrlPressed && this.canvas.canvasSelection.selectedLayers.length > 0) {
|
||||
const firstLayer = this.canvas.canvasSelection.selectedLayers[0];
|
||||
const originalPos = this.originalLayerPositions.get(firstLayer);
|
||||
if (originalPos) {
|
||||
const tempLayerForSnap = {
|
||||
...firstLayer,
|
||||
x: originalPos.x + totalDx,
|
||||
y: originalPos.y + totalDy
|
||||
};
|
||||
const snapAdjustment = getSnapAdjustment(tempLayerForSnap);
|
||||
if (snapAdjustment) {
|
||||
finalDx += snapAdjustment.x;
|
||||
finalDy += snapAdjustment.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||
const originalPos = this.originalLayerPositions.get(layer);
|
||||
if (originalPos) {
|
||||
layer.x = originalPos.x + finalDx;
|
||||
layer.y = originalPos.y + finalDy;
|
||||
}
|
||||
});
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
resizeLayerFromHandle(worldCoords: Point, isShiftPressed: boolean): void {
|
||||
const layer = this.interaction.transformingLayer;
|
||||
if (!layer) return;
|
||||
|
||||
let mouseX = worldCoords.x;
|
||||
let mouseY = worldCoords.y;
|
||||
|
||||
if (this.interaction.isCtrlPressed) {
|
||||
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
||||
const snappedMouseX = snapToGrid(mouseX);
|
||||
if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX;
|
||||
const snappedMouseY = snapToGrid(mouseY);
|
||||
if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY;
|
||||
}
|
||||
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
||||
const handle = this.interaction.resizeHandle;
|
||||
const anchor = this.interaction.resizeAnchor;
|
||||
|
||||
const rad = o.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
const vecX = mouseX - anchor.x;
|
||||
const vecY = mouseY - anchor.y;
|
||||
|
||||
let newWidth = vecX * cos + vecY * sin;
|
||||
let newHeight = vecY * cos - vecX * sin;
|
||||
|
||||
if (isShiftPressed) {
|
||||
const originalAspectRatio = o.width / o.height;
|
||||
|
||||
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
||||
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
||||
} else {
|
||||
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
||||
let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
||||
|
||||
newWidth *= signX;
|
||||
newHeight *= signY;
|
||||
|
||||
if (signX === 0) newWidth = o.width;
|
||||
if (signY === 0) newHeight = o.height;
|
||||
|
||||
if (newWidth < 10) newWidth = 10;
|
||||
if (newHeight < 10) newHeight = 10;
|
||||
|
||||
layer.width = newWidth;
|
||||
layer.height = newHeight;
|
||||
|
||||
const deltaW = newWidth - o.width;
|
||||
const deltaH = newHeight - o.height;
|
||||
|
||||
const shiftX = (deltaW / 2) * signX;
|
||||
const shiftY = (deltaH / 2) * signY;
|
||||
|
||||
const worldShiftX = shiftX * cos - shiftY * sin;
|
||||
const worldShiftY = shiftX * sin + shiftY * cos;
|
||||
|
||||
const newCenterX = o.centerX + worldShiftX;
|
||||
const newCenterY = o.centerY + worldShiftY;
|
||||
|
||||
layer.x = newCenterX - layer.width / 2;
|
||||
layer.y = newCenterY - layer.height / 2;
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
rotateLayerFromHandle(worldCoords: Point, isShiftPressed: boolean): void {
|
||||
const layer = this.interaction.transformingLayer;
|
||||
if (!layer) return;
|
||||
|
||||
const o = this.interaction.transformOrigin;
|
||||
if (o.rotation === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
||||
const startAngle = Math.atan2(this.interaction.dragStart.y - o.centerY, this.interaction.dragStart.x - o.centerX);
|
||||
const currentAngle = Math.atan2(worldCoords.y - o.centerY, worldCoords.x - o.centerX);
|
||||
let angleDiff = (currentAngle - startAngle) * 180 / Math.PI;
|
||||
let newRotation = o.rotation + angleDiff;
|
||||
|
||||
if (isShiftPressed) {
|
||||
newRotation = Math.round(newRotation / 15) * 15;
|
||||
}
|
||||
|
||||
layer.rotation = newRotation;
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
updateCanvasResize(worldCoords: Point): void {
|
||||
if (!this.interaction.canvasResizeRect) return;
|
||||
const snappedMouseX = snapToGrid(worldCoords.x);
|
||||
const snappedMouseY = snapToGrid(worldCoords.y);
|
||||
const start = this.interaction.canvasResizeStart;
|
||||
|
||||
this.interaction.canvasResizeRect.x = Math.min(snappedMouseX, start.x);
|
||||
this.interaction.canvasResizeRect.y = Math.min(snappedMouseY, start.y);
|
||||
this.interaction.canvasResizeRect.width = Math.abs(snappedMouseX - start.x);
|
||||
this.interaction.canvasResizeRect.height = Math.abs(snappedMouseY - start.y);
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
finalizeCanvasResize(): void {
|
||||
if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) {
|
||||
const newWidth = Math.round(this.interaction.canvasResizeRect.width);
|
||||
const newHeight = Math.round(this.interaction.canvasResizeRect.height);
|
||||
const finalX = this.interaction.canvasResizeRect.x;
|
||||
const finalY = this.interaction.canvasResizeRect.y;
|
||||
|
||||
this.canvas.updateOutputAreaSize(newWidth, newHeight);
|
||||
|
||||
this.canvas.layers.forEach((layer: Layer) => {
|
||||
layer.x -= finalX;
|
||||
layer.y -= finalY;
|
||||
});
|
||||
|
||||
this.canvas.maskTool.updatePosition(-finalX, -finalY);
|
||||
|
||||
// If a batch generation is in progress, update the captured context as well
|
||||
if (this.canvas.pendingBatchContext) {
|
||||
this.canvas.pendingBatchContext.outputArea.x -= finalX;
|
||||
this.canvas.pendingBatchContext.outputArea.y -= finalY;
|
||||
|
||||
// Also update the menu spawn position to keep it relative
|
||||
this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
|
||||
this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
|
||||
log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext);
|
||||
}
|
||||
|
||||
// Also move any active batch preview menus
|
||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||
this.canvas.batchPreviewManagers.forEach((manager: any) => { // TODO: Type for manager
|
||||
manager.worldX -= finalX;
|
||||
manager.worldY -= finalY;
|
||||
if (manager.generationArea) {
|
||||
manager.generationArea.x -= finalX;
|
||||
manager.generationArea.y -= finalY;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.canvas.viewport.x -= finalX;
|
||||
this.canvas.viewport.y -= finalY;
|
||||
}
|
||||
}
|
||||
|
||||
handleDragOver(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
handleDragEnter(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||
this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)';
|
||||
this.canvas.canvas.style.border = '2px dashed #2d5aa0';
|
||||
}
|
||||
|
||||
handleDragLeave(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||
|
||||
if (!this.canvas.canvas.contains(e.relatedTarget as Node)) {
|
||||
this.canvas.canvas.style.backgroundColor = '';
|
||||
this.canvas.canvas.style.border = '';
|
||||
}
|
||||
}
|
||||
|
||||
async handleDrop(e: DragEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow
|
||||
|
||||
log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading");
|
||||
|
||||
this.canvas.canvas.style.backgroundColor = '';
|
||||
this.canvas.canvas.style.border = '';
|
||||
|
||||
if (!e.dataTransfer) return;
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||
|
||||
log.info(`Dropped ${files.length} file(s) onto canvas at position (${worldCoords.x}, ${worldCoords.y})`);
|
||||
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
try {
|
||||
await this.loadDroppedImageFile(file, worldCoords);
|
||||
log.info(`Successfully loaded dropped image: ${file.name}`);
|
||||
} catch (error) {
|
||||
log.error(`Failed to load dropped image ${file.name}:`, error);
|
||||
}
|
||||
} else {
|
||||
log.warn(`Skipped non-image file: ${file.name} (${file.type})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadDroppedImageFile(file: File, worldCoords: Point): Promise<void> {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
|
||||
const fitOnAddWidget = this.canvas.node.widgets.find((w: any) => w.name === "fit_on_add");
|
||||
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
|
||||
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.error(`Failed to load dropped image: ${file.name}`);
|
||||
};
|
||||
if (e.target?.result) {
|
||||
img.src = e.target.result as string;
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
log.error(`Failed to read dropped file: ${file.name}`);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
async handlePasteEvent(e: ClipboardEvent): Promise<void> {
|
||||
|
||||
const shouldHandle = this.canvas.isMouseOver ||
|
||||
this.canvas.canvas.contains(document.activeElement) ||
|
||||
document.activeElement === this.canvas.canvas ||
|
||||
document.activeElement === document.body;
|
||||
|
||||
if (!shouldHandle) {
|
||||
log.debug("Paste event ignored - not focused on canvas");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Paste event detected, checking clipboard preference");
|
||||
|
||||
const preference = this.canvas.canvasLayers.clipboardPreference;
|
||||
|
||||
if (preference === 'clipspace') {
|
||||
|
||||
log.info("Clipboard preference is clipspace, delegating to ClipboardManager");
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
||||
return;
|
||||
}
|
||||
|
||||
const clipboardData = e.clipboardData;
|
||||
if (clipboardData && clipboardData.items) {
|
||||
for (const item of clipboardData.items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
log.info("Found direct image data in paste event");
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse');
|
||||
};
|
||||
if (event.target?.result) {
|
||||
img.src = event.target.result as string;
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
||||
}
|
||||
}
|
||||
963
src/CanvasLayers.ts
Normal file
963
src/CanvasLayers.ts
Normal file
@@ -0,0 +1,963 @@
|
||||
import {saveImage, removeImage} from "./db.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import {generateUUID, generateUniqueFileName} from "./utils/CommonUtils.js";
|
||||
import {withErrorHandling, createValidationError} from "./ErrorHandler.js";
|
||||
// @ts-ignore
|
||||
import {app} from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import {ComfyApp} from "../../scripts/app.js";
|
||||
import { ClipboardManager } from "./utils/ClipboardManager.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer, Point, AddMode, ClipboardPreference } from './types';
|
||||
|
||||
const log = createModuleLogger('CanvasLayers');
|
||||
|
||||
interface BlendMode {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export class CanvasLayers {
|
||||
private canvas: Canvas;
|
||||
public clipboardManager: ClipboardManager;
|
||||
private blendModes: BlendMode[];
|
||||
private selectedBlendMode: string | null;
|
||||
private blendOpacity: number;
|
||||
private isAdjustingOpacity: boolean;
|
||||
public internalClipboard: Layer[];
|
||||
public clipboardPreference: ClipboardPreference;
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
this.canvas = canvas;
|
||||
this.clipboardManager = new ClipboardManager(canvas as any);
|
||||
this.blendModes = [
|
||||
{ name: 'normal', label: 'Normal' },
|
||||
{name: 'multiply', label: 'Multiply'},
|
||||
{name: 'screen', label: 'Screen'},
|
||||
{name: 'overlay', label: 'Overlay'},
|
||||
{name: 'darken', label: 'Darken'},
|
||||
{name: 'lighten', label: 'Lighten'},
|
||||
{name: 'color-dodge', label: 'Color Dodge'},
|
||||
{name: 'color-burn', label: 'Color Burn'},
|
||||
{name: 'hard-light', label: 'Hard Light'},
|
||||
{name: 'soft-light', label: 'Soft Light'},
|
||||
{name: 'difference', label: 'Difference'},
|
||||
{ name: 'exclusion', label: 'Exclusion' }
|
||||
];
|
||||
this.selectedBlendMode = null;
|
||||
this.blendOpacity = 100;
|
||||
this.isAdjustingOpacity = false;
|
||||
this.internalClipboard = [];
|
||||
this.clipboardPreference = 'system';
|
||||
}
|
||||
|
||||
async copySelectedLayers(): Promise<void> {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
||||
|
||||
this.internalClipboard = this.canvas.canvasSelection.selectedLayers.map((layer: Layer) => ({ ...layer }));
|
||||
log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`);
|
||||
|
||||
const blob = await this.getFlattenedSelectionAsBlob();
|
||||
if (!blob) {
|
||||
log.warn("Failed to create flattened selection blob");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.clipboardPreference === 'clipspace') {
|
||||
try {
|
||||
const dataURL = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (!this.canvas.node.imgs) {
|
||||
this.canvas.node.imgs = [];
|
||||
}
|
||||
this.canvas.node.imgs[0] = img;
|
||||
|
||||
if (ComfyApp.copyToClipspace) {
|
||||
ComfyApp.copyToClipspace(this.canvas.node);
|
||||
log.info("Flattened selection copied to ComfyUI Clipspace.");
|
||||
} else {
|
||||
log.warn("ComfyUI copyToClipspace not available");
|
||||
}
|
||||
};
|
||||
img.src = dataURL;
|
||||
} catch (error) {
|
||||
log.error("Failed to copy image to ComfyUI Clipspace:", error);
|
||||
try {
|
||||
const item = new ClipboardItem({ 'image/png': blob });
|
||||
await navigator.clipboard.write([item]);
|
||||
log.info("Fallback: Flattened selection copied to system clipboard.");
|
||||
} catch (fallbackError) {
|
||||
log.error("Failed to copy to system clipboard as fallback:", fallbackError);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const item = new ClipboardItem({ 'image/png': blob });
|
||||
await navigator.clipboard.write([item]);
|
||||
log.info("Flattened selection copied to system clipboard.");
|
||||
} catch (error) {
|
||||
log.error("Failed to copy image to system clipboard:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pasteLayers(): void {
|
||||
if (this.internalClipboard.length === 0) return;
|
||||
this.canvas.saveState();
|
||||
const newLayers: Layer[] = [];
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
this.internalClipboard.forEach((layer: Layer) => {
|
||||
minX = Math.min(minX, layer.x);
|
||||
minY = Math.min(minY, layer.y);
|
||||
maxX = Math.max(maxX, layer.x + layer.width);
|
||||
maxY = Math.max(maxY, layer.y + layer.height);
|
||||
});
|
||||
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const centerY = (minY + maxY) / 2;
|
||||
|
||||
const { x: mouseX, y: mouseY } = this.canvas.lastMousePosition;
|
||||
const offsetX = mouseX - centerX;
|
||||
const offsetY = mouseY - centerY;
|
||||
|
||||
this.internalClipboard.forEach((clipboardLayer: Layer) => {
|
||||
const newLayer: Layer = {
|
||||
...clipboardLayer,
|
||||
x: clipboardLayer.x + offsetX,
|
||||
y: clipboardLayer.y + offsetY,
|
||||
zIndex: this.canvas.layers.length
|
||||
};
|
||||
this.canvas.layers.push(newLayer);
|
||||
newLayers.push(newLayer);
|
||||
});
|
||||
|
||||
this.canvas.updateSelection(newLayers);
|
||||
this.canvas.render();
|
||||
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
|
||||
log.info(`Pasted ${newLayers.length} layer(s) at mouse position (${mouseX}, ${mouseY}).`);
|
||||
}
|
||||
|
||||
async handlePaste(addMode: AddMode = 'mouse'): Promise<void> {
|
||||
try {
|
||||
log.info(`Paste operation started with preference: ${this.clipboardPreference}`);
|
||||
await this.clipboardManager.handlePaste(addMode, this.clipboardPreference);
|
||||
} catch (err) {
|
||||
log.error("Paste operation failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
addLayerWithImage = withErrorHandling(async (image: HTMLImageElement, layerProps: Partial<Layer> = {}, addMode: AddMode = 'default', targetArea: { x: number, y: number, width: number, height: number } | null = null): Promise<Layer> => {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required for layer creation");
|
||||
}
|
||||
|
||||
log.debug("Adding layer with image:", image, "with mode:", addMode, "targetArea:", targetArea);
|
||||
const imageId = generateUUID();
|
||||
await saveImage(imageId, image.src);
|
||||
this.canvas.imageCache.set(imageId, image.src);
|
||||
|
||||
let finalWidth = image.width;
|
||||
let finalHeight = image.height;
|
||||
let finalX, finalY;
|
||||
|
||||
// Use the targetArea if provided, otherwise default to the current canvas dimensions
|
||||
const area = targetArea || { width: this.canvas.width, height: this.canvas.height, x: 0, y: 0 };
|
||||
|
||||
if (addMode === 'fit') {
|
||||
const scale = Math.min(area.width / image.width, area.height / image.height);
|
||||
finalWidth = image.width * scale;
|
||||
finalHeight = image.height * scale;
|
||||
finalX = area.x + (area.width - finalWidth) / 2;
|
||||
finalY = area.y + (area.height - finalHeight) / 2;
|
||||
} else if (addMode === 'mouse') {
|
||||
finalX = this.canvas.lastMousePosition.x - finalWidth / 2;
|
||||
finalY = this.canvas.lastMousePosition.y - finalHeight / 2;
|
||||
} else {
|
||||
finalX = area.x + (area.width - finalWidth) / 2;
|
||||
finalY = area.y + (area.height - finalHeight) / 2;
|
||||
}
|
||||
|
||||
const layer: Layer = {
|
||||
id: generateUUID(),
|
||||
image: image,
|
||||
imageId: imageId,
|
||||
name: 'Layer',
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
width: finalWidth,
|
||||
height: finalHeight,
|
||||
originalWidth: image.width,
|
||||
originalHeight: image.height,
|
||||
rotation: 0,
|
||||
zIndex: this.canvas.layers.length,
|
||||
blendMode: 'normal',
|
||||
opacity: 1,
|
||||
...layerProps
|
||||
};
|
||||
|
||||
this.canvas.layers.push(layer);
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
|
||||
log.info("Layer added successfully");
|
||||
return layer;
|
||||
}, 'CanvasLayers.addLayerWithImage');
|
||||
|
||||
async addLayer(image: HTMLImageElement): Promise<Layer> {
|
||||
return this.addLayerWithImage(image);
|
||||
}
|
||||
|
||||
moveLayers(layersToMove: Layer[], options: { direction?: 'up' | 'down', toIndex?: number } = {}): void {
|
||||
if (!layersToMove || layersToMove.length === 0) return;
|
||||
|
||||
let finalLayers: Layer[];
|
||||
|
||||
if (options.direction) {
|
||||
const allLayers = [...this.canvas.layers];
|
||||
const selectedIndices = new Set(layersToMove.map((l: Layer) => allLayers.indexOf(l)));
|
||||
|
||||
if (options.direction === 'up') {
|
||||
const sorted = Array.from(selectedIndices).sort((a, b) => b - a);
|
||||
sorted.forEach((index: number) => {
|
||||
const targetIndex = index + 1;
|
||||
if (targetIndex < allLayers.length && !selectedIndices.has(targetIndex)) {
|
||||
[allLayers[index], allLayers[targetIndex]] = [allLayers[targetIndex], allLayers[index]];
|
||||
}
|
||||
});
|
||||
} else if (options.direction === 'down') {
|
||||
const sorted = Array.from(selectedIndices).sort((a, b) => a - b);
|
||||
sorted.forEach((index: number) => {
|
||||
const targetIndex = index - 1;
|
||||
if (targetIndex >= 0 && !selectedIndices.has(targetIndex)) {
|
||||
[allLayers[index], allLayers[targetIndex]] = [allLayers[targetIndex], allLayers[index]];
|
||||
}
|
||||
});
|
||||
}
|
||||
finalLayers = allLayers;
|
||||
} else if (options.toIndex !== undefined) {
|
||||
const displayedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||
const reorderedFinal: Layer[] = [];
|
||||
let inserted = false;
|
||||
|
||||
for (let i = 0; i < displayedLayers.length; i++) {
|
||||
if (i === options.toIndex) {
|
||||
reorderedFinal.push(...layersToMove);
|
||||
inserted = true;
|
||||
}
|
||||
const currentLayer = displayedLayers[i];
|
||||
if (!layersToMove.includes(currentLayer)) {
|
||||
reorderedFinal.push(currentLayer);
|
||||
}
|
||||
}
|
||||
if (!inserted) {
|
||||
reorderedFinal.push(...layersToMove);
|
||||
}
|
||||
finalLayers = reorderedFinal;
|
||||
} else {
|
||||
log.warn("Invalid options for moveLayers", options);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalLayers = finalLayers.length;
|
||||
finalLayers.forEach((layer, index) => {
|
||||
const zIndex = (options.toIndex !== undefined) ? (totalLayers - 1 - index) : index;
|
||||
layer.zIndex = zIndex;
|
||||
});
|
||||
|
||||
this.canvas.layers = finalLayers;
|
||||
this.canvas.layers.sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
|
||||
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
this.canvas.requestSaveState();
|
||||
log.info(`Moved ${layersToMove.length} layer(s).`);
|
||||
}
|
||||
|
||||
moveLayerUp(): void {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
||||
this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'up' });
|
||||
}
|
||||
|
||||
moveLayerDown(): void {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
||||
this.moveLayers(this.canvas.canvasSelection.selectedLayers, { direction: 'down' });
|
||||
}
|
||||
|
||||
resizeLayer(scale: number): void {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
||||
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||
layer.width *= scale;
|
||||
layer.height *= scale;
|
||||
});
|
||||
this.canvas.render();
|
||||
this.canvas.requestSaveState();
|
||||
}
|
||||
|
||||
rotateLayer(angle: number): void {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
||||
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||
layer.rotation += angle;
|
||||
});
|
||||
this.canvas.render();
|
||||
this.canvas.requestSaveState();
|
||||
}
|
||||
|
||||
getLayerAtPosition(worldX: number, worldY: number): { layer: Layer, localX: number, localY: number } | null {
|
||||
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
|
||||
const layer = this.canvas.layers[i];
|
||||
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
|
||||
const dx = worldX - centerX;
|
||||
const dy = worldY - centerY;
|
||||
|
||||
const rad = -layer.rotation * Math.PI / 180;
|
||||
const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad);
|
||||
const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad);
|
||||
|
||||
if (Math.abs(rotatedX) <= layer.width / 2 && Math.abs(rotatedY) <= layer.height / 2) {
|
||||
return {
|
||||
layer: layer,
|
||||
localX: rotatedX + layer.width / 2,
|
||||
localY: rotatedY + layer.height / 2
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, options: { offsetX?: number, offsetY?: number } = {}): void {
|
||||
if (!layer.image) return;
|
||||
|
||||
const { offsetX = 0, offsetY = 0 } = options;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
|
||||
const centerX = layer.x + layer.width / 2 - offsetX;
|
||||
const centerY = layer.y + layer.height / 2 - offsetY;
|
||||
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(layer.rotation * Math.PI / 180);
|
||||
|
||||
const scaleH = layer.flipH ? -1 : 1;
|
||||
const scaleV = layer.flipV ? -1 : 1;
|
||||
if (layer.flipH || layer.flipV) {
|
||||
ctx.scale(scaleH, scaleV);
|
||||
}
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(
|
||||
layer.image,
|
||||
-layer.width / 2, -layer.height / 2,
|
||||
layer.width, layer.height
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private _drawLayers(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
|
||||
const sortedLayers = [...layers].sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach(layer => this._drawLayer(ctx, layer, options));
|
||||
}
|
||||
|
||||
public drawLayersToContext(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
|
||||
this._drawLayers(ctx, layers, options);
|
||||
}
|
||||
|
||||
async mirrorHorizontal(): Promise<void> {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||
layer.flipH = !layer.flipH;
|
||||
});
|
||||
this.canvas.render();
|
||||
this.canvas.requestSaveState();
|
||||
}
|
||||
|
||||
async mirrorVertical(): Promise<void> {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||
layer.flipV = !layer.flipV;
|
||||
});
|
||||
this.canvas.render();
|
||||
this.canvas.requestSaveState();
|
||||
}
|
||||
|
||||
async getLayerImageData(layer: Layer): Promise<string> {
|
||||
try {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
if (!tempCtx) throw new Error("Could not create canvas context");
|
||||
|
||||
tempCanvas.width = layer.width;
|
||||
tempCanvas.height = layer.height;
|
||||
|
||||
// We need to draw the layer relative to the new canvas, so we "move" it to 0,0
|
||||
// by creating a temporary layer object for drawing.
|
||||
const layerToDraw = {
|
||||
...layer,
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
|
||||
this._drawLayer(tempCtx, layerToDraw);
|
||||
|
||||
const dataUrl = tempCanvas.toDataURL('image/png');
|
||||
if (!dataUrl.startsWith('data:image/png;base64,')) {
|
||||
throw new Error("Invalid image data format");
|
||||
}
|
||||
return dataUrl;
|
||||
} catch (error) {
|
||||
log.error("Error getting layer image data:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
updateOutputAreaSize(width: number, height: number, saveHistory = true): void {
|
||||
if (saveHistory) {
|
||||
this.canvas.saveState();
|
||||
}
|
||||
this.canvas.width = width;
|
||||
this.canvas.height = height;
|
||||
this.canvas.maskTool.resize(width, height);
|
||||
|
||||
this.canvas.canvas.width = width;
|
||||
this.canvas.canvas.height = height;
|
||||
|
||||
this.canvas.render();
|
||||
|
||||
if (saveHistory) {
|
||||
this.canvas.canvasState.saveStateToDB();
|
||||
}
|
||||
}
|
||||
|
||||
getHandles(layer: Layer): Record<string, Point> {
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
const localHandles: Record<string, Point> = {
|
||||
'n': { x: 0, y: -halfH },
|
||||
'ne': { x: halfW, y: -halfH },
|
||||
'e': { x: halfW, y: 0 },
|
||||
'se': { x: halfW, y: halfH },
|
||||
's': { x: 0, y: halfH },
|
||||
'sw': { x: -halfW, y: halfH },
|
||||
'w': { x: -halfW, y: 0 },
|
||||
'nw': { x: -halfW, y: -halfH },
|
||||
'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom }
|
||||
};
|
||||
|
||||
const worldHandles: Record<string, Point> = {};
|
||||
for (const key in localHandles) {
|
||||
const p = localHandles[key];
|
||||
worldHandles[key] = {
|
||||
x: centerX + (p.x * cos - p.y * sin),
|
||||
y: centerY + (p.x * sin + p.y * cos)
|
||||
};
|
||||
}
|
||||
return worldHandles;
|
||||
}
|
||||
|
||||
getHandleAtPosition(worldX: number, worldY: number): { layer: Layer, handle: string } | null {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) return null;
|
||||
|
||||
const handleRadius = 8 / this.canvas.viewport.zoom;
|
||||
for (let i = this.canvas.canvasSelection.selectedLayers.length - 1; i >= 0; i--) {
|
||||
const layer = this.canvas.canvasSelection.selectedLayers[i];
|
||||
const handles = this.getHandles(layer);
|
||||
|
||||
for (const key in handles) {
|
||||
const handlePos = handles[key];
|
||||
const dx = worldX - handlePos.x;
|
||||
const dy = worldY - handlePos.y;
|
||||
if (dx * dx + dy * dy <= handleRadius * handleRadius) {
|
||||
return { layer: layer, handle: key };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
showBlendModeMenu(x: number, y: number): void {
|
||||
this.closeBlendModeMenu();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.id = 'blend-mode-menu';
|
||||
menu.style.cssText = `
|
||||
position: fixed;
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
z-index: 10000;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
min-width: 200px;
|
||||
`;
|
||||
|
||||
const titleBar = document.createElement('div');
|
||||
titleBar.style.cssText = `
|
||||
background: #3a3a3a;
|
||||
color: white;
|
||||
padding: 8px 10px;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
border-radius: 3px 3px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #4a4a4a;
|
||||
`;
|
||||
titleBar.textContent = 'Blend Mode';
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `padding: 5px;`;
|
||||
|
||||
menu.appendChild(titleBar);
|
||||
menu.appendChild(content);
|
||||
|
||||
let isDragging = false;
|
||||
let dragOffset = { x: 0, y: 0 };
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const newX = e.clientX - dragOffset.x;
|
||||
const newY = e.clientY - dragOffset.y;
|
||||
const maxX = window.innerWidth - menu.offsetWidth;
|
||||
const maxY = window.innerHeight - menu.offsetHeight;
|
||||
menu.style.left = Math.max(0, Math.min(newX, maxX)) + 'px';
|
||||
menu.style.top = Math.max(0, Math.min(newY, maxY)) + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDragging) {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
};
|
||||
|
||||
titleBar.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
isDragging = true;
|
||||
dragOffset.x = e.clientX - parseInt(menu.style.left, 10);
|
||||
dragOffset.y = e.clientY - parseInt(menu.style.top, 10);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
this.blendModes.forEach((mode: BlendMode) => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'blend-mode-container';
|
||||
container.style.cssText = `margin-bottom: 5px;`;
|
||||
|
||||
const option = document.createElement('div');
|
||||
option.style.cssText = `padding: 5px 10px; color: white; cursor: pointer; transition: background-color 0.2s;`;
|
||||
option.textContent = `${mode.label} (${mode.name})`;
|
||||
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.min = '0';
|
||||
slider.max = '100';
|
||||
const selectedLayer = this.canvas.canvasSelection.selectedLayers[0];
|
||||
slider.value = selectedLayer ? String(Math.round(selectedLayer.opacity * 100)) : '100';
|
||||
slider.style.cssText = `width: 100%; margin: 5px 0; display: none;`;
|
||||
|
||||
if (selectedLayer && selectedLayer.blendMode === mode.name) {
|
||||
slider.style.display = 'block';
|
||||
option.style.backgroundColor = '#3a3a3a';
|
||||
}
|
||||
|
||||
option.onclick = () => {
|
||||
content.querySelectorAll<HTMLInputElement>('input[type="range"]').forEach(s => s.style.display = 'none');
|
||||
content.querySelectorAll<HTMLDivElement>('.blend-mode-container div').forEach(d => d.style.backgroundColor = '');
|
||||
|
||||
slider.style.display = 'block';
|
||||
option.style.backgroundColor = '#3a3a3a';
|
||||
|
||||
if (selectedLayer) {
|
||||
selectedLayer.blendMode = mode.name;
|
||||
this.canvas.render();
|
||||
}
|
||||
};
|
||||
|
||||
slider.addEventListener('input', () => {
|
||||
if (selectedLayer) {
|
||||
selectedLayer.opacity = parseInt(slider.value, 10) / 100;
|
||||
this.canvas.render();
|
||||
}
|
||||
});
|
||||
|
||||
slider.addEventListener('change', async () => {
|
||||
if (selectedLayer) {
|
||||
selectedLayer.opacity = parseInt(slider.value, 10) / 100;
|
||||
this.canvas.render();
|
||||
const saveWithFallback = async (fileName: string) => {
|
||||
try {
|
||||
const uniqueFileName = generateUniqueFileName(fileName, this.canvas.node.id);
|
||||
return await this.canvas.canvasIO.saveToServer(uniqueFileName);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error);
|
||||
return await this.canvas.canvasIO.saveToServer(fileName);
|
||||
}
|
||||
};
|
||||
if (this.canvas.widget) {
|
||||
await saveWithFallback(this.canvas.widget.value);
|
||||
if (this.canvas.node) {
|
||||
app.graph.runStep();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(option);
|
||||
container.appendChild(slider);
|
||||
content.appendChild(container);
|
||||
});
|
||||
|
||||
const container = this.canvas.canvas.parentElement || document.body;
|
||||
container.appendChild(menu);
|
||||
|
||||
const closeMenu = (e: MouseEvent) => {
|
||||
if (e.target instanceof Node && !menu.contains(e.target) && !isDragging) {
|
||||
this.closeBlendModeMenu();
|
||||
document.removeEventListener('mousedown', closeMenu);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('mousedown', closeMenu), 0);
|
||||
}
|
||||
|
||||
closeBlendModeMenu(): void {
|
||||
const menu = document.getElementById('blend-mode-menu');
|
||||
if (menu && menu.parentNode) {
|
||||
menu.parentNode.removeChild(menu);
|
||||
}
|
||||
}
|
||||
|
||||
showOpacitySlider(mode: string): void {
|
||||
const slider = document.createElement('input');
|
||||
slider.type = 'range';
|
||||
slider.min = '0';
|
||||
slider.max = '100';
|
||||
slider.value = String(this.blendOpacity);
|
||||
slider.className = 'blend-opacity-slider';
|
||||
|
||||
slider.addEventListener('input', (e) => {
|
||||
this.blendOpacity = parseInt((e.target as HTMLInputElement).value, 10);
|
||||
});
|
||||
|
||||
const modeElement = document.querySelector(`[data-blend-mode="${mode}"]`);
|
||||
if (modeElement) {
|
||||
modeElement.appendChild(slider);
|
||||
}
|
||||
}
|
||||
|
||||
async getFlattenedCanvasWithMaskAsBlob(): Promise<Blob | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = this.canvas.width;
|
||||
tempCanvas.height = this.canvas.height;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
if (!tempCtx) {
|
||||
reject(new Error("Could not create canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
this._drawLayers(tempCtx, this.canvas.layers);
|
||||
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||
if (toolMaskCanvas) {
|
||||
const tempMaskCanvas = document.createElement('canvas');
|
||||
tempMaskCanvas.width = this.canvas.width;
|
||||
tempMaskCanvas.height = this.canvas.height;
|
||||
const tempMaskCtx = tempMaskCanvas.getContext('2d');
|
||||
if (!tempMaskCtx) {
|
||||
reject(new Error("Could not create mask canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||
|
||||
const maskX = this.canvas.maskTool.x;
|
||||
const maskY = this.canvas.maskTool.y;
|
||||
|
||||
const sourceX = Math.max(0, -maskX);
|
||||
const sourceY = Math.max(0, -maskY);
|
||||
const destX = Math.max(0, maskX);
|
||||
const destY = Math.max(0, maskY);
|
||||
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
|
||||
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
|
||||
|
||||
if (copyWidth > 0 && copyHeight > 0) {
|
||||
tempMaskCtx.drawImage(
|
||||
toolMaskCanvas,
|
||||
sourceX, sourceY, copyWidth, copyHeight,
|
||||
destX, destY, copyWidth, copyHeight
|
||||
);
|
||||
}
|
||||
|
||||
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
for (let i = 0; i < tempMaskData.data.length; i += 4) {
|
||||
const alpha = tempMaskData.data[i + 3];
|
||||
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
|
||||
tempMaskData.data[i + 3] = alpha;
|
||||
}
|
||||
tempMaskCtx.putImageData(tempMaskData, 0, 0);
|
||||
|
||||
const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const maskData = maskImageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const originalAlpha = data[i + 3];
|
||||
const maskAlpha = maskData[i + 3] / 255;
|
||||
const invertedMaskAlpha = 1 - maskAlpha;
|
||||
data[i + 3] = originalAlpha * invertedMaskAlpha;
|
||||
}
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
tempCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
async getFlattenedCanvasAsBlob(): Promise<Blob | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = this.canvas.width;
|
||||
tempCanvas.height = this.canvas.height;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
if (!tempCtx) {
|
||||
reject(new Error("Could not create canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
this._drawLayers(tempCtx, this.canvas.layers);
|
||||
|
||||
tempCanvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
async getFlattenedCanvasForMaskEditor(): Promise<Blob | null> {
|
||||
return this.getFlattenedCanvasWithMaskAsBlob();
|
||||
}
|
||||
|
||||
async getFlattenedSelectionAsBlob(): Promise<Blob | null> {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
|
||||
const corners = [
|
||||
{ x: -halfW, y: -halfH },
|
||||
{ x: halfW, y: -halfH },
|
||||
{ x: halfW, y: halfH },
|
||||
{ x: -halfW, y: halfH }
|
||||
];
|
||||
|
||||
corners.forEach(p => {
|
||||
const worldX = centerX + (p.x * cos - p.y * sin);
|
||||
const worldY = centerY + (p.x * sin + p.y * cos);
|
||||
|
||||
minX = Math.min(minX, worldX);
|
||||
minY = Math.min(minY, worldY);
|
||||
maxX = Math.max(maxX, worldX);
|
||||
maxY = Math.max(maxY, worldY);
|
||||
});
|
||||
});
|
||||
|
||||
const newWidth = Math.ceil(maxX - minX);
|
||||
const newHeight = Math.ceil(maxY - minY);
|
||||
|
||||
if (newWidth <= 0 || newHeight <= 0) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = newWidth;
|
||||
tempCanvas.height = newHeight;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
if (!tempCtx) {
|
||||
reject(new Error("Could not create canvas context"));
|
||||
return;
|
||||
}
|
||||
|
||||
tempCtx.translate(-minX, -minY);
|
||||
|
||||
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
|
||||
|
||||
tempCanvas.toBlob((blob) => {
|
||||
resolve(blob);
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
async fuseLayers(): Promise<void> {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length < 2) {
|
||||
alert("Please select at least 2 layers to fuse.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Fusing ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
|
||||
|
||||
try {
|
||||
this.canvas.saveState();
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
|
||||
const corners = [
|
||||
{ x: -halfW, y: -halfH },
|
||||
{ x: halfW, y: -halfH },
|
||||
{ x: halfW, y: halfH },
|
||||
{ x: -halfW, y: halfH }
|
||||
];
|
||||
|
||||
corners.forEach(p => {
|
||||
const worldX = centerX + (p.x * cos - p.y * sin);
|
||||
const worldY = centerY + (p.x * sin + p.y * cos);
|
||||
minX = Math.min(minX, worldX);
|
||||
minY = Math.min(minY, worldY);
|
||||
maxX = Math.max(maxX, worldX);
|
||||
maxY = Math.max(maxY, worldY);
|
||||
});
|
||||
});
|
||||
|
||||
const fusedWidth = Math.ceil(maxX - minX);
|
||||
const fusedHeight = Math.ceil(maxY - minY);
|
||||
|
||||
if (fusedWidth <= 0 || fusedHeight <= 0) {
|
||||
log.warn("Calculated fused layer dimensions are invalid");
|
||||
alert("Cannot fuse layers: invalid dimensions calculated.");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = fusedWidth;
|
||||
tempCanvas.height = fusedHeight;
|
||||
const tempCtx = tempCanvas.getContext('2d');
|
||||
if (!tempCtx) throw new Error("Could not create canvas context");
|
||||
|
||||
tempCtx.translate(-minX, -minY);
|
||||
|
||||
this._drawLayers(tempCtx, this.canvas.canvasSelection.selectedLayers);
|
||||
|
||||
const fusedImage = new Image();
|
||||
fusedImage.src = tempCanvas.toDataURL();
|
||||
await new Promise((resolve, reject) => {
|
||||
fusedImage.onload = resolve;
|
||||
fusedImage.onerror = reject;
|
||||
});
|
||||
|
||||
const minZIndex = Math.min(...this.canvas.canvasSelection.selectedLayers.map((layer: Layer) => layer.zIndex));
|
||||
const imageId = generateUUID();
|
||||
await saveImage(imageId, fusedImage.src);
|
||||
this.canvas.imageCache.set(imageId, fusedImage.src);
|
||||
|
||||
const fusedLayer: Layer = {
|
||||
id: generateUUID(),
|
||||
image: fusedImage,
|
||||
imageId: imageId,
|
||||
name: 'Fused Layer',
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: fusedWidth,
|
||||
height: fusedHeight,
|
||||
originalWidth: fusedWidth,
|
||||
originalHeight: fusedHeight,
|
||||
rotation: 0,
|
||||
zIndex: minZIndex,
|
||||
blendMode: 'normal',
|
||||
opacity: 1
|
||||
};
|
||||
|
||||
this.canvas.layers = this.canvas.layers.filter((layer: Layer) => !this.canvas.canvasSelection.selectedLayers.includes(layer));
|
||||
this.canvas.layers.push(fusedLayer);
|
||||
this.canvas.layers.sort((a: Layer, b: Layer) => a.zIndex - b.zIndex);
|
||||
this.canvas.layers.forEach((layer: Layer, index: number) => {
|
||||
layer.zIndex = index;
|
||||
});
|
||||
|
||||
this.canvas.updateSelection([fusedLayer]);
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
|
||||
log.info("Layers fused successfully", {
|
||||
originalLayerCount: this.canvas.canvasSelection.selectedLayers.length,
|
||||
fusedDimensions: { width: fusedWidth, height: fusedHeight },
|
||||
fusedPosition: { x: minX, y: minY }
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
log.error("Error during layer fusion:", error);
|
||||
alert(`Error fusing layers: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
613
src/CanvasLayersPanel.ts
Normal file
613
src/CanvasLayersPanel.ts
Normal file
@@ -0,0 +1,613 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer } from './types';
|
||||
|
||||
const log = createModuleLogger('CanvasLayersPanel');
|
||||
|
||||
export class CanvasLayersPanel {
|
||||
private canvas: Canvas;
|
||||
private container: HTMLElement | null;
|
||||
private layersContainer: HTMLElement | null;
|
||||
private draggedElements: Layer[];
|
||||
private dragInsertionLine: HTMLElement | null;
|
||||
private isMultiSelecting: boolean;
|
||||
private lastSelectedIndex: number;
|
||||
|
||||
constructor(canvas: Canvas) {
|
||||
this.canvas = canvas;
|
||||
this.container = null;
|
||||
this.layersContainer = null;
|
||||
this.draggedElements = [];
|
||||
this.dragInsertionLine = null;
|
||||
this.isMultiSelecting = false;
|
||||
this.lastSelectedIndex = -1;
|
||||
|
||||
this.handleLayerClick = this.handleLayerClick.bind(this);
|
||||
this.handleDragStart = this.handleDragStart.bind(this);
|
||||
this.handleDragOver = this.handleDragOver.bind(this);
|
||||
this.handleDragEnd = this.handleDragEnd.bind(this);
|
||||
this.handleDrop = this.handleDrop.bind(this);
|
||||
|
||||
log.info('CanvasLayersPanel initialized');
|
||||
}
|
||||
|
||||
createPanelStructure(): HTMLElement {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'layers-panel';
|
||||
this.container.tabIndex = 0; // Umożliwia fokus na panelu
|
||||
this.container.innerHTML = `
|
||||
<div class="layers-panel-header">
|
||||
<span class="layers-panel-title">Layers</span>
|
||||
<div class="layers-panel-controls">
|
||||
<button class="layers-btn" id="delete-layer-btn" title="Delete layer">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layers-container" id="layers-container">
|
||||
<!-- Lista warstw będzie renderowana tutaj -->
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container');
|
||||
|
||||
this.injectStyles();
|
||||
|
||||
// Setup event listeners dla przycisków
|
||||
this.setupControlButtons();
|
||||
|
||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||
this.container.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.deleteSelectedLayers();
|
||||
}
|
||||
});
|
||||
|
||||
log.debug('Panel structure created');
|
||||
return this.container;
|
||||
}
|
||||
|
||||
injectStyles(): void {
|
||||
const styleId = 'layers-panel-styles';
|
||||
if (document.getElementById(styleId)) {
|
||||
return; // Style już istnieją
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = `
|
||||
.layers-panel {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12px;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layers-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.layers-panel-title {
|
||||
font-weight: bold;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layers-panel-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layers-btn {
|
||||
background: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.layers-btn:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
.layers-btn:active {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
.layers-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 4px;
|
||||
margin-bottom: 2px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.layer-row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.layer-row.selected {
|
||||
background: #2d5aa0 !important;
|
||||
box-shadow: inset 0 0 0 1px #4a7bc8;
|
||||
}
|
||||
|
||||
.layer-row.dragging {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
|
||||
.layer-thumbnail {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.layer-thumbnail::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #555 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #555 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #555 75%);
|
||||
background-size: 8px 8px;
|
||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.layer-thumbnail canvas {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name.editing {
|
||||
background: #4a4a4a;
|
||||
border: 1px solid #6a6a6a;
|
||||
outline: none;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.layer-name input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.drag-insertion-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #4a7bc8;
|
||||
border-radius: 1px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.layers-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
`;
|
||||
|
||||
document.head.appendChild(style);
|
||||
log.debug('Styles injected');
|
||||
}
|
||||
|
||||
setupControlButtons(): void {
|
||||
if (!this.container) return;
|
||||
const deleteBtn = this.container.querySelector('#delete-layer-btn');
|
||||
|
||||
deleteBtn?.addEventListener('click', () => {
|
||||
log.info('Delete layer button clicked');
|
||||
this.deleteSelectedLayers();
|
||||
});
|
||||
}
|
||||
|
||||
renderLayers(): void {
|
||||
if (!this.layersContainer) {
|
||||
log.warn('Layers container not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Wyczyść istniejącą zawartość
|
||||
this.layersContainer.innerHTML = '';
|
||||
|
||||
// Usuń linię wstawiania jeśli istnieje
|
||||
this.removeDragInsertionLine();
|
||||
|
||||
// Sortuj warstwy według zIndex (od najwyższej do najniższej)
|
||||
const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => b.zIndex - a.zIndex);
|
||||
|
||||
sortedLayers.forEach((layer: Layer, index: number) => {
|
||||
const layerElement = this.createLayerElement(layer, index);
|
||||
if(this.layersContainer)
|
||||
this.layersContainer.appendChild(layerElement);
|
||||
});
|
||||
|
||||
log.debug(`Rendered ${sortedLayers.length} layers`);
|
||||
}
|
||||
|
||||
createLayerElement(layer: Layer, index: number): HTMLElement {
|
||||
const layerRow = document.createElement('div');
|
||||
layerRow.className = 'layer-row';
|
||||
layerRow.draggable = true;
|
||||
layerRow.dataset.layerIndex = String(index);
|
||||
|
||||
const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
|
||||
if (isSelected) {
|
||||
layerRow.classList.add('selected');
|
||||
}
|
||||
|
||||
// Ustawienie domyślnych właściwości jeśli nie istnieją
|
||||
if (!layer.name) {
|
||||
layer.name = this.ensureUniqueName(`Layer ${layer.zIndex + 1}`, layer);
|
||||
} else {
|
||||
// Sprawdź unikalność istniejącej nazwy (np. przy duplikowaniu)
|
||||
layer.name = this.ensureUniqueName(layer.name, layer);
|
||||
}
|
||||
|
||||
layerRow.innerHTML = `
|
||||
<div class="layer-thumbnail" data-layer-index="${index}"></div>
|
||||
<span class="layer-name" data-layer-index="${index}">${layer.name}</span>
|
||||
`;
|
||||
|
||||
const thumbnailContainer = layerRow.querySelector<HTMLElement>('.layer-thumbnail');
|
||||
if (thumbnailContainer) {
|
||||
this.generateThumbnail(layer, thumbnailContainer);
|
||||
}
|
||||
|
||||
this.setupLayerEventListeners(layerRow, layer, index);
|
||||
|
||||
return layerRow;
|
||||
}
|
||||
|
||||
generateThumbnail(layer: Layer, thumbnailContainer: HTMLElement): void {
|
||||
if (!layer.image) {
|
||||
thumbnailContainer.style.background = '#4a4a4a';
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
canvas.width = 48;
|
||||
canvas.height = 48;
|
||||
|
||||
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
|
||||
const scaledWidth = layer.image.width * scale;
|
||||
const scaledHeight = layer.image.height * scale;
|
||||
|
||||
// Wycentruj obraz
|
||||
const x = (48 - scaledWidth) / 2;
|
||||
const y = (48 - scaledHeight) / 2;
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(layer.image, x, y, scaledWidth, scaledHeight);
|
||||
|
||||
thumbnailContainer.appendChild(canvas);
|
||||
}
|
||||
|
||||
setupLayerEventListeners(layerRow: HTMLElement, layer: Layer, index: number): void {
|
||||
layerRow.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
const nameElement = layerRow.querySelector<HTMLElement>('.layer-name');
|
||||
if (nameElement && nameElement.classList.contains('editing')) {
|
||||
return;
|
||||
}
|
||||
this.handleLayerClick(e, layer, index);
|
||||
});
|
||||
|
||||
layerRow.addEventListener('dblclick', (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const nameElement = layerRow.querySelector<HTMLElement>('.layer-name');
|
||||
if (nameElement) {
|
||||
this.startEditingLayerName(nameElement, layer);
|
||||
}
|
||||
});
|
||||
|
||||
layerRow.addEventListener('dragstart', (e: DragEvent) => this.handleDragStart(e, layer, index));
|
||||
layerRow.addEventListener('dragover', this.handleDragOver.bind(this));
|
||||
layerRow.addEventListener('dragend', this.handleDragEnd.bind(this));
|
||||
layerRow.addEventListener('drop', (e: DragEvent) => this.handleDrop(e, index));
|
||||
}
|
||||
|
||||
handleLayerClick(e: MouseEvent, layer: Layer, index: number): void {
|
||||
const isCtrlPressed = e.ctrlKey || e.metaKey;
|
||||
const isShiftPressed = e.shiftKey;
|
||||
|
||||
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
|
||||
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
|
||||
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
|
||||
|
||||
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
|
||||
this.updateSelectionAppearance();
|
||||
|
||||
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
|
||||
}
|
||||
|
||||
|
||||
startEditingLayerName(nameElement: HTMLElement, layer: Layer): void {
|
||||
const currentName = layer.name;
|
||||
nameElement.classList.add('editing');
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = currentName;
|
||||
input.style.width = '100%';
|
||||
|
||||
nameElement.innerHTML = '';
|
||||
nameElement.appendChild(input);
|
||||
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const finishEditing = () => {
|
||||
let newName = input.value.trim() || `Layer ${layer.zIndex + 1}`;
|
||||
newName = this.ensureUniqueName(newName, layer);
|
||||
layer.name = newName;
|
||||
nameElement.classList.remove('editing');
|
||||
nameElement.textContent = newName;
|
||||
|
||||
this.canvas.saveState();
|
||||
log.info(`Layer renamed to: ${newName}`);
|
||||
};
|
||||
|
||||
input.addEventListener('blur', finishEditing);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
finishEditing();
|
||||
} else if (e.key === 'Escape') {
|
||||
nameElement.classList.remove('editing');
|
||||
nameElement.textContent = currentName;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
ensureUniqueName(proposedName: string, currentLayer: Layer): string {
|
||||
const existingNames = this.canvas.layers
|
||||
.filter((layer: Layer) => layer !== currentLayer)
|
||||
.map((layer: Layer) => layer.name);
|
||||
|
||||
if (!existingNames.includes(proposedName)) {
|
||||
return proposedName;
|
||||
}
|
||||
|
||||
// Sprawdź czy nazwa już ma numerację w nawiasach
|
||||
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
|
||||
let baseName, startNumber;
|
||||
|
||||
if (match) {
|
||||
baseName = match[1].trim();
|
||||
startNumber = parseInt(match[2]) + 1;
|
||||
} else {
|
||||
baseName = proposedName;
|
||||
startNumber = 1;
|
||||
}
|
||||
|
||||
// Znajdź pierwszą dostępną numerację
|
||||
let counter = startNumber;
|
||||
let uniqueName;
|
||||
|
||||
do {
|
||||
uniqueName = `${baseName} (${counter})`;
|
||||
counter++;
|
||||
} while (existingNames.includes(uniqueName));
|
||||
|
||||
return uniqueName;
|
||||
}
|
||||
|
||||
deleteSelectedLayers(): void {
|
||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) {
|
||||
log.debug('No layers selected for deletion');
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Deleting ${this.canvas.canvasSelection.selectedLayers.length} selected layers`);
|
||||
this.canvas.removeSelectedLayers();
|
||||
this.renderLayers();
|
||||
}
|
||||
|
||||
handleDragStart(e: DragEvent, layer: Layer, index: number): void {
|
||||
if (!this.layersContainer || !e.dataTransfer) return;
|
||||
const editingElement = this.layersContainer.querySelector('.layer-name.editing');
|
||||
if (editingElement) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Jeśli przeciągana warstwa nie jest zaznaczona, zaznacz ją
|
||||
if (!this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
this.canvas.updateSelection([layer]);
|
||||
this.renderLayers();
|
||||
}
|
||||
|
||||
this.draggedElements = [...this.canvas.canvasSelection.selectedLayers];
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', '');
|
||||
|
||||
this.layersContainer.querySelectorAll('.layer-row').forEach((row: Element, idx: number) => {
|
||||
const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => b.zIndex - a.zIndex);
|
||||
if (this.draggedElements.includes(sortedLayers[idx])) {
|
||||
row.classList.add('dragging');
|
||||
}
|
||||
});
|
||||
|
||||
log.debug(`Started dragging ${this.draggedElements.length} layers`);
|
||||
}
|
||||
|
||||
handleDragOver(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const layerRow = e.currentTarget as HTMLElement;
|
||||
const rect = layerRow.getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
const isUpperHalf = e.clientY < midpoint;
|
||||
|
||||
this.showDragInsertionLine(layerRow, isUpperHalf);
|
||||
}
|
||||
|
||||
showDragInsertionLine(targetRow: HTMLElement, isUpperHalf: boolean): void {
|
||||
this.removeDragInsertionLine();
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = 'drag-insertion-line';
|
||||
|
||||
if (isUpperHalf) {
|
||||
line.style.top = '-1px';
|
||||
} else {
|
||||
line.style.bottom = '-1px';
|
||||
}
|
||||
|
||||
targetRow.style.position = 'relative';
|
||||
targetRow.appendChild(line);
|
||||
this.dragInsertionLine = line;
|
||||
}
|
||||
|
||||
removeDragInsertionLine(): void {
|
||||
if (this.dragInsertionLine) {
|
||||
this.dragInsertionLine.remove();
|
||||
this.dragInsertionLine = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleDrop(e: DragEvent, targetIndex: number): void {
|
||||
e.preventDefault();
|
||||
this.removeDragInsertionLine();
|
||||
|
||||
if (this.draggedElements.length === 0 || !(e.currentTarget instanceof HTMLElement)) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const midpoint = rect.top + rect.height / 2;
|
||||
const isUpperHalf = e.clientY < midpoint;
|
||||
|
||||
// Oblicz docelowy indeks
|
||||
let insertIndex = targetIndex;
|
||||
if (!isUpperHalf) {
|
||||
insertIndex = targetIndex + 1;
|
||||
}
|
||||
|
||||
// Użyj nowej, centralnej funkcji do przesuwania warstw
|
||||
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
|
||||
|
||||
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
|
||||
}
|
||||
|
||||
handleDragEnd(e: DragEvent): void {
|
||||
this.removeDragInsertionLine();
|
||||
if (!this.layersContainer) return;
|
||||
this.layersContainer.querySelectorAll('.layer-row').forEach((row: Element) => {
|
||||
row.classList.remove('dragging');
|
||||
});
|
||||
|
||||
this.draggedElements = [];
|
||||
}
|
||||
|
||||
|
||||
onLayersChanged(): void {
|
||||
this.renderLayers();
|
||||
}
|
||||
|
||||
updateSelectionAppearance(): void {
|
||||
if (!this.layersContainer) return;
|
||||
const sortedLayers = [...this.canvas.layers].sort((a: Layer, b: Layer) => b.zIndex - a.zIndex);
|
||||
const layerRows = this.layersContainer.querySelectorAll('.layer-row');
|
||||
|
||||
layerRows.forEach((row: Element, index: number) => {
|
||||
const layer = sortedLayers[index];
|
||||
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
row.classList.add('selected');
|
||||
} else {
|
||||
row.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
|
||||
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
|
||||
*/
|
||||
onSelectionChanged(): void {
|
||||
this.updateSelectionAppearance();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.container && this.container.parentNode) {
|
||||
this.container.parentNode.removeChild(this.container);
|
||||
}
|
||||
this.container = null;
|
||||
this.layersContainer = null;
|
||||
this.draggedElements = [];
|
||||
this.removeDragInsertionLine();
|
||||
|
||||
log.info('CanvasLayersPanel destroyed');
|
||||
}
|
||||
}
|
||||
564
src/CanvasMask.ts
Normal file
564
src/CanvasMask.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
// @ts-ignore
|
||||
import {app} from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import {ComfyApp} from "../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import {api} from "../../scripts/api.js";
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js";
|
||||
|
||||
const log = createModuleLogger('CanvasMask');
|
||||
|
||||
export class CanvasMask {
|
||||
canvas: any;
|
||||
editorWasShowing: any;
|
||||
maskEditorCancelled: any;
|
||||
maskTool: any;
|
||||
node: any;
|
||||
pendingMask: any;
|
||||
savedMaskState: any;
|
||||
constructor(canvas: any) {
|
||||
this.canvas = canvas;
|
||||
this.node = canvas.node;
|
||||
this.maskTool = canvas.maskTool;
|
||||
|
||||
this.savedMaskState = null;
|
||||
this.maskEditorCancelled = false;
|
||||
this.pendingMask = null;
|
||||
this.editorWasShowing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uruchamia edytor masek
|
||||
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora
|
||||
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
|
||||
*/
|
||||
async startMaskEditor(predefinedMask: any = null, sendCleanImage = true) {
|
||||
log.info('Starting mask editor', {
|
||||
hasPredefinedMask: !!predefinedMask,
|
||||
sendCleanImage,
|
||||
layersCount: this.canvas.layers.length
|
||||
});
|
||||
|
||||
this.savedMaskState = await this.saveMaskState();
|
||||
this.maskEditorCancelled = false;
|
||||
|
||||
if (!predefinedMask && this.maskTool && this.maskTool.maskCanvas) {
|
||||
try {
|
||||
log.debug('Creating mask from current mask tool');
|
||||
predefinedMask = await this.createMaskFromCurrentMask();
|
||||
log.debug('Mask created from current mask tool successfully');
|
||||
} catch (error) {
|
||||
log.warn("Could not create mask from current mask:", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.pendingMask = predefinedMask;
|
||||
|
||||
let blob;
|
||||
if (sendCleanImage) {
|
||||
log.debug('Getting flattened canvas as blob (clean image)');
|
||||
blob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
|
||||
} else {
|
||||
log.debug('Getting flattened canvas for mask editor (with mask)');
|
||||
blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor();
|
||||
}
|
||||
|
||||
if (!blob) {
|
||||
log.warn("Canvas is empty, cannot open mask editor.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('Canvas blob created successfully, size:', blob.size);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
const filename = `layerforge-mask-edit-${+new Date()}.png`;
|
||||
formData.append("image", blob, filename);
|
||||
formData.append("overwrite", "true");
|
||||
formData.append("type", "temp");
|
||||
|
||||
log.debug('Uploading image to server:', filename);
|
||||
|
||||
const response = await api.fetchApi("/upload/image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload image: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
log.debug('Image uploaded successfully:', data);
|
||||
|
||||
const img = new Image();
|
||||
img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
|
||||
await new Promise((res, rej) => {
|
||||
img.onload = res;
|
||||
img.onerror = rej;
|
||||
});
|
||||
|
||||
this.node.imgs = [img];
|
||||
|
||||
log.info('Opening ComfyUI mask editor');
|
||||
ComfyApp.copyToClipspace(this.node);
|
||||
ComfyApp.clipspace_return_node = this.node;
|
||||
ComfyApp.open_maskeditor();
|
||||
|
||||
this.editorWasShowing = false;
|
||||
this.waitWhileMaskEditing();
|
||||
|
||||
this.setupCancelListener();
|
||||
|
||||
if (predefinedMask) {
|
||||
log.debug('Will apply predefined mask when editor is ready');
|
||||
this.waitForMaskEditorAndApplyMask();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log.error("Error preparing image for mask editor:", error);
|
||||
alert(`Error: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Czeka na otwarcie mask editora i automatycznie nakłada predefiniowaną maskę
|
||||
*/
|
||||
waitForMaskEditorAndApplyMask() {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 100; // Zwiększone do 10 sekund oczekiwania
|
||||
|
||||
const checkEditor = () => {
|
||||
attempts++;
|
||||
|
||||
if (mask_editor_showing(app)) {
|
||||
|
||||
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
|
||||
let editorReady = false;
|
||||
|
||||
if (useNewEditor) {
|
||||
|
||||
const MaskEditorDialog = window.MaskEditorDialog;
|
||||
if (MaskEditorDialog && MaskEditorDialog.instance) {
|
||||
|
||||
try {
|
||||
const messageBroker = MaskEditorDialog.instance.getMessageBroker();
|
||||
if (messageBroker) {
|
||||
editorReady = true;
|
||||
log.info("New mask editor detected as ready via MessageBroker");
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
editorReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!editorReady) {
|
||||
const maskEditorElement = document.getElementById('maskEditor');
|
||||
if (maskEditorElement && maskEditorElement.style.display !== 'none') {
|
||||
|
||||
const canvas = maskEditorElement.querySelector('canvas');
|
||||
if (canvas) {
|
||||
editorReady = true;
|
||||
log.info("New mask editor detected as ready via DOM element");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
const maskCanvas = document.getElementById('maskCanvas') as HTMLCanvasElement;
|
||||
if (maskCanvas) {
|
||||
editorReady = !!(maskCanvas.getContext('2d') && maskCanvas.width > 0 && maskCanvas.height > 0);
|
||||
if (editorReady) {
|
||||
log.info("Old mask editor detected as ready");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editorReady) {
|
||||
|
||||
log.info("Applying mask to editor after", attempts * 100, "ms wait");
|
||||
setTimeout(() => {
|
||||
this.applyMaskToEditor(this.pendingMask);
|
||||
this.pendingMask = null;
|
||||
}, 300);
|
||||
} else if (attempts < maxAttempts) {
|
||||
|
||||
if (attempts % 10 === 0) {
|
||||
log.info("Waiting for mask editor to be ready... attempt", attempts, "/", maxAttempts);
|
||||
}
|
||||
setTimeout(checkEditor, 100);
|
||||
} else {
|
||||
log.warn("Mask editor timeout - editor not ready after", maxAttempts * 100, "ms");
|
||||
|
||||
log.info("Attempting to apply mask anyway...");
|
||||
setTimeout(() => {
|
||||
this.applyMaskToEditor(this.pendingMask);
|
||||
this.pendingMask = null;
|
||||
}, 100);
|
||||
}
|
||||
} else if (attempts < maxAttempts) {
|
||||
|
||||
setTimeout(checkEditor, 100);
|
||||
} else {
|
||||
log.warn("Mask editor timeout - editor not showing after", maxAttempts * 100, "ms");
|
||||
this.pendingMask = null;
|
||||
}
|
||||
};
|
||||
|
||||
checkEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nakłada maskę na otwarty mask editor
|
||||
* @param {Image|HTMLCanvasElement} maskData - Dane maski do nałożenia
|
||||
*/
|
||||
async applyMaskToEditor(maskData: any) {
|
||||
try {
|
||||
|
||||
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
|
||||
|
||||
if (useNewEditor) {
|
||||
|
||||
const MaskEditorDialog = window.MaskEditorDialog;
|
||||
if (MaskEditorDialog && MaskEditorDialog.instance) {
|
||||
|
||||
await this.applyMaskToNewEditor(maskData);
|
||||
} else {
|
||||
log.warn("New editor setting enabled but instance not found, trying old editor");
|
||||
await this.applyMaskToOldEditor(maskData);
|
||||
}
|
||||
} else {
|
||||
|
||||
await this.applyMaskToOldEditor(maskData);
|
||||
}
|
||||
|
||||
log.info("Predefined mask applied to mask editor successfully");
|
||||
} catch (error) {
|
||||
log.error("Failed to apply predefined mask to editor:", error);
|
||||
|
||||
try {
|
||||
log.info("Trying alternative mask application method...");
|
||||
await this.applyMaskToOldEditor(maskData);
|
||||
log.info("Alternative method succeeded");
|
||||
} catch (fallbackError) {
|
||||
log.error("Alternative method also failed:", fallbackError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nakłada maskę na nowy mask editor (przez MessageBroker)
|
||||
* @param {Image|HTMLCanvasElement} maskData - Dane maski
|
||||
*/
|
||||
async applyMaskToNewEditor(maskData: any) {
|
||||
|
||||
const MaskEditorDialog = window.MaskEditorDialog;
|
||||
if (!MaskEditorDialog || !MaskEditorDialog.instance) {
|
||||
throw new Error("New mask editor instance not found");
|
||||
}
|
||||
|
||||
const editor = MaskEditorDialog.instance;
|
||||
const messageBroker = editor.getMessageBroker();
|
||||
|
||||
const maskCanvas = await messageBroker.pull('maskCanvas');
|
||||
const maskCtx = await messageBroker.pull('maskCtx');
|
||||
const maskColor = await messageBroker.pull('getMaskColor');
|
||||
|
||||
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
|
||||
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(processedMask, 0, 0);
|
||||
|
||||
messageBroker.publish('saveState');
|
||||
}
|
||||
|
||||
/**
|
||||
* Nakłada maskę na stary mask editor
|
||||
* @param {Image|HTMLCanvasElement} maskData - Dane maski
|
||||
*/
|
||||
async applyMaskToOldEditor(maskData: any) {
|
||||
|
||||
const maskCanvas = document.getElementById('maskCanvas') as HTMLCanvasElement;
|
||||
if (!maskCanvas) {
|
||||
throw new Error("Old mask editor canvas not found");
|
||||
}
|
||||
|
||||
const maskCtx = maskCanvas.getContext('2d', {willReadFrequently: true});
|
||||
if (!maskCtx) {
|
||||
throw new Error("Old mask editor context not found");
|
||||
}
|
||||
|
||||
const maskColor = {r: 255, g: 255, b: 255};
|
||||
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
|
||||
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(processedMask, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Przetwarza maskę do odpowiedniego formatu dla editora
|
||||
* @param {Image|HTMLCanvasElement} maskData - Oryginalne dane maski
|
||||
* @param {number} targetWidth - Docelowa szerokość
|
||||
* @param {number} targetHeight - Docelowa wysokość
|
||||
* @param {Object} maskColor - Kolor maski {r, g, b}
|
||||
* @returns {HTMLCanvasElement} Przetworzona maska
|
||||
*/async processMaskForEditor(maskData: any, targetWidth: any, targetHeight: any, maskColor: any) {
|
||||
// Współrzędne przesunięcia (pan) widoku edytora
|
||||
const panX = this.maskTool.x;
|
||||
const panY = this.maskTool.y;
|
||||
|
||||
log.info("Processing mask for editor:", {
|
||||
sourceSize: {width: maskData.width, height: maskData.height},
|
||||
targetSize: {width: targetWidth, height: targetHeight},
|
||||
viewportPan: {x: panX, y: panY}
|
||||
});
|
||||
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = targetWidth;
|
||||
tempCanvas.height = targetHeight;
|
||||
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
|
||||
|
||||
const sourceX = -panX;
|
||||
const sourceY = -panY;
|
||||
|
||||
if (tempCtx) {
|
||||
tempCtx.drawImage(
|
||||
maskData, // Źródło: pełna maska z "output area"
|
||||
sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000)
|
||||
sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000)
|
||||
targetWidth, // sWidth: Szerokość wycinanego fragmentu
|
||||
targetHeight, // sHeight: Wysokość wycinanego fragmentu
|
||||
0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0)
|
||||
0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0)
|
||||
targetWidth, // dWidth: Szerokość wklejanego obrazu
|
||||
targetHeight // dHeight: Wysokość wklejanego obrazu
|
||||
);
|
||||
}
|
||||
|
||||
log.info("Mask viewport cropped correctly.", {
|
||||
source: "maskData",
|
||||
cropArea: {x: sourceX, y: sourceY, width: targetWidth, height: targetHeight}
|
||||
});
|
||||
|
||||
// Reszta kodu (zmiana koloru) pozostaje bez zmian
|
||||
if (tempCtx) {
|
||||
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i + 3];
|
||||
if (alpha > 0) {
|
||||
data[i] = maskColor.r;
|
||||
data[i + 1] = maskColor.g;
|
||||
data[i + 2] = maskColor.b;
|
||||
}
|
||||
}
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
log.info("Mask processing completed - color applied.");
|
||||
return tempCanvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy obiekt Image z obecnej maski canvas
|
||||
* @returns {Promise<Image>} Promise zwracający obiekt Image z maską
|
||||
*/
|
||||
async createMaskFromCurrentMask() {
|
||||
if (!this.maskTool || !this.maskTool.maskCanvas) {
|
||||
throw new Error("No mask canvas available");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const maskImage = new Image();
|
||||
maskImage.onload = () => resolve(maskImage);
|
||||
maskImage.onerror = reject;
|
||||
maskImage.src = this.maskTool.maskCanvas.toDataURL();
|
||||
});
|
||||
}
|
||||
|
||||
waitWhileMaskEditing() {
|
||||
if (mask_editor_showing(app)) {
|
||||
this.editorWasShowing = true;
|
||||
}
|
||||
|
||||
if (!mask_editor_showing(app) && this.editorWasShowing) {
|
||||
this.editorWasShowing = false;
|
||||
setTimeout(() => this.handleMaskEditorClose(), 100);
|
||||
} else {
|
||||
setTimeout(this.waitWhileMaskEditing.bind(this), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisuje obecny stan maski przed otwarciem editora
|
||||
* @returns {Object} Zapisany stan maski
|
||||
*/
|
||||
async saveMaskState() {
|
||||
if (!this.maskTool || !this.maskTool.maskCanvas) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maskCanvas = this.maskTool.maskCanvas;
|
||||
const savedCanvas = document.createElement('canvas');
|
||||
savedCanvas.width = maskCanvas.width;
|
||||
savedCanvas.height = maskCanvas.height;
|
||||
const savedCtx = savedCanvas.getContext('2d', {willReadFrequently: true});
|
||||
if (savedCtx) {
|
||||
savedCtx.drawImage(maskCanvas, 0, 0);
|
||||
}
|
||||
|
||||
return {
|
||||
maskData: savedCanvas,
|
||||
maskPosition: {
|
||||
x: this.maskTool.x,
|
||||
y: this.maskTool.y
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Przywraca zapisany stan maski
|
||||
* @param {Object} savedState - Zapisany stan maski
|
||||
*/
|
||||
async restoreMaskState(savedState: any) {
|
||||
if (!savedState || !this.maskTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (savedState.maskData) {
|
||||
const maskCtx = this.maskTool.maskCtx;
|
||||
maskCtx.clearRect(0, 0, this.maskTool.maskCanvas.width, this.maskTool.maskCanvas.height);
|
||||
maskCtx.drawImage(savedState.maskData, 0, 0);
|
||||
}
|
||||
|
||||
if (savedState.maskPosition) {
|
||||
this.maskTool.x = savedState.maskPosition.x;
|
||||
this.maskTool.y = savedState.maskPosition.y;
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
log.info("Mask state restored after cancel");
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfiguruje nasłuchiwanie na przycisk Cancel w mask editorze
|
||||
*/
|
||||
setupCancelListener() {
|
||||
mask_editor_listen_for_cancel(app, () => {
|
||||
log.info("Mask editor cancel button clicked");
|
||||
this.maskEditorCancelled = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdza czy mask editor został anulowany i obsługuje to odpowiednio
|
||||
*/
|
||||
async handleMaskEditorClose() {
|
||||
log.info("Handling mask editor close");
|
||||
log.debug("Node object after mask editor close:", this.node);
|
||||
|
||||
if (this.maskEditorCancelled) {
|
||||
log.info("Mask editor was cancelled - restoring original mask state");
|
||||
|
||||
if (this.savedMaskState) {
|
||||
await this.restoreMaskState(this.savedMaskState);
|
||||
}
|
||||
|
||||
this.maskEditorCancelled = false;
|
||||
this.savedMaskState = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.node.imgs || this.node.imgs.length === 0 || !this.node.imgs[0].src) {
|
||||
log.warn("Mask editor was closed without a result.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("Processing mask editor result, image source:", this.node.imgs[0].src.substring(0, 100) + '...');
|
||||
|
||||
const resultImage = new Image();
|
||||
resultImage.src = this.node.imgs[0].src;
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
resultImage.onload = resolve;
|
||||
resultImage.onerror = reject;
|
||||
});
|
||||
|
||||
log.debug("Result image loaded successfully", {
|
||||
width: resultImage.width,
|
||||
height: resultImage.height
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Failed to load image from mask editor.", error);
|
||||
this.node.imgs = [];
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("Creating temporary canvas for mask processing");
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = this.canvas.width;
|
||||
tempCanvas.height = this.canvas.height;
|
||||
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
|
||||
|
||||
if (tempCtx) {
|
||||
tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
log.debug("Processing image data to create mask");
|
||||
const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const originalAlpha = data[i + 3];
|
||||
data[i] = 255;
|
||||
data[i + 1] = 255;
|
||||
data[i + 2] = 255;
|
||||
data[i + 3] = 255 - originalAlpha;
|
||||
}
|
||||
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
log.debug("Converting processed mask to image");
|
||||
const maskAsImage = new Image();
|
||||
maskAsImage.src = tempCanvas.toDataURL();
|
||||
await new Promise(resolve => maskAsImage.onload = resolve);
|
||||
|
||||
const maskCtx = this.maskTool.maskCtx;
|
||||
const destX = -this.maskTool.x;
|
||||
const destY = -this.maskTool.y;
|
||||
|
||||
log.debug("Applying mask to canvas", {destX, destY});
|
||||
|
||||
maskCtx.globalCompositeOperation = 'source-over';
|
||||
maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height);
|
||||
|
||||
maskCtx.drawImage(maskAsImage, destX, destY);
|
||||
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
|
||||
log.debug("Creating new preview image");
|
||||
const new_preview = new Image();
|
||||
|
||||
const blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
|
||||
if (blob) {
|
||||
new_preview.src = URL.createObjectURL(blob);
|
||||
await new Promise(r => new_preview.onload = r);
|
||||
this.node.imgs = [new_preview];
|
||||
log.debug("New preview image created successfully");
|
||||
} else {
|
||||
this.node.imgs = [];
|
||||
log.warn("Failed to create preview blob");
|
||||
}
|
||||
|
||||
this.canvas.render();
|
||||
|
||||
this.savedMaskState = null;
|
||||
log.info("Mask editor result processed successfully");
|
||||
}
|
||||
}
|
||||
376
src/CanvasRenderer.ts
Normal file
376
src/CanvasRenderer.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
const log = createModuleLogger('CanvasRenderer');
|
||||
|
||||
export class CanvasRenderer {
|
||||
canvas: any;
|
||||
isDirty: any;
|
||||
lastRenderTime: any;
|
||||
renderAnimationFrame: any;
|
||||
renderInterval: any;
|
||||
constructor(canvas: any) {
|
||||
this.canvas = canvas;
|
||||
this.renderAnimationFrame = null;
|
||||
this.lastRenderTime = 0;
|
||||
this.renderInterval = 1000 / 60;
|
||||
this.isDirty = false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.renderAnimationFrame) {
|
||||
this.isDirty = true;
|
||||
return;
|
||||
}
|
||||
this.renderAnimationFrame = requestAnimationFrame(() => {
|
||||
const now = performance.now();
|
||||
if (now - this.lastRenderTime >= this.renderInterval) {
|
||||
this.lastRenderTime = now;
|
||||
this.actualRender();
|
||||
this.isDirty = false;
|
||||
}
|
||||
|
||||
if (this.isDirty) {
|
||||
this.renderAnimationFrame = null;
|
||||
this.render();
|
||||
} else {
|
||||
this.renderAnimationFrame = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
actualRender() {
|
||||
if (this.canvas.offscreenCanvas.width !== this.canvas.canvas.clientWidth ||
|
||||
this.canvas.offscreenCanvas.height !== this.canvas.canvas.clientHeight) {
|
||||
const newWidth = Math.max(1, this.canvas.canvas.clientWidth);
|
||||
const newHeight = Math.max(1, this.canvas.canvas.clientHeight);
|
||||
this.canvas.offscreenCanvas.width = newWidth;
|
||||
this.canvas.offscreenCanvas.height = newHeight;
|
||||
}
|
||||
|
||||
const ctx = this.canvas.offscreenCtx;
|
||||
|
||||
ctx.fillStyle = '#606060';
|
||||
ctx.fillRect(0, 0, this.canvas.offscreenCanvas.width, this.canvas.offscreenCanvas.height);
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
|
||||
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
|
||||
|
||||
this.drawGrid(ctx);
|
||||
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||
sortedLayers.forEach(layer => {
|
||||
if (!layer.image) return;
|
||||
ctx.save();
|
||||
const currentTransform = ctx.getTransform();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||
ctx.setTransform(currentTransform);
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.rotate(layer.rotation * Math.PI / 180);
|
||||
|
||||
const scaleH = layer.flipH ? -1 : 1;
|
||||
const scaleV = layer.flipV ? -1 : 1;
|
||||
if (layer.flipH || layer.flipV) {
|
||||
ctx.scale(scaleH, scaleV);
|
||||
}
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(
|
||||
layer.image, -layer.width / 2, -layer.height / 2,
|
||||
layer.width,
|
||||
layer.height
|
||||
);
|
||||
if (layer.mask) {
|
||||
}
|
||||
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
|
||||
this.drawSelectionFrame(ctx, layer);
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
this.drawCanvasOutline(ctx);
|
||||
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
|
||||
const maskImage = this.canvas.maskTool.getMask();
|
||||
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
|
||||
|
||||
ctx.save();
|
||||
|
||||
if (this.canvas.maskTool.isActive) {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 0.5;
|
||||
} else {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y);
|
||||
|
||||
ctx.globalAlpha = 1.0;
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
this.renderInteractionElements(ctx);
|
||||
this.renderLayerInfo(ctx);
|
||||
|
||||
ctx.restore();
|
||||
|
||||
if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width ||
|
||||
this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) {
|
||||
this.canvas.canvas.width = this.canvas.offscreenCanvas.width;
|
||||
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
|
||||
}
|
||||
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
|
||||
|
||||
// Update Batch Preview UI positions
|
||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||
this.canvas.batchPreviewManagers.forEach((manager: any) => {
|
||||
manager.updateScreenPosition(this.canvas.viewport);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
renderInteractionElements(ctx: any) {
|
||||
const interaction = this.canvas.interaction;
|
||||
|
||||
if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) {
|
||||
const rect = interaction.canvasResizeRect;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(0, 255, 0, 0.8)';
|
||||
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]);
|
||||
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
||||
const textWorldX = rect.x + rect.width / 2;
|
||||
const textWorldY = rect.y + rect.height + (20 / this.canvas.viewport.zoom);
|
||||
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
ctx.font = "14px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
const textMetrics = ctx.measureText(text);
|
||||
const bgWidth = textMetrics.width + 10;
|
||||
const bgHeight = 22;
|
||||
ctx.fillStyle = "rgba(0, 128, 0, 0.7)";
|
||||
ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight);
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillText(text, screenX, screenY);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) {
|
||||
const rect = interaction.canvasMoveRect;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)';
|
||||
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]);
|
||||
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
|
||||
const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`;
|
||||
const textWorldX = rect.x + rect.width / 2;
|
||||
const textWorldY = rect.y - (20 / this.canvas.viewport.zoom);
|
||||
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
ctx.font = "14px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
const textMetrics = ctx.measureText(text);
|
||||
const bgWidth = textMetrics.width + 10;
|
||||
const bgHeight = 22;
|
||||
ctx.fillStyle = "rgba(0, 100, 170, 0.7)";
|
||||
ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight);
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillText(text, screenX, screenY);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
renderLayerInfo(ctx: any) {
|
||||
if (this.canvas.canvasSelection.selectedLayer) {
|
||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: any) => {
|
||||
if (!layer.image) return;
|
||||
|
||||
const layerIndex = this.canvas.layers.indexOf(layer);
|
||||
const currentWidth = Math.round(layer.width);
|
||||
const currentHeight = Math.round(layer.height);
|
||||
const rotation = Math.round(layer.rotation % 360);
|
||||
let text = `${currentWidth}x${currentHeight} | ${rotation}° | Layer #${layerIndex + 1}`;
|
||||
if (layer.originalWidth && layer.originalHeight) {
|
||||
text += `\nOriginal: ${layer.originalWidth}x${layer.originalHeight}`;
|
||||
}
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const rad = layer.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
const halfW = layer.width / 2;
|
||||
const halfH = layer.height / 2;
|
||||
|
||||
const localCorners = [
|
||||
{x: -halfW, y: -halfH},
|
||||
{x: halfW, y: -halfH},
|
||||
{x: halfW, y: halfH},
|
||||
{x: -halfW, y: halfH}
|
||||
];
|
||||
const worldCorners = localCorners.map(p => ({
|
||||
x: centerX + p.x * cos - p.y * sin,
|
||||
y: centerY + p.x * sin + p.y * cos
|
||||
}));
|
||||
let minX = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
worldCorners.forEach(p => {
|
||||
minX = Math.min(minX, p.x);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
});
|
||||
const padding = 20 / this.canvas.viewport.zoom;
|
||||
const textWorldX = (minX + maxX) / 2;
|
||||
const textWorldY = maxY + padding;
|
||||
ctx.save();
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
|
||||
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
|
||||
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
|
||||
|
||||
ctx.font = "14px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
const lines = text.split('\n');
|
||||
const textMetrics = lines.map(line => ctx.measureText(line));
|
||||
const textBgWidth = Math.max(...textMetrics.map(m => m.width)) + 10;
|
||||
const lineHeight = 18;
|
||||
const textBgHeight = lines.length * lineHeight + 4;
|
||||
|
||||
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
|
||||
ctx.fillRect(screenX - textBgWidth / 2, screenY - textBgHeight / 2, textBgWidth, textBgHeight);
|
||||
|
||||
ctx.fillStyle = "white";
|
||||
lines.forEach((line, index) => {
|
||||
const yPos = screenY - (textBgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2;
|
||||
ctx.fillText(line, screenX, yPos);
|
||||
});
|
||||
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drawGrid(ctx: any) {
|
||||
const gridSize = 64;
|
||||
const lineWidth = 0.5 / this.canvas.viewport.zoom;
|
||||
|
||||
const viewLeft = this.canvas.viewport.x;
|
||||
const viewTop = this.canvas.viewport.y;
|
||||
const viewRight = this.canvas.viewport.x + this.canvas.offscreenCanvas.width / this.canvas.viewport.zoom;
|
||||
const viewBottom = this.canvas.viewport.y + this.canvas.offscreenCanvas.height / this.canvas.viewport.zoom;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#707070';
|
||||
ctx.lineWidth = lineWidth;
|
||||
|
||||
for (let x = Math.floor(viewLeft / gridSize) * gridSize; x < viewRight; x += gridSize) {
|
||||
ctx.moveTo(x, viewTop);
|
||||
ctx.lineTo(x, viewBottom);
|
||||
}
|
||||
|
||||
for (let y = Math.floor(viewTop / gridSize) * gridSize; y < viewBottom; y += gridSize) {
|
||||
ctx.moveTo(viewLeft, y);
|
||||
ctx.lineTo(viewRight, y);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
drawCanvasOutline(ctx: any) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]);
|
||||
|
||||
ctx.rect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
drawSelectionFrame(ctx: any, layer: any) {
|
||||
const lineWidth = 2 / this.canvas.viewport.zoom;
|
||||
const handleRadius = 5 / this.canvas.viewport.zoom;
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.beginPath();
|
||||
ctx.rect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -layer.height / 2);
|
||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
||||
ctx.stroke();
|
||||
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.strokeStyle = '#000000';
|
||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||
|
||||
for (const key in handles) {
|
||||
const point = handles[key];
|
||||
ctx.beginPath();
|
||||
const localX = point.x - (layer.x + layer.width / 2);
|
||||
const localY = point.y - (layer.y + layer.height / 2);
|
||||
|
||||
const rad = -layer.rotation * Math.PI / 180;
|
||||
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
||||
|
||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
drawPendingGenerationAreas(ctx: any) {
|
||||
const areasToDraw = [];
|
||||
|
||||
// 1. Get areas from active managers
|
||||
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
|
||||
this.canvas.batchPreviewManagers.forEach((manager: any) => {
|
||||
if (manager.generationArea) {
|
||||
areasToDraw.push(manager.generationArea);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Get the area from the pending context (if it exists)
|
||||
if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) {
|
||||
areasToDraw.push(this.canvas.pendingBatchContext.outputArea);
|
||||
}
|
||||
|
||||
if (areasToDraw.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Draw all collected areas
|
||||
areasToDraw.forEach(area => {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = 'rgba(0, 150, 255, 0.9)'; // Blue color
|
||||
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
|
||||
ctx.setLineDash([12 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]);
|
||||
ctx.strokeRect(area.x, area.y, area.width, area.height);
|
||||
ctx.restore();
|
||||
});
|
||||
}
|
||||
}
|
||||
170
src/CanvasSelection.ts
Normal file
170
src/CanvasSelection.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
||||
|
||||
const log = createModuleLogger('CanvasSelection');
|
||||
|
||||
export class CanvasSelection {
|
||||
canvas: any;
|
||||
onSelectionChange: any;
|
||||
selectedLayer: any;
|
||||
selectedLayers: any;
|
||||
constructor(canvas: any) {
|
||||
this.canvas = canvas;
|
||||
this.selectedLayers = [];
|
||||
this.selectedLayer = null;
|
||||
this.onSelectionChange = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
|
||||
*/
|
||||
duplicateSelectedLayers() {
|
||||
if (this.selectedLayers.length === 0) return [];
|
||||
|
||||
const newLayers: any = [];
|
||||
const sortedLayers = [...this.selectedLayers].sort((a,b) => a.zIndex - b.zIndex);
|
||||
|
||||
sortedLayers.forEach(layer => {
|
||||
const newLayer = {
|
||||
...layer,
|
||||
id: `layer_${+new Date()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu
|
||||
};
|
||||
this.canvas.layers.push(newLayer);
|
||||
newLayers.push(newLayer);
|
||||
});
|
||||
|
||||
// Aktualizuj zaznaczenie, co powiadomi panel (ale nie renderuje go całego)
|
||||
this.updateSelection(newLayers);
|
||||
|
||||
// Powiadom panel o zmianie struktury, aby się przerysował
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
|
||||
log.info(`Duplicated ${newLayers.length} layers (in-memory).`);
|
||||
return newLayers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
|
||||
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
|
||||
* @param {Array} newSelection - Nowa lista zaznaczonych warstw
|
||||
*/
|
||||
updateSelection(newSelection: any) {
|
||||
const previousSelection = this.selectedLayers.length;
|
||||
this.selectedLayers = newSelection || [];
|
||||
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
|
||||
|
||||
// Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli
|
||||
const hasChanged = previousSelection !== this.selectedLayers.length ||
|
||||
this.selectedLayers.some((layer: any, i: any) => this.selectedLayers[i] !== (newSelection || [])[i]);
|
||||
|
||||
if (!hasChanged && previousSelection > 0) {
|
||||
// return; // Zablokowane na razie, może powodować problemy
|
||||
}
|
||||
|
||||
log.debug('Selection updated', {
|
||||
previousCount: previousSelection,
|
||||
newCount: this.selectedLayers.length,
|
||||
selectedLayerIds: this.selectedLayers.map((l: any) => l.id || 'unknown')
|
||||
});
|
||||
|
||||
// 1. Zrenderuj ponownie canvas, aby pokazać nowe kontrolki transformacji
|
||||
this.canvas.render();
|
||||
|
||||
// 2. Powiadom inne części aplikacji (jeśli są)
|
||||
if (this.onSelectionChange) {
|
||||
this.onSelectionChange();
|
||||
}
|
||||
|
||||
// 3. Powiadom panel warstw, aby zaktualizował swój wygląd
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logika aktualizacji zaznaczenia, wywoływana przez panel warstw.
|
||||
*/
|
||||
updateSelectionLogic(layer: any, isCtrlPressed: any, isShiftPressed: any, index: any) {
|
||||
let newSelection = [...this.selectedLayers];
|
||||
let selectionChanged = false;
|
||||
|
||||
if (isShiftPressed && this.canvas.canvasLayersPanel.lastSelectedIndex !== -1) {
|
||||
const sortedLayers = [...this.canvas.layers].sort((a, b) => b.zIndex - a.zIndex);
|
||||
const startIndex = Math.min(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
|
||||
const endIndex = Math.max(this.canvas.canvasLayersPanel.lastSelectedIndex, index);
|
||||
|
||||
newSelection = [];
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
if (sortedLayers[i]) {
|
||||
newSelection.push(sortedLayers[i]);
|
||||
}
|
||||
}
|
||||
selectionChanged = true;
|
||||
} else if (isCtrlPressed) {
|
||||
const layerIndex = newSelection.indexOf(layer);
|
||||
if (layerIndex === -1) {
|
||||
newSelection.push(layer);
|
||||
} else {
|
||||
newSelection.splice(layerIndex, 1);
|
||||
}
|
||||
this.canvas.canvasLayersPanel.lastSelectedIndex = index;
|
||||
selectionChanged = true;
|
||||
} else {
|
||||
// Jeśli kliknięta warstwa nie jest częścią obecnego zaznaczenia,
|
||||
// wyczyść zaznaczenie i zaznacz tylko ją.
|
||||
if (!this.selectedLayers.includes(layer)) {
|
||||
newSelection = [layer];
|
||||
selectionChanged = true;
|
||||
}
|
||||
// Jeśli kliknięta warstwa JEST już zaznaczona (potencjalnie z innymi),
|
||||
// NIE rób nic, aby umożliwić przeciąganie całej grupy.
|
||||
this.canvas.canvasLayersPanel.lastSelectedIndex = index;
|
||||
}
|
||||
|
||||
// Aktualizuj zaznaczenie tylko jeśli faktycznie się zmieniło
|
||||
if (selectionChanged) {
|
||||
this.updateSelection(newSelection);
|
||||
}
|
||||
}
|
||||
|
||||
removeSelectedLayers() {
|
||||
if (this.selectedLayers.length > 0) {
|
||||
log.info('Removing selected layers', {
|
||||
layersToRemove: this.selectedLayers.length,
|
||||
totalLayers: this.canvas.layers.length
|
||||
});
|
||||
|
||||
this.canvas.saveState();
|
||||
this.canvas.layers = this.canvas.layers.filter((l: any) => !this.selectedLayers.includes(l));
|
||||
|
||||
this.updateSelection([]);
|
||||
|
||||
this.canvas.render();
|
||||
this.canvas.saveState();
|
||||
|
||||
if (this.canvas.canvasLayersPanel) {
|
||||
this.canvas.canvasLayersPanel.onLayersChanged();
|
||||
}
|
||||
|
||||
log.debug('Layers removed successfully, remaining layers:', this.canvas.layers.length);
|
||||
} else {
|
||||
log.debug('No layers selected for removal');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje zaznaczenie po operacji historii
|
||||
*/
|
||||
updateSelectionAfterHistory() {
|
||||
const newSelectedLayers: any = [];
|
||||
if (this.selectedLayers) {
|
||||
this.selectedLayers.forEach((sl: any) => {
|
||||
const found = this.canvas.layers.find((l: any) => l.id === sl.id);
|
||||
if (found) newSelectedLayers.push(found);
|
||||
});
|
||||
}
|
||||
this.updateSelection(newSelectedLayers);
|
||||
}
|
||||
}
|
||||
498
src/CanvasState.ts
Normal file
498
src/CanvasState.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import {getCanvasState, setCanvasState, saveImage, getImage} from "./db.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import {generateUUID, cloneLayers, getStateSignature, debounce} from "./utils/CommonUtils.js";
|
||||
import {withErrorHandling} from "./ErrorHandler.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer, ComfyNode } from './types';
|
||||
|
||||
const log = createModuleLogger('CanvasState');
|
||||
|
||||
interface HistoryInfo {
|
||||
undoCount: number;
|
||||
redoCount: number;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
historyLimit: number;
|
||||
}
|
||||
|
||||
export class CanvasState {
|
||||
private _debouncedSave: (() => void) | null;
|
||||
private _loadInProgress: Promise<boolean> | null;
|
||||
private canvas: Canvas & { node: ComfyNode, layers: Layer[] };
|
||||
private historyLimit: number;
|
||||
private lastSavedStateSignature: string | null;
|
||||
public layersRedoStack: Layer[][];
|
||||
public layersUndoStack: Layer[][];
|
||||
public maskRedoStack: HTMLCanvasElement[];
|
||||
public maskUndoStack: HTMLCanvasElement[];
|
||||
private saveTimeout: number | null;
|
||||
private stateSaverWorker: Worker | null;
|
||||
|
||||
constructor(canvas: Canvas & { node: ComfyNode, layers: Layer[] }) {
|
||||
this.canvas = canvas;
|
||||
this.layersUndoStack = [];
|
||||
this.layersRedoStack = [];
|
||||
this.maskUndoStack = [];
|
||||
this.maskRedoStack = [];
|
||||
this.historyLimit = 100;
|
||||
this.saveTimeout = null;
|
||||
this.lastSavedStateSignature = null;
|
||||
this._loadInProgress = null;
|
||||
this._debouncedSave = null;
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' });
|
||||
log.info("State saver worker initialized successfully.");
|
||||
|
||||
this.stateSaverWorker.onmessage = (e: MessageEvent) => {
|
||||
log.info("Message from state saver worker:", e.data);
|
||||
};
|
||||
this.stateSaverWorker.onerror = (e: ErrorEvent) => {
|
||||
log.error("Error in state saver worker:", e.message, e.filename, e.lineno);
|
||||
this.stateSaverWorker = null;
|
||||
};
|
||||
} catch (e) {
|
||||
log.error("Failed to initialize state saver worker:", e);
|
||||
this.stateSaverWorker = null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadStateFromDB(): Promise<boolean> {
|
||||
if (this._loadInProgress) {
|
||||
log.warn("Load already in progress, waiting...");
|
||||
return this._loadInProgress;
|
||||
}
|
||||
|
||||
log.info("Attempting to load state from IndexedDB for node:", this.canvas.node.id);
|
||||
const loadPromise = this._performLoad();
|
||||
this._loadInProgress = loadPromise;
|
||||
|
||||
try {
|
||||
const result = await loadPromise;
|
||||
this._loadInProgress = null;
|
||||
return result;
|
||||
} catch (error) {
|
||||
this._loadInProgress = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async _performLoad(): Promise<boolean> {
|
||||
try {
|
||||
if (!this.canvas.node.id) {
|
||||
log.error("Node ID is not available for loading state from DB.");
|
||||
return false;
|
||||
}
|
||||
const savedState = await getCanvasState(String(this.canvas.node.id));
|
||||
if (!savedState) {
|
||||
log.info("No saved state found in IndexedDB for node:", this.canvas.node.id);
|
||||
return false;
|
||||
}
|
||||
log.info("Found saved state in IndexedDB.");
|
||||
this.canvas.width = savedState.width || 512;
|
||||
this.canvas.height = savedState.height || 512;
|
||||
this.canvas.viewport = savedState.viewport || {
|
||||
x: -(this.canvas.width / 4),
|
||||
y: -(this.canvas.height / 4),
|
||||
zoom: 0.8
|
||||
};
|
||||
|
||||
this.canvas.canvasLayers.updateOutputAreaSize(this.canvas.width, this.canvas.height, false);
|
||||
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
|
||||
const loadedLayers = await this._loadLayers(savedState.layers);
|
||||
this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null);
|
||||
log.info(`Loaded ${this.canvas.layers.length} layers.`);
|
||||
|
||||
if (this.canvas.layers.length === 0) {
|
||||
log.warn("No valid layers loaded, state may be corrupted.");
|
||||
return false;
|
||||
}
|
||||
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error("Error during state load:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje warstwy z zapisanego stanu
|
||||
* @param {any[]} layersData - Dane warstw do załadowania
|
||||
* @returns {Promise<(Layer | null)[]>} Załadowane warstwy
|
||||
*/
|
||||
async _loadLayers(layersData: any[]): Promise<(Layer | null)[]> {
|
||||
const imagePromises = layersData.map((layerData: any, index: number) =>
|
||||
this._loadSingleLayer(layerData, index)
|
||||
);
|
||||
return Promise.all(imagePromises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje pojedynczą warstwę
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @returns {Promise<Layer | null>} Załadowana warstwa lub null
|
||||
*/
|
||||
async _loadSingleLayer(layerData: Layer, index: number): Promise<Layer | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (layerData.imageId) {
|
||||
this._loadLayerFromImageId(layerData, index, resolve);
|
||||
} else if ((layerData as any).imageSrc) {
|
||||
this._convertLegacyLayer(layerData, index, resolve);
|
||||
} else {
|
||||
log.error(`Layer ${index}: No imageId or imageSrc found, skipping layer.`);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ładuje warstwę z imageId
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
||||
*/
|
||||
_loadLayerFromImageId(layerData: Layer, index: number, resolve: (value: Layer | null) => void): void {
|
||||
log.debug(`Layer ${index}: Loading image with id: ${layerData.imageId}`);
|
||||
|
||||
if (this.canvas.imageCache.has(layerData.imageId)) {
|
||||
log.debug(`Layer ${index}: Image found in cache.`);
|
||||
const imageData = this.canvas.imageCache.get(layerData.imageId);
|
||||
if (imageData) {
|
||||
const imageSrc = URL.createObjectURL(new Blob([imageData.data]));
|
||||
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
} else {
|
||||
getImage(layerData.imageId)
|
||||
.then(imageSrc => {
|
||||
if (imageSrc) {
|
||||
log.debug(`Layer ${index}: Loading image from data:URL...`);
|
||||
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
||||
} else {
|
||||
log.error(`Layer ${index}: Image not found in IndexedDB.`);
|
||||
resolve(null);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
log.error(`Layer ${index}: Error loading image from IndexedDB:`, err);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje starą warstwę z imageSrc na nowy format
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
||||
*/
|
||||
_convertLegacyLayer(layerData: Layer, index: number, resolve: (value: Layer | null) => void): void {
|
||||
log.info(`Layer ${index}: Found imageSrc, converting to new format with imageId.`);
|
||||
const imageId = generateUUID();
|
||||
|
||||
saveImage(imageId, (layerData as any).imageSrc)
|
||||
.then(() => {
|
||||
log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`);
|
||||
const newLayerData = {...layerData, imageId};
|
||||
delete (newLayerData as any).imageSrc;
|
||||
this._createLayerFromSrc(newLayerData, (layerData as any).imageSrc, index, resolve);
|
||||
})
|
||||
.catch(err => {
|
||||
log.error(`Layer ${index}: Error saving image to IndexedDB:`, err);
|
||||
resolve(null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy warstwę z src obrazu
|
||||
* @param {any} layerData - Dane warstwy
|
||||
* @param {string} imageSrc - Źródło obrazu
|
||||
* @param {number} index - Indeks warstwy
|
||||
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
||||
*/
|
||||
_createLayerFromSrc(layerData: Layer, imageSrc: string | ImageBitmap, index: number, resolve: (value: Layer | null) => void): void {
|
||||
if (typeof imageSrc === 'string') {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully.`);
|
||||
const newLayer: Layer = {...layerData, image: img};
|
||||
resolve(newLayer);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.error(`Layer ${index}: Failed to load image from src.`);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = imageSrc;
|
||||
} else {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imageSrc.width;
|
||||
canvas.height = imageSrc.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(imageSrc, 0, 0);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
||||
const newLayer: Layer = {...layerData, image: img};
|
||||
resolve(newLayer);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.error(`Layer ${index}: Failed to load image from ImageBitmap.`);
|
||||
resolve(null);
|
||||
};
|
||||
img.src = canvas.toDataURL();
|
||||
} else {
|
||||
log.error(`Layer ${index}: Failed to get 2d context from canvas.`);
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveStateToDB(): Promise<void> {
|
||||
if (!this.canvas.node.id) {
|
||||
log.error("Node ID is not available for saving state to DB.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Preparing state to be sent to worker...");
|
||||
const layers = await this._prepareLayers();
|
||||
const state = {
|
||||
layers: layers.filter(layer => layer !== null),
|
||||
viewport: this.canvas.viewport,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
};
|
||||
|
||||
if (state.layers.length === 0) {
|
||||
log.warn("No valid layers to save, skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.stateSaverWorker) {
|
||||
log.info("Posting state to worker for background saving.");
|
||||
this.stateSaverWorker.postMessage({
|
||||
nodeId: String(this.canvas.node.id),
|
||||
state: state
|
||||
});
|
||||
this.canvas.render();
|
||||
} else {
|
||||
log.warn("State saver worker not available. Saving on main thread.");
|
||||
await setCanvasState(String(this.canvas.node.id), state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Przygotowuje warstwy do zapisu
|
||||
* @returns {Promise<(Omit<Layer, 'image'> & { imageId: string })[]>} Przygotowane warstwy
|
||||
*/
|
||||
async _prepareLayers(): Promise<(Omit<Layer, 'image'> & { imageId: string })[]> {
|
||||
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer: Layer, index: number) => {
|
||||
const newLayer: Omit<Layer, 'image'> & { imageId: string } = { ...layer, imageId: layer.imageId || '' };
|
||||
delete (newLayer as any).image;
|
||||
|
||||
if (layer.image instanceof HTMLImageElement) {
|
||||
if (layer.imageId) {
|
||||
newLayer.imageId = layer.imageId;
|
||||
} else {
|
||||
log.debug(`Layer ${index}: No imageId found, generating new one and saving image.`);
|
||||
newLayer.imageId = generateUUID();
|
||||
const imageBitmap = await createImageBitmap(layer.image);
|
||||
await saveImage(newLayer.imageId, imageBitmap);
|
||||
}
|
||||
} else if (!layer.imageId) {
|
||||
log.error(`Layer ${index}: No image or imageId found, skipping layer.`);
|
||||
return null;
|
||||
}
|
||||
return newLayer;
|
||||
}));
|
||||
return preparedLayers.filter((layer): layer is Omit<Layer, 'image'> & { imageId: string } => layer !== null);
|
||||
}
|
||||
|
||||
saveState(replaceLast = false): void {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.saveMaskState(replaceLast);
|
||||
} else {
|
||||
this.saveLayersState(replaceLast);
|
||||
}
|
||||
}
|
||||
|
||||
saveLayersState(replaceLast = false): void {
|
||||
if (replaceLast && this.layersUndoStack.length > 0) {
|
||||
this.layersUndoStack.pop();
|
||||
}
|
||||
|
||||
const currentState = cloneLayers(this.canvas.layers);
|
||||
const currentStateSignature = getStateSignature(currentState);
|
||||
|
||||
if (this.layersUndoStack.length > 0) {
|
||||
const lastState = this.layersUndoStack[this.layersUndoStack.length - 1];
|
||||
if (getStateSignature(lastState) === currentStateSignature) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.layersUndoStack.push(currentState);
|
||||
|
||||
if (this.layersUndoStack.length > this.historyLimit) {
|
||||
this.layersUndoStack.shift();
|
||||
}
|
||||
this.layersRedoStack = [];
|
||||
this.canvas.updateHistoryButtons();
|
||||
|
||||
if (!this._debouncedSave) {
|
||||
this._debouncedSave = debounce(this.saveStateToDB.bind(this), 1000);
|
||||
}
|
||||
this._debouncedSave();
|
||||
}
|
||||
|
||||
saveMaskState(replaceLast = false): void {
|
||||
if (!this.canvas.maskTool) return;
|
||||
|
||||
if (replaceLast && this.maskUndoStack.length > 0) {
|
||||
this.maskUndoStack.pop();
|
||||
}
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const clonedCanvas = document.createElement('canvas');
|
||||
clonedCanvas.width = maskCanvas.width;
|
||||
clonedCanvas.height = maskCanvas.height;
|
||||
const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (clonedCtx) {
|
||||
clonedCtx.drawImage(maskCanvas, 0, 0);
|
||||
}
|
||||
|
||||
this.maskUndoStack.push(clonedCanvas);
|
||||
|
||||
if (this.maskUndoStack.length > this.historyLimit) {
|
||||
this.maskUndoStack.shift();
|
||||
}
|
||||
this.maskRedoStack = [];
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.undoMaskState();
|
||||
} else {
|
||||
this.undoLayersState();
|
||||
}
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.redoMaskState();
|
||||
} else {
|
||||
this.redoLayersState();
|
||||
}
|
||||
}
|
||||
|
||||
undoLayersState(): void {
|
||||
if (this.layersUndoStack.length <= 1) return;
|
||||
|
||||
const currentState = this.layersUndoStack.pop();
|
||||
if (currentState) {
|
||||
this.layersRedoStack.push(currentState);
|
||||
}
|
||||
const prevState = this.layersUndoStack[this.layersUndoStack.length - 1];
|
||||
this.canvas.layers = cloneLayers(prevState);
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
redoLayersState(): void {
|
||||
if (this.layersRedoStack.length === 0) return;
|
||||
|
||||
const nextState = this.layersRedoStack.pop();
|
||||
if (nextState) {
|
||||
this.layersUndoStack.push(nextState);
|
||||
this.canvas.layers = cloneLayers(nextState);
|
||||
this.canvas.updateSelectionAfterHistory();
|
||||
this.canvas.render();
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
}
|
||||
|
||||
undoMaskState(): void {
|
||||
if (!this.canvas.maskTool || this.maskUndoStack.length <= 1) return;
|
||||
|
||||
const currentState = this.maskUndoStack.pop();
|
||||
if (currentState) {
|
||||
this.maskRedoStack.push(currentState);
|
||||
}
|
||||
|
||||
if (this.maskUndoStack.length > 0) {
|
||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (maskCtx) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(prevState, 0, 0);
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
redoMaskState(): void {
|
||||
if (!this.canvas.maskTool || this.maskRedoStack.length === 0) return;
|
||||
|
||||
const nextState = this.maskRedoStack.pop();
|
||||
if (nextState) {
|
||||
this.maskUndoStack.push(nextState);
|
||||
const maskCanvas = this.canvas.maskTool.getMask();
|
||||
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (maskCtx) {
|
||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||
maskCtx.drawImage(nextState, 0, 0);
|
||||
}
|
||||
this.canvas.render();
|
||||
}
|
||||
this.canvas.updateHistoryButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści historię undo/redo
|
||||
*/
|
||||
clearHistory(): void {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
this.maskUndoStack = [];
|
||||
this.maskRedoStack = [];
|
||||
} else {
|
||||
this.layersUndoStack = [];
|
||||
this.layersRedoStack = [];
|
||||
}
|
||||
this.canvas.updateHistoryButtons();
|
||||
log.info("History cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca informacje o historii
|
||||
* @returns {HistoryInfo} Informacje o historii
|
||||
*/
|
||||
getHistoryInfo(): HistoryInfo {
|
||||
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
||||
return {
|
||||
undoCount: this.maskUndoStack.length,
|
||||
redoCount: this.maskRedoStack.length,
|
||||
canUndo: this.maskUndoStack.length > 1,
|
||||
canRedo: this.maskRedoStack.length > 0,
|
||||
historyLimit: this.historyLimit
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
undoCount: this.layersUndoStack.length,
|
||||
redoCount: this.layersRedoStack.length,
|
||||
canUndo: this.layersUndoStack.length > 1,
|
||||
canRedo: this.layersRedoStack.length > 0,
|
||||
historyLimit: this.historyLimit
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
1044
src/CanvasView.ts
Normal file
1044
src/CanvasView.ts
Normal file
File diff suppressed because it is too large
Load Diff
383
src/ErrorHandler.ts
Normal file
383
src/ErrorHandler.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* ErrorHandler - Centralna obsługa błędów
|
||||
* Eliminuje powtarzalne wzorce obsługi błędów w całym projekcie
|
||||
*/
|
||||
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
const log = createModuleLogger('ErrorHandler');
|
||||
|
||||
/**
|
||||
* Typy błędów w aplikacji
|
||||
*/
|
||||
export const ErrorTypes = {
|
||||
VALIDATION: 'VALIDATION_ERROR',
|
||||
NETWORK: 'NETWORK_ERROR',
|
||||
FILE_IO: 'FILE_IO_ERROR',
|
||||
CANVAS: 'CANVAS_ERROR',
|
||||
IMAGE_PROCESSING: 'IMAGE_PROCESSING_ERROR',
|
||||
STATE_MANAGEMENT: 'STATE_MANAGEMENT_ERROR',
|
||||
USER_INPUT: 'USER_INPUT_ERROR',
|
||||
SYSTEM: 'SYSTEM_ERROR'
|
||||
} as const;
|
||||
|
||||
export type ErrorType = typeof ErrorTypes[keyof typeof ErrorTypes];
|
||||
|
||||
interface ErrorHistoryEntry {
|
||||
timestamp: string;
|
||||
type: ErrorType;
|
||||
message: string;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
interface ErrorStats {
|
||||
totalErrors: number;
|
||||
errorCounts: { [key: string]: number };
|
||||
recentErrors: ErrorHistoryEntry[];
|
||||
errorsByType: { [key: string]: ErrorHistoryEntry[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Klasa błędu aplikacji z dodatkowymi informacjami
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
details: any;
|
||||
originalError: Error | null;
|
||||
timestamp: string;
|
||||
type: ErrorType;
|
||||
constructor(message: string, type: ErrorType = ErrorTypes.SYSTEM, details: any = null, originalError: Error | null = null) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
this.type = type;
|
||||
this.details = details;
|
||||
this.originalError = originalError;
|
||||
this.timestamp = new Date().toISOString();
|
||||
if ((Error as any).captureStackTrace) {
|
||||
(Error as any).captureStackTrace(this, AppError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler błędów z automatycznym logowaniem i kategoryzacją
|
||||
*/
|
||||
export class ErrorHandler {
|
||||
private errorCounts: Map<ErrorType, number>;
|
||||
private errorHistory: ErrorHistoryEntry[];
|
||||
private maxHistorySize: number;
|
||||
|
||||
constructor() {
|
||||
this.errorCounts = new Map();
|
||||
this.errorHistory = [];
|
||||
this.maxHistorySize = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsługuje błąd z automatycznym logowaniem
|
||||
* @param {Error | AppError | string} error - Błąd do obsłużenia
|
||||
* @param {string} context - Kontekst wystąpienia błędu
|
||||
* @param {object} additionalInfo - Dodatkowe informacje
|
||||
* @returns {AppError} Znormalizowany błąd
|
||||
*/
|
||||
handle(error: Error | AppError | string, context = 'Unknown', additionalInfo: object = {}): AppError {
|
||||
const normalizedError = this.normalizeError(error, context, additionalInfo);
|
||||
this.logError(normalizedError, context);
|
||||
this.recordError(normalizedError);
|
||||
this.incrementErrorCount(normalizedError.type);
|
||||
|
||||
return normalizedError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizuje błąd do standardowego formatu
|
||||
* @param {Error | AppError | string} error - Błąd do znormalizowania
|
||||
* @param {string} context - Kontekst
|
||||
* @param {object} additionalInfo - Dodatkowe informacje
|
||||
* @returns {AppError} Znormalizowany błąd
|
||||
*/
|
||||
normalizeError(error: Error | AppError | string, context: string, additionalInfo: object): AppError {
|
||||
if (error instanceof AppError) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const type = this.categorizeError(error, context);
|
||||
return new AppError(
|
||||
error.message,
|
||||
type,
|
||||
{context, ...additionalInfo},
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return new AppError(
|
||||
error,
|
||||
ErrorTypes.SYSTEM,
|
||||
{context, ...additionalInfo}
|
||||
);
|
||||
}
|
||||
|
||||
return new AppError(
|
||||
'Unknown error occurred',
|
||||
ErrorTypes.SYSTEM,
|
||||
{context, originalError: error, ...additionalInfo}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategoryzuje błąd na podstawie wiadomości i kontekstu
|
||||
* @param {Error} error - Błąd do skategoryzowania
|
||||
* @param {string} context - Kontekst
|
||||
* @returns {ErrorType} Typ błędu
|
||||
*/
|
||||
categorizeError(error: Error, context: string): ErrorType {
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes('fetch') || message.includes('network') ||
|
||||
message.includes('connection') || message.includes('timeout')) {
|
||||
return ErrorTypes.NETWORK;
|
||||
}
|
||||
if (message.includes('file') || message.includes('read') ||
|
||||
message.includes('write') || message.includes('path')) {
|
||||
return ErrorTypes.FILE_IO;
|
||||
}
|
||||
if (message.includes('invalid') || message.includes('required') ||
|
||||
message.includes('validation') || message.includes('format')) {
|
||||
return ErrorTypes.VALIDATION;
|
||||
}
|
||||
if (message.includes('image') || message.includes('canvas') ||
|
||||
message.includes('blob') || message.includes('tensor')) {
|
||||
return ErrorTypes.IMAGE_PROCESSING;
|
||||
}
|
||||
if (message.includes('state') || message.includes('cache') ||
|
||||
message.includes('storage')) {
|
||||
return ErrorTypes.STATE_MANAGEMENT;
|
||||
}
|
||||
if (context.toLowerCase().includes('canvas')) {
|
||||
return ErrorTypes.CANVAS;
|
||||
}
|
||||
|
||||
return ErrorTypes.SYSTEM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loguje błąd z odpowiednim poziomem
|
||||
* @param {AppError} error - Błąd do zalogowania
|
||||
* @param {string} context - Kontekst
|
||||
*/
|
||||
logError(error: AppError, context: string): void {
|
||||
const logMessage = `[${error.type}] ${error.message}`;
|
||||
const logDetails = {
|
||||
context,
|
||||
timestamp: error.timestamp,
|
||||
details: error.details,
|
||||
stack: error.stack
|
||||
};
|
||||
switch (error.type) {
|
||||
case ErrorTypes.VALIDATION:
|
||||
case ErrorTypes.USER_INPUT:
|
||||
log.warn(logMessage, logDetails);
|
||||
break;
|
||||
case ErrorTypes.NETWORK:
|
||||
log.error(logMessage, logDetails);
|
||||
break;
|
||||
default:
|
||||
log.error(logMessage, logDetails);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisuje błąd w historii
|
||||
* @param {AppError} error - Błąd do zapisania
|
||||
*/
|
||||
recordError(error: AppError): void {
|
||||
this.errorHistory.push({
|
||||
timestamp: error.timestamp,
|
||||
type: error.type,
|
||||
message: error.message,
|
||||
context: error.details?.context
|
||||
});
|
||||
if (this.errorHistory.length > this.maxHistorySize) {
|
||||
this.errorHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwiększa licznik błędów dla danego typu
|
||||
* @param {ErrorType} errorType - Typ błędu
|
||||
*/
|
||||
incrementErrorCount(errorType: ErrorType): void {
|
||||
const current = this.errorCounts.get(errorType) || 0;
|
||||
this.errorCounts.set(errorType, current + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca statystyki błędów
|
||||
* @returns {ErrorStats} Statystyki błędów
|
||||
*/
|
||||
getErrorStats(): ErrorStats {
|
||||
const errorCountsObj: { [key: string]: number } = {};
|
||||
for (const [key, value] of this.errorCounts.entries()) {
|
||||
errorCountsObj[key] = value;
|
||||
}
|
||||
return {
|
||||
totalErrors: this.errorHistory.length,
|
||||
errorCounts: errorCountsObj,
|
||||
recentErrors: this.errorHistory.slice(-10),
|
||||
errorsByType: this.groupErrorsByType()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Grupuje błędy według typu
|
||||
* @returns {{ [key: string]: ErrorHistoryEntry[] }} Błędy pogrupowane według typu
|
||||
*/
|
||||
groupErrorsByType(): { [key: string]: ErrorHistoryEntry[] } {
|
||||
const grouped: { [key: string]: ErrorHistoryEntry[] } = {};
|
||||
this.errorHistory.forEach((error) => {
|
||||
if (!grouped[error.type]) {
|
||||
grouped[error.type] = [];
|
||||
}
|
||||
grouped[error.type].push(error);
|
||||
});
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści historię błędów
|
||||
*/
|
||||
clearHistory(): void {
|
||||
this.errorHistory = [];
|
||||
this.errorCounts.clear();
|
||||
log.info('Error history cleared');
|
||||
}
|
||||
}
|
||||
|
||||
const errorHandler = new ErrorHandler();
|
||||
|
||||
/**
|
||||
* Wrapper funkcji z automatyczną obsługą błędów
|
||||
* @param {Function} fn - Funkcja do opakowania
|
||||
* @param {string} context - Kontekst wykonania
|
||||
* @returns {Function} Opakowana funkcja
|
||||
*/
|
||||
export function withErrorHandling<T extends (...args: any[]) => any>(
|
||||
fn: T,
|
||||
context: string
|
||||
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
|
||||
return async function(this: any, ...args: Parameters<T>): Promise<ReturnType<T>> {
|
||||
try {
|
||||
return await fn.apply(this, args);
|
||||
} catch (error) {
|
||||
const handledError = errorHandler.handle(error as Error, context, {
|
||||
functionName: fn.name,
|
||||
arguments: args.length
|
||||
});
|
||||
throw handledError;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator dla metod klasy z automatyczną obsługą błędów
|
||||
* @param {string} context - Kontekst wykonania
|
||||
*/
|
||||
export function handleErrors(context: string) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
try {
|
||||
return await originalMethod.apply(this, args);
|
||||
} catch (error) {
|
||||
const handledError = errorHandler.handle(error as Error, `${context}.${propertyKey}`, {
|
||||
className: target.constructor.name,
|
||||
methodName: propertyKey,
|
||||
arguments: args.length
|
||||
});
|
||||
throw handledError;
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia błędów walidacji
|
||||
* @param {string} message - Wiadomość błędu
|
||||
* @param {object} details - Szczegóły walidacji
|
||||
* @returns {AppError} Błąd walidacji
|
||||
*/
|
||||
export function createValidationError(message: string, details: object = {}): AppError {
|
||||
return new AppError(message, ErrorTypes.VALIDATION, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia błędów sieciowych
|
||||
* @param {string} message - Wiadomość błędu
|
||||
* @param {object} details - Szczegóły sieci
|
||||
* @returns {AppError} Błąd sieciowy
|
||||
*/
|
||||
export function createNetworkError(message: string, details: object = {}): AppError {
|
||||
return new AppError(message, ErrorTypes.NETWORK, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia błędów plików
|
||||
* @param {string} message - Wiadomość błędu
|
||||
* @param {object} details - Szczegóły pliku
|
||||
* @returns {AppError} Błąd pliku
|
||||
*/
|
||||
export function createFileError(message: string, details: object = {}): AppError {
|
||||
return new AppError(message, ErrorTypes.FILE_IO, details);
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do bezpiecznego wykonania operacji
|
||||
* @param {() => Promise<T>} operation - Operacja do wykonania
|
||||
* @param {T} fallbackValue - Wartość fallback w przypadku błędu
|
||||
* @param {string} context - Kontekst operacji
|
||||
* @returns {Promise<T>} Wynik operacji lub wartość fallback
|
||||
*/
|
||||
export async function safeExecute<T>(operation: () => Promise<T>, fallbackValue: T, context = 'SafeExecute'): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
errorHandler.handle(error as Error, context);
|
||||
return fallbackValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja do retry operacji z exponential backoff
|
||||
* @param {() => Promise<T>} operation - Operacja do powtórzenia
|
||||
* @param {number} maxRetries - Maksymalna liczba prób
|
||||
* @param {number} baseDelay - Podstawowe opóźnienie w ms
|
||||
* @param {string} context - Kontekst operacji
|
||||
* @returns {Promise<T>} Wynik operacji
|
||||
*/
|
||||
export async function retryWithBackoff<T>(operation: () => Promise<T>, maxRetries = 3, baseDelay = 1000, context = 'RetryOperation'): Promise<T> {
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = baseDelay * Math.pow(2, attempt);
|
||||
log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms`, {error: lastError.message, context});
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw errorHandler.handle(lastError!, context, {attempts: maxRetries + 1});
|
||||
}
|
||||
|
||||
export {errorHandler};
|
||||
export default errorHandler;
|
||||
32
src/ImageCache.ts
Normal file
32
src/ImageCache.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import type { ImageDataPixel } from './types';
|
||||
|
||||
const log = createModuleLogger('ImageCache');
|
||||
|
||||
export class ImageCache {
|
||||
private cache: Map<string, ImageDataPixel>;
|
||||
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
set(key: string, imageData: ImageDataPixel): void {
|
||||
log.info("Caching image data for key:", key);
|
||||
this.cache.set(key, imageData);
|
||||
}
|
||||
|
||||
get(key: string): ImageDataPixel | undefined {
|
||||
const data = this.cache.get(key);
|
||||
log.debug("Retrieved cached data for key:", key, !!data);
|
||||
return data;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
log.info("Clearing image cache");
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
309
src/ImageReferenceManager.ts
Normal file
309
src/ImageReferenceManager.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import {removeImage, getAllImageIds} from "./db.js";
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Layer, CanvasState } from './types';
|
||||
|
||||
const log = createModuleLogger('ImageReferenceManager');
|
||||
|
||||
interface GarbageCollectionStats {
|
||||
trackedImages: number;
|
||||
totalReferences: number;
|
||||
isRunning: boolean;
|
||||
gcInterval: number;
|
||||
maxAge: number;
|
||||
}
|
||||
|
||||
export class ImageReferenceManager {
|
||||
private canvas: Canvas & { canvasState: CanvasState };
|
||||
private gcInterval: number;
|
||||
private gcTimer: number | null;
|
||||
private imageLastUsed: Map<string, number>;
|
||||
private imageReferences: Map<string, number>;
|
||||
private isGcRunning: boolean;
|
||||
private maxAge: number;
|
||||
public operationCount: number;
|
||||
public operationThreshold: number;
|
||||
|
||||
constructor(canvas: Canvas & { canvasState: CanvasState }) {
|
||||
this.canvas = canvas;
|
||||
this.imageReferences = new Map(); // imageId -> count
|
||||
this.imageLastUsed = new Map(); // imageId -> timestamp
|
||||
this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane)
|
||||
this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia
|
||||
this.gcTimer = null;
|
||||
this.isGcRunning = false;
|
||||
this.operationCount = 0;
|
||||
this.operationThreshold = 500; // Uruchom GC po 500 operacjach
|
||||
}
|
||||
|
||||
/**
|
||||
* Uruchamia automatyczne garbage collection
|
||||
*/
|
||||
startGarbageCollection(): void {
|
||||
if (this.gcTimer) {
|
||||
clearInterval(this.gcTimer);
|
||||
}
|
||||
|
||||
this.gcTimer = window.setInterval(() => {
|
||||
this.performGarbageCollection();
|
||||
}, this.gcInterval);
|
||||
|
||||
log.info("Garbage collection started with interval:", this.gcInterval / 1000, "seconds");
|
||||
}
|
||||
|
||||
/**
|
||||
* Zatrzymuje automatyczne garbage collection
|
||||
*/
|
||||
stopGarbageCollection(): void {
|
||||
if (this.gcTimer) {
|
||||
clearInterval(this.gcTimer);
|
||||
this.gcTimer = null;
|
||||
}
|
||||
log.info("Garbage collection stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dodaje referencję do obrazu
|
||||
* @param {string} imageId - ID obrazu
|
||||
*/
|
||||
addReference(imageId: string): void {
|
||||
if (!imageId) return;
|
||||
|
||||
const currentCount = this.imageReferences.get(imageId) || 0;
|
||||
this.imageReferences.set(imageId, currentCount + 1);
|
||||
this.imageLastUsed.set(imageId, Date.now());
|
||||
|
||||
log.debug(`Added reference to image ${imageId}, count: ${currentCount + 1}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuwa referencję do obrazu
|
||||
* @param {string} imageId - ID obrazu
|
||||
*/
|
||||
removeReference(imageId: string): void {
|
||||
if (!imageId) return;
|
||||
|
||||
const currentCount = this.imageReferences.get(imageId) || 0;
|
||||
if (currentCount <= 1) {
|
||||
this.imageReferences.delete(imageId);
|
||||
log.debug(`Removed last reference to image ${imageId}`);
|
||||
} else {
|
||||
this.imageReferences.set(imageId, currentCount - 1);
|
||||
log.debug(`Removed reference to image ${imageId}, count: ${currentCount - 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualizuje referencje na podstawie aktualnego stanu canvas
|
||||
*/
|
||||
updateReferences(): void {
|
||||
log.debug("Updating image references...");
|
||||
this.imageReferences.clear();
|
||||
const usedImageIds = this.collectAllUsedImageIds();
|
||||
usedImageIds.forEach(imageId => {
|
||||
this.addReference(imageId);
|
||||
});
|
||||
|
||||
log.info(`Updated references for ${usedImageIds.size} unique images`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zbiera wszystkie używane imageId z różnych źródeł
|
||||
* @returns {Set<string>} Zbiór używanych imageId
|
||||
*/
|
||||
collectAllUsedImageIds(): Set<string> {
|
||||
const usedImageIds = new Set<string>();
|
||||
this.canvas.layers.forEach((layer: Layer) => {
|
||||
if (layer.imageId) {
|
||||
usedImageIds.add(layer.imageId);
|
||||
}
|
||||
});
|
||||
if (this.canvas.canvasState && this.canvas.canvasState.layersUndoStack) {
|
||||
this.canvas.canvasState.layersUndoStack.forEach((layersState: Layer[]) => {
|
||||
layersState.forEach((layer: Layer) => {
|
||||
if (layer.imageId) {
|
||||
usedImageIds.add(layer.imageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (this.canvas.canvasState && this.canvas.canvasState.layersRedoStack) {
|
||||
this.canvas.canvasState.layersRedoStack.forEach((layersState: Layer[]) => {
|
||||
layersState.forEach((layer: Layer) => {
|
||||
if (layer.imageId) {
|
||||
usedImageIds.add(layer.imageId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
log.debug(`Collected ${usedImageIds.size} used image IDs`);
|
||||
return usedImageIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Znajduje nieużywane obrazy
|
||||
* @param {Set<string>} usedImageIds - Zbiór używanych imageId
|
||||
* @returns {Promise<string[]>} Lista nieużywanych imageId
|
||||
*/
|
||||
async findUnusedImages(usedImageIds: Set<string>): Promise<string[]> {
|
||||
try {
|
||||
const allImageIds = await getAllImageIds();
|
||||
const unusedImages: string[] = [];
|
||||
const now = Date.now();
|
||||
|
||||
for (const imageId of allImageIds) {
|
||||
if (!usedImageIds.has(imageId)) {
|
||||
const lastUsed = this.imageLastUsed.get(imageId) || 0;
|
||||
const age = now - lastUsed;
|
||||
|
||||
if (age > this.maxAge) {
|
||||
unusedImages.push(imageId);
|
||||
} else {
|
||||
log.debug(`Image ${imageId} is unused but too young (age: ${Math.round(age / 1000)}s)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`Found ${unusedImages.length} unused images ready for cleanup`);
|
||||
return unusedImages;
|
||||
} catch (error) {
|
||||
log.error("Error finding unused images:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści nieużywane obrazy
|
||||
* @param {string[]} unusedImages - Lista nieużywanych imageId
|
||||
*/
|
||||
async cleanupUnusedImages(unusedImages: string[]): Promise<void> {
|
||||
if (unusedImages.length === 0) {
|
||||
log.debug("No unused images to cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Starting cleanup of ${unusedImages.length} unused images`);
|
||||
let cleanedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const imageId of unusedImages) {
|
||||
try {
|
||||
|
||||
await removeImage(imageId);
|
||||
|
||||
if (this.canvas.imageCache && this.canvas.imageCache.has(imageId)) {
|
||||
this.canvas.imageCache.delete(imageId);
|
||||
}
|
||||
|
||||
this.imageReferences.delete(imageId);
|
||||
this.imageLastUsed.delete(imageId);
|
||||
|
||||
cleanedCount++;
|
||||
log.debug(`Cleaned up image: ${imageId}`);
|
||||
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
log.error(`Error cleaning up image ${imageId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Garbage collection completed: ${cleanedCount} images cleaned, ${errorCount} errors`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wykonuje pełne garbage collection
|
||||
*/
|
||||
async performGarbageCollection(): Promise<void> {
|
||||
if (this.isGcRunning) {
|
||||
log.debug("Garbage collection already running, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isGcRunning = true;
|
||||
log.info("Starting garbage collection...");
|
||||
|
||||
try {
|
||||
|
||||
this.updateReferences();
|
||||
|
||||
const usedImageIds = this.collectAllUsedImageIds();
|
||||
|
||||
const unusedImages = await this.findUnusedImages(usedImageIds);
|
||||
|
||||
await this.cleanupUnusedImages(unusedImages);
|
||||
|
||||
} catch (error) {
|
||||
log.error("Error during garbage collection:", error);
|
||||
} finally {
|
||||
this.isGcRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwiększa licznik operacji i sprawdza czy uruchomić GC
|
||||
*/
|
||||
incrementOperationCount(): void {
|
||||
this.operationCount++;
|
||||
log.debug(`Operation count: ${this.operationCount}/${this.operationThreshold}`);
|
||||
|
||||
if (this.operationCount >= this.operationThreshold) {
|
||||
log.info(`Operation threshold reached (${this.operationThreshold}), triggering garbage collection`);
|
||||
this.operationCount = 0; // Reset counter
|
||||
|
||||
setTimeout(() => {
|
||||
this.performGarbageCollection();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resetuje licznik operacji
|
||||
*/
|
||||
resetOperationCount(): void {
|
||||
this.operationCount = 0;
|
||||
log.debug("Operation count reset");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustawia próg operacji dla automatycznego GC
|
||||
* @param {number} threshold - Nowy próg operacji
|
||||
*/
|
||||
setOperationThreshold(threshold: number): void {
|
||||
this.operationThreshold = Math.max(1, threshold);
|
||||
log.info(`Operation threshold set to: ${this.operationThreshold}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ręczne uruchomienie garbage collection
|
||||
*/
|
||||
async manualGarbageCollection(): Promise<void> {
|
||||
log.info("Manual garbage collection triggered");
|
||||
await this.performGarbageCollection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Zwraca statystyki garbage collection
|
||||
* @returns {GarbageCollectionStats} Statystyki
|
||||
*/
|
||||
getStats(): GarbageCollectionStats {
|
||||
return {
|
||||
trackedImages: this.imageReferences.size,
|
||||
totalReferences: Array.from(this.imageReferences.values()).reduce((sum, count) => sum + count, 0),
|
||||
isRunning: this.isGcRunning,
|
||||
gcInterval: this.gcInterval,
|
||||
maxAge: this.maxAge
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Czyści wszystkie dane (przy usuwaniu canvas)
|
||||
*/
|
||||
destroy(): void {
|
||||
this.stopGarbageCollection();
|
||||
this.imageReferences.clear();
|
||||
this.imageLastUsed.clear();
|
||||
log.info("ImageReferenceManager destroyed");
|
||||
}
|
||||
}
|
||||
339
src/MaskTool.ts
Normal file
339
src/MaskTool.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
import type { Canvas } from './Canvas';
|
||||
import type { Point, CanvasState } from './types';
|
||||
|
||||
const log = createModuleLogger('Mask_tool');
|
||||
|
||||
interface MaskToolCallbacks {
|
||||
onStateChange?: () => void;
|
||||
}
|
||||
|
||||
export class MaskTool {
|
||||
private brushHardness: number;
|
||||
private brushSize: number;
|
||||
private brushStrength: number;
|
||||
private canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number };
|
||||
public isActive: boolean;
|
||||
public isDrawing: boolean;
|
||||
public isOverlayVisible: boolean;
|
||||
private lastPosition: Point | null;
|
||||
private mainCanvas: HTMLCanvasElement;
|
||||
private maskCanvas: HTMLCanvasElement;
|
||||
private maskCtx: CanvasRenderingContext2D;
|
||||
private onStateChange: (() => void) | null;
|
||||
private previewCanvas: HTMLCanvasElement;
|
||||
private previewCanvasInitialized: boolean;
|
||||
private previewCtx: CanvasRenderingContext2D;
|
||||
private previewVisible: boolean;
|
||||
public x: number;
|
||||
public y: number;
|
||||
|
||||
constructor(canvasInstance: Canvas & { canvasState: CanvasState, width: number, height: number }, callbacks: MaskToolCallbacks = {}) {
|
||||
this.canvasInstance = canvasInstance;
|
||||
this.mainCanvas = canvasInstance.canvas;
|
||||
this.onStateChange = callbacks.onStateChange || null;
|
||||
this.maskCanvas = document.createElement('canvas');
|
||||
const maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!maskCtx) {
|
||||
throw new Error("Failed to get 2D context for mask canvas");
|
||||
}
|
||||
this.maskCtx = maskCtx;
|
||||
|
||||
this.x = 0;
|
||||
this.y = 0;
|
||||
|
||||
this.isOverlayVisible = true;
|
||||
this.isActive = false;
|
||||
this.brushSize = 20;
|
||||
this.brushStrength = 0.5;
|
||||
this.brushHardness = 0.5;
|
||||
this.isDrawing = false;
|
||||
this.lastPosition = null;
|
||||
|
||||
this.previewCanvas = document.createElement('canvas');
|
||||
const previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!previewCtx) {
|
||||
throw new Error("Failed to get 2D context for preview canvas");
|
||||
}
|
||||
this.previewCtx = previewCtx;
|
||||
this.previewVisible = false;
|
||||
this.previewCanvasInitialized = false;
|
||||
|
||||
this.initMaskCanvas();
|
||||
}
|
||||
|
||||
initPreviewCanvas(): void {
|
||||
if (this.previewCanvas.parentElement) {
|
||||
this.previewCanvas.parentElement.removeChild(this.previewCanvas);
|
||||
}
|
||||
this.previewCanvas.width = this.canvasInstance.canvas.width;
|
||||
this.previewCanvas.height = this.canvasInstance.canvas.height;
|
||||
this.previewCanvas.style.position = 'absolute';
|
||||
this.previewCanvas.style.left = `${this.canvasInstance.canvas.offsetLeft}px`;
|
||||
this.previewCanvas.style.top = `${this.canvasInstance.canvas.offsetTop}px`;
|
||||
this.previewCanvas.style.pointerEvents = 'none';
|
||||
this.previewCanvas.style.zIndex = '10';
|
||||
if (this.canvasInstance.canvas.parentElement) {
|
||||
this.canvasInstance.canvas.parentElement.appendChild(this.previewCanvas);
|
||||
}
|
||||
}
|
||||
|
||||
setBrushHardness(hardness: number): void {
|
||||
this.brushHardness = Math.max(0, Math.min(1, hardness));
|
||||
}
|
||||
|
||||
initMaskCanvas(): void {
|
||||
const extraSpace = 2000; // Allow for a generous drawing area outside the output area
|
||||
this.maskCanvas.width = this.canvasInstance.width + extraSpace;
|
||||
this.maskCanvas.height = this.canvasInstance.height + extraSpace;
|
||||
|
||||
|
||||
this.x = -extraSpace / 2;
|
||||
this.y = -extraSpace / 2;
|
||||
|
||||
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
|
||||
log.info(`Initialized mask canvas with extended size: ${this.maskCanvas.width}x${this.maskCanvas.height}, origin at (${this.x}, ${this.y})`);
|
||||
}
|
||||
|
||||
activate(): void {
|
||||
if (!this.previewCanvasInitialized) {
|
||||
this.initPreviewCanvas();
|
||||
this.previewCanvasInitialized = true;
|
||||
}
|
||||
this.isActive = true;
|
||||
this.previewCanvas.style.display = 'block';
|
||||
this.canvasInstance.interaction.mode = 'drawingMask';
|
||||
if (this.canvasInstance.canvasState.maskUndoStack.length === 0) {
|
||||
this.canvasInstance.canvasState.saveMaskState();
|
||||
}
|
||||
this.canvasInstance.updateHistoryButtons();
|
||||
|
||||
log.info("Mask tool activated");
|
||||
}
|
||||
|
||||
deactivate(): void {
|
||||
this.isActive = false;
|
||||
this.previewCanvas.style.display = 'none';
|
||||
this.canvasInstance.interaction.mode = 'none';
|
||||
this.canvasInstance.updateHistoryButtons();
|
||||
|
||||
log.info("Mask tool deactivated");
|
||||
}
|
||||
|
||||
setBrushSize(size: number): void {
|
||||
this.brushSize = Math.max(1, size);
|
||||
}
|
||||
|
||||
setBrushStrength(strength: number): void {
|
||||
this.brushStrength = Math.max(0, Math.min(1, strength));
|
||||
}
|
||||
|
||||
handleMouseDown(worldCoords: Point, viewCoords: Point): void {
|
||||
if (!this.isActive) return;
|
||||
this.isDrawing = true;
|
||||
this.lastPosition = worldCoords;
|
||||
this.draw(worldCoords);
|
||||
this.clearPreview();
|
||||
}
|
||||
|
||||
handleMouseMove(worldCoords: Point, viewCoords: Point): void {
|
||||
if (this.isActive) {
|
||||
this.drawBrushPreview(viewCoords);
|
||||
}
|
||||
if (!this.isActive || !this.isDrawing) return;
|
||||
this.draw(worldCoords);
|
||||
this.lastPosition = worldCoords;
|
||||
}
|
||||
|
||||
handleMouseLeave(): void {
|
||||
this.previewVisible = false;
|
||||
this.clearPreview();
|
||||
}
|
||||
|
||||
handleMouseEnter(): void {
|
||||
this.previewVisible = true;
|
||||
}
|
||||
|
||||
handleMouseUp(viewCoords: Point): void {
|
||||
if (!this.isActive) return;
|
||||
if (this.isDrawing) {
|
||||
this.isDrawing = false;
|
||||
this.lastPosition = null;
|
||||
this.canvasInstance.canvasState.saveMaskState();
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
}
|
||||
this.drawBrushPreview(viewCoords);
|
||||
}
|
||||
}
|
||||
|
||||
draw(worldCoords: Point): void {
|
||||
if (!this.lastPosition) {
|
||||
this.lastPosition = worldCoords;
|
||||
}
|
||||
|
||||
|
||||
const canvasLastX = this.lastPosition.x - this.x;
|
||||
const canvasLastY = this.lastPosition.y - this.y;
|
||||
const canvasX = worldCoords.x - this.x;
|
||||
const canvasY = worldCoords.y - this.y;
|
||||
|
||||
|
||||
const canvasWidth = this.maskCanvas.width;
|
||||
const canvasHeight = this.maskCanvas.height;
|
||||
|
||||
if (canvasX >= 0 && canvasX < canvasWidth &&
|
||||
canvasY >= 0 && canvasY < canvasHeight &&
|
||||
canvasLastX >= 0 && canvasLastX < canvasWidth &&
|
||||
canvasLastY >= 0 && canvasLastY < canvasHeight) {
|
||||
|
||||
this.maskCtx.beginPath();
|
||||
this.maskCtx.moveTo(canvasLastX, canvasLastY);
|
||||
this.maskCtx.lineTo(canvasX, canvasY);
|
||||
const gradientRadius = this.brushSize / 2;
|
||||
|
||||
if (this.brushHardness === 1) {
|
||||
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
||||
} else {
|
||||
const innerRadius = gradientRadius * this.brushHardness;
|
||||
const gradient = this.maskCtx.createRadialGradient(
|
||||
canvasX, canvasY, innerRadius,
|
||||
canvasX, canvasY, gradientRadius
|
||||
);
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.brushStrength})`);
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
||||
this.maskCtx.strokeStyle = gradient;
|
||||
}
|
||||
|
||||
this.maskCtx.lineWidth = this.brushSize;
|
||||
this.maskCtx.lineCap = 'round';
|
||||
this.maskCtx.lineJoin = 'round';
|
||||
this.maskCtx.globalCompositeOperation = 'source-over';
|
||||
this.maskCtx.stroke();
|
||||
} else {
|
||||
log.debug(`Drawing outside mask canvas bounds: (${canvasX}, ${canvasY})`);
|
||||
}
|
||||
}
|
||||
|
||||
drawBrushPreview(viewCoords: Point): void {
|
||||
if (!this.previewVisible || this.isDrawing) {
|
||||
this.clearPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearPreview();
|
||||
const zoom = this.canvasInstance.viewport.zoom;
|
||||
const radius = (this.brushSize / 2) * zoom;
|
||||
|
||||
this.previewCtx.beginPath();
|
||||
this.previewCtx.arc(viewCoords.x, viewCoords.y, radius, 0, 2 * Math.PI);
|
||||
this.previewCtx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
this.previewCtx.lineWidth = 1;
|
||||
this.previewCtx.setLineDash([2, 4]);
|
||||
this.previewCtx.stroke();
|
||||
}
|
||||
|
||||
clearPreview(): void {
|
||||
this.previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.maskCtx.clearRect(0, 0, this.maskCanvas.width, this.maskCanvas.height);
|
||||
if (this.isActive) {
|
||||
this.canvasInstance.canvasState.saveMaskState();
|
||||
}
|
||||
}
|
||||
|
||||
getMask(): HTMLCanvasElement {
|
||||
return this.maskCanvas;
|
||||
}
|
||||
|
||||
getMaskImageWithAlpha(): HTMLImageElement {
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = this.maskCanvas.width;
|
||||
tempCanvas.height = this.maskCanvas.height;
|
||||
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!tempCtx) {
|
||||
throw new Error("Failed to get 2D context for temporary canvas");
|
||||
}
|
||||
tempCtx.drawImage(this.maskCanvas, 0, 0);
|
||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i];
|
||||
data[i] = 255;
|
||||
data[i + 1] = 255;
|
||||
data[i + 2] = 255;
|
||||
data[i + 3] = alpha;
|
||||
}
|
||||
tempCtx.putImageData(imageData, 0, 0);
|
||||
const maskImage = new Image();
|
||||
maskImage.src = tempCanvas.toDataURL();
|
||||
return maskImage;
|
||||
}
|
||||
|
||||
resize(width: number, height: number): void {
|
||||
this.initPreviewCanvas();
|
||||
const oldMask = this.maskCanvas;
|
||||
const oldX = this.x;
|
||||
const oldY = this.y;
|
||||
const oldWidth = oldMask.width;
|
||||
const oldHeight = oldMask.height;
|
||||
|
||||
const isIncreasingWidth = width > this.canvasInstance.width;
|
||||
const isIncreasingHeight = height > this.canvasInstance.height;
|
||||
|
||||
this.maskCanvas = document.createElement('canvas');
|
||||
|
||||
const extraSpace = 2000;
|
||||
|
||||
const newWidth = isIncreasingWidth ? width + extraSpace : Math.max(oldWidth, width + extraSpace);
|
||||
const newHeight = isIncreasingHeight ? height + extraSpace : Math.max(oldHeight, height + extraSpace);
|
||||
|
||||
this.maskCanvas.width = newWidth;
|
||||
this.maskCanvas.height = newHeight;
|
||||
const newMaskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!newMaskCtx) {
|
||||
throw new Error("Failed to get 2D context for new mask canvas");
|
||||
}
|
||||
this.maskCtx = newMaskCtx;
|
||||
|
||||
if (oldMask.width > 0 && oldMask.height > 0) {
|
||||
const offsetX = this.x - oldX;
|
||||
const offsetY = this.y - oldY;
|
||||
|
||||
this.maskCtx.drawImage(oldMask, offsetX, offsetY);
|
||||
|
||||
log.debug(`Preserved mask content with offset (${offsetX}, ${offsetY})`);
|
||||
}
|
||||
|
||||
log.info(`Mask canvas resized to ${this.maskCanvas.width}x${this.maskCanvas.height}, position (${this.x}, ${this.y})`);
|
||||
log.info(`Canvas size change: width ${isIncreasingWidth ? 'increased' : 'decreased'}, height ${isIncreasingHeight ? 'increased' : 'decreased'}`);
|
||||
}
|
||||
|
||||
updatePosition(dx: number, dy: number): void {
|
||||
this.x += dx;
|
||||
this.y += dy;
|
||||
log.info(`Mask position updated to (${this.x}, ${this.y})`);
|
||||
}
|
||||
|
||||
toggleOverlayVisibility(): void {
|
||||
this.isOverlayVisible = !this.isOverlayVisible;
|
||||
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
|
||||
}
|
||||
|
||||
setMask(image: HTMLImageElement): void {
|
||||
const destX = -this.x;
|
||||
const destY = -this.y;
|
||||
|
||||
this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height);
|
||||
|
||||
this.maskCtx.drawImage(image, destX, destY);
|
||||
|
||||
if (this.onStateChange) {
|
||||
this.onStateChange();
|
||||
}
|
||||
this.canvasInstance.render();
|
||||
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
|
||||
}
|
||||
}
|
||||
5
src/config.ts
Normal file
5
src/config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { LogLevel } from "./logger";
|
||||
|
||||
// Log level for development.
|
||||
// Possible values: 'DEBUG', 'INFO', 'WARN', 'ERROR', 'NONE'
|
||||
export const LOG_LEVEL: keyof typeof LogLevel = 'DEBUG';
|
||||
405
src/css/canvas_view.css
Normal file
405
src/css/canvas_view.css
Normal file
@@ -0,0 +1,405 @@
|
||||
.painter-button {
|
||||
background: linear-gradient(to bottom, #4a4a4a, #3a3a3a);
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
margin: 2px;
|
||||
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.painter-button:hover {
|
||||
background: linear-gradient(to bottom, #5a5a5a, #4a4a4a);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.painter-button:active {
|
||||
background: linear-gradient(to bottom, #3a3a3a, #4a4a4a);
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.painter-button:disabled,
|
||||
.painter-button:disabled:hover {
|
||||
background: #555;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
border-color: #444;
|
||||
}
|
||||
|
||||
.painter-button.primary {
|
||||
background: linear-gradient(to bottom, #4a6cd4, #3a5cc4);
|
||||
border-color: #2a4cb4;
|
||||
}
|
||||
|
||||
.painter-button.primary:hover {
|
||||
background: linear-gradient(to bottom, #5a7ce4, #4a6cd4);
|
||||
}
|
||||
|
||||
.painter-controls {
|
||||
background: linear-gradient(to bottom, #404040, #383838);
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.painter-slider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.painter-slider-container input[type="range"] {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
|
||||
.painter-button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.painter-clipboard-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background-color: rgba(0,0,0,0.15);
|
||||
padding: 3px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.painter-clipboard-group::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, rgba(74, 108, 212, 0.6), transparent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.painter-clipboard-group .painter-button {
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
.painter-separator {
|
||||
width: 1px;
|
||||
height: 28px;
|
||||
background-color: #2a2a2a;
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.painter-container {
|
||||
background: #607080; /* 带蓝色的灰色背景 */
|
||||
border: 1px solid #4a5a6a;
|
||||
border-radius: 6px;
|
||||
box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
|
||||
transition: border-color 0.3s ease; /* Dodano dla płynnej zmiany ramki */
|
||||
}
|
||||
|
||||
.painter-container.drag-over {
|
||||
border-color: #00ff00; /* Zielona ramka podczas przeciągania */
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.painter-dialog {
|
||||
background: #404040;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
padding: 20px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.painter-dialog input {
|
||||
background: #303030;
|
||||
border: 1px solid #505050;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 4px 8px;
|
||||
margin: 4px;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.painter-dialog button {
|
||||
background: #505050;
|
||||
border: 1px solid #606060;
|
||||
border-radius: 4px;
|
||||
color: #ffffff;
|
||||
padding: 4px 12px;
|
||||
margin: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.painter-dialog button:hover {
|
||||
background: #606060;
|
||||
}
|
||||
|
||||
.blend-opacity-slider {
|
||||
width: 100%;
|
||||
margin: 5px 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.blend-mode-active .blend-opacity-slider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blend-mode-item {
|
||||
padding: 5px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.blend-mode-item.active {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.blend-mode-item.active {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.painter-tooltip {
|
||||
position: fixed;
|
||||
display: none;
|
||||
background: #3a3a3a;
|
||||
color: #f0f0f0;
|
||||
border: 1px solid #555;
|
||||
border-radius: 8px;
|
||||
padding: 12px 18px;
|
||||
z-index: 9999;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
width: auto;
|
||||
max-width: min(500px, calc(100vw - 40px));
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
pointer-events: none;
|
||||
transform-origin: top left;
|
||||
transition: transform 0.2s ease;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.painter-tooltip.scale-down {
|
||||
transform: scale(0.9);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.painter-tooltip.scale-down-more {
|
||||
transform: scale(0.8);
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.painter-tooltip table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.painter-tooltip table td {
|
||||
padding: 2px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.painter-tooltip table td:first-child {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.painter-tooltip table td:last-child {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.painter-tooltip table tr:nth-child(odd) td {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.painter-tooltip {
|
||||
font-size: 11px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.painter-tooltip table td {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.painter-tooltip kbd {
|
||||
padding: 1px 4px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.painter-tooltip table td:first-child {
|
||||
width: 40%;
|
||||
}
|
||||
.painter-tooltip table td:last-child {
|
||||
width: 60%;
|
||||
}
|
||||
.painter-tooltip h4 {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.painter-tooltip {
|
||||
font-size: 10px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.painter-tooltip table td {
|
||||
padding: 1px 3px;
|
||||
}
|
||||
.painter-tooltip kbd {
|
||||
padding: 0px 3px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.painter-tooltip table td:first-child {
|
||||
width: 35%;
|
||||
}
|
||||
.painter-tooltip table td:last-child {
|
||||
width: 65%;
|
||||
}
|
||||
.painter-tooltip h4 {
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.painter-tooltip::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
.painter-tooltip h4 {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 5px;
|
||||
color: #4a90e2; /* Jasnoniebieski akcent */
|
||||
border-bottom: 1px solid #555;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.painter-tooltip ul {
|
||||
list-style: none;
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.painter-tooltip kbd {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #1a1a1a;
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
|
||||
.painter-container.has-focus {
|
||||
/* Używamy box-shadow, aby stworzyć efekt zewnętrznej ramki,
|
||||
która nie wpłynie na rozmiar ani pozycję elementu. */
|
||||
box-shadow: 0 0 0 2px white;
|
||||
/* Możesz też zmienić kolor istniejącej ramki, ale box-shadow jest bardziej wyrazisty */
|
||||
/* border-color: white; */
|
||||
}
|
||||
|
||||
.painter-button.matting-button {
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.painter-button.matting-button.loading {
|
||||
padding-right: 36px; /* Make space for spinner */
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.painter-button.matting-button .matting-spinner {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #fff;
|
||||
animation: matting-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.painter-button.matting-button.loading .matting-spinner {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes matting-spin {
|
||||
to {
|
||||
transform: translateY(-50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
.painter-modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 111;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.painter-modal-content {
|
||||
width: 90vw;
|
||||
height: 90vh;
|
||||
background-color: #353535;
|
||||
border: 1px solid #222;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 5px 25px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.painterMainContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.painterCanvasContainer {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
192
src/db.ts
Normal file
192
src/db.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||
|
||||
const log = createModuleLogger('db');
|
||||
|
||||
const DB_NAME = 'CanvasNodeDB';
|
||||
const STATE_STORE_NAME = 'CanvasState';
|
||||
const IMAGE_STORE_NAME = 'CanvasImages';
|
||||
const DB_VERSION = 3;
|
||||
|
||||
let db: IDBDatabase | null = null;
|
||||
|
||||
type DBRequestOperation = 'get' | 'put' | 'delete' | 'clear';
|
||||
|
||||
interface CanvasStateDB {
|
||||
id: string;
|
||||
state: any;
|
||||
}
|
||||
|
||||
interface CanvasImageDB {
|
||||
imageId: string;
|
||||
imageSrc: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja pomocnicza do tworzenia żądań IndexedDB z ujednoliconą obsługą błędów
|
||||
* @param {IDBObjectStore} store - Store IndexedDB
|
||||
* @param {DBRequestOperation} operation - Nazwa operacji (get, put, delete, clear)
|
||||
* @param {any} data - Dane dla operacji (opcjonalne)
|
||||
* @param {string} errorMessage - Wiadomość błędu
|
||||
* @returns {Promise<any>} Promise z wynikiem operacji
|
||||
*/
|
||||
function createDBRequest(store: IDBObjectStore, operation: DBRequestOperation, data: any, errorMessage: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let request: IDBRequest;
|
||||
switch (operation) {
|
||||
case 'get':
|
||||
request = store.get(data);
|
||||
break;
|
||||
case 'put':
|
||||
request = store.put(data);
|
||||
break;
|
||||
case 'delete':
|
||||
request = store.delete(data);
|
||||
break;
|
||||
case 'clear':
|
||||
request = store.clear();
|
||||
break;
|
||||
default:
|
||||
reject(new Error(`Unknown operation: ${operation}`));
|
||||
return;
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
log.error(errorMessage, (event.target as IDBRequest).error);
|
||||
reject(errorMessage);
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
resolve((event.target as IDBRequest).result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (db) {
|
||||
resolve(db);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Opening IndexedDB...");
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = (event) => {
|
||||
log.error("IndexedDB error:", (event.target as IDBOpenDBRequest).error);
|
||||
reject("Error opening IndexedDB.");
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
db = (event.target as IDBOpenDBRequest).result;
|
||||
log.info("IndexedDB opened successfully.");
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
log.info("Upgrading IndexedDB...");
|
||||
const dbInstance = (event.target as IDBOpenDBRequest).result;
|
||||
if (!dbInstance.objectStoreNames.contains(STATE_STORE_NAME)) {
|
||||
dbInstance.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'});
|
||||
log.info("Object store created:", STATE_STORE_NAME);
|
||||
}
|
||||
if (!dbInstance.objectStoreNames.contains(IMAGE_STORE_NAME)) {
|
||||
dbInstance.createObjectStore(IMAGE_STORE_NAME, {keyPath: 'imageId'});
|
||||
log.info("Object store created:", IMAGE_STORE_NAME);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCanvasState(id: string): Promise<any | null> {
|
||||
log.info(`Getting state for id: ${id}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
const result = await createDBRequest(store, 'get', id, "Error getting canvas state") as CanvasStateDB;
|
||||
log.debug(`Get success for id: ${id}`, result ? 'found' : 'not found');
|
||||
return result ? result.state : null;
|
||||
}
|
||||
|
||||
export async function setCanvasState(id: string, state: any): Promise<void> {
|
||||
log.info(`Setting state for id: ${id}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'put', {id, state}, "Error setting canvas state");
|
||||
log.debug(`Set success for id: ${id}`);
|
||||
}
|
||||
|
||||
export async function removeCanvasState(id: string): Promise<void> {
|
||||
log.info(`Removing state for id: ${id}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'delete', id, "Error removing canvas state");
|
||||
log.debug(`Remove success for id: ${id}`);
|
||||
}
|
||||
|
||||
export async function saveImage(imageId: string, imageSrc: string | ImageBitmap): Promise<void> {
|
||||
log.info(`Saving image with id: ${imageId}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'put', {imageId, imageSrc}, "Error saving image");
|
||||
log.debug(`Image saved successfully for id: ${imageId}`);
|
||||
}
|
||||
|
||||
export async function getImage(imageId: string): Promise<string | ImageBitmap | null> {
|
||||
log.info(`Getting image with id: ${imageId}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
const result = await createDBRequest(store, 'get', imageId, "Error getting image") as CanvasImageDB;
|
||||
log.debug(`Get image success for id: ${imageId}`, result ? 'found' : 'not found');
|
||||
return result ? result.imageSrc : null;
|
||||
}
|
||||
|
||||
export async function removeImage(imageId: string): Promise<void> {
|
||||
log.info(`Removing image with id: ${imageId}`);
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'delete', imageId, "Error removing image");
|
||||
log.debug(`Remove image success for id: ${imageId}`);
|
||||
}
|
||||
|
||||
export async function getAllImageIds(): Promise<string[]> {
|
||||
log.info("Getting all image IDs...");
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly');
|
||||
const store = transaction.objectStore(IMAGE_STORE_NAME);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.getAllKeys();
|
||||
|
||||
request.onerror = (event) => {
|
||||
log.error("Error getting all image IDs:", (event.target as IDBRequest).error);
|
||||
reject("Error getting all image IDs");
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const imageIds = (event.target as IDBRequest).result;
|
||||
log.debug(`Found ${imageIds.length} image IDs in database`);
|
||||
resolve(imageIds);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearAllCanvasStates(): Promise<void> {
|
||||
log.info("Clearing all canvas states...");
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
|
||||
await createDBRequest(store, 'clear', null, "Error clearing canvas states");
|
||||
log.info("All canvas states cleared successfully.");
|
||||
}
|
||||
374
src/logger.ts
Normal file
374
src/logger.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Logger - Centralny system logowania dla ComfyUI-LayerForge
|
||||
*
|
||||
* Funkcje:
|
||||
* - Różne poziomy logowania (DEBUG, INFO, WARN, ERROR)
|
||||
* - Możliwość włączania/wyłączania logów globalnie lub per moduł
|
||||
* - Kolorowe logi w konsoli
|
||||
* - Możliwość zapisywania logów do localStorage
|
||||
* - Możliwość eksportu logów
|
||||
*/
|
||||
|
||||
function padStart(str: string, targetLength: number, padString: string): string {
|
||||
targetLength = targetLength >> 0;
|
||||
padString = String(padString || ' ');
|
||||
if (str.length > targetLength) {
|
||||
return String(str);
|
||||
} else {
|
||||
targetLength = targetLength - str.length;
|
||||
if (targetLength > padString.length) {
|
||||
padString += padString.repeat(targetLength / padString.length);
|
||||
}
|
||||
return padString.slice(0, targetLength) + String(str);
|
||||
}
|
||||
}
|
||||
|
||||
export const LogLevel = {
|
||||
DEBUG: 0,
|
||||
INFO: 1,
|
||||
WARN: 2,
|
||||
ERROR: 3,
|
||||
NONE: 4
|
||||
} as const;
|
||||
|
||||
export type LogLevels = typeof LogLevel[keyof typeof LogLevel];
|
||||
|
||||
interface LoggerConfig {
|
||||
globalLevel: LogLevels;
|
||||
moduleSettings: { [key: string]: LogLevels };
|
||||
useColors: boolean;
|
||||
saveToStorage: boolean;
|
||||
maxStoredLogs: number;
|
||||
timestampFormat: string;
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
interface LogData {
|
||||
timestamp: string;
|
||||
module: string;
|
||||
level: LogLevels;
|
||||
levelName: string;
|
||||
args: any[];
|
||||
time: Date;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: LoggerConfig = {
|
||||
globalLevel: LogLevel.INFO,
|
||||
moduleSettings: {},
|
||||
useColors: true,
|
||||
saveToStorage: false,
|
||||
maxStoredLogs: 1000,
|
||||
timestampFormat: 'HH:mm:ss',
|
||||
storageKey: 'layerforge_logs'
|
||||
};
|
||||
|
||||
const COLORS: { [key: number]: string } = {
|
||||
[LogLevel.DEBUG]: '#9e9e9e',
|
||||
[LogLevel.INFO]: '#2196f3',
|
||||
[LogLevel.WARN]: '#ff9800',
|
||||
[LogLevel.ERROR]: '#f44336',
|
||||
};
|
||||
|
||||
const LEVEL_NAMES: { [key: number]: string } = {
|
||||
[LogLevel.DEBUG]: 'DEBUG',
|
||||
[LogLevel.INFO]: 'INFO',
|
||||
[LogLevel.WARN]: 'WARN',
|
||||
[LogLevel.ERROR]: 'ERROR',
|
||||
};
|
||||
|
||||
class Logger {
|
||||
private config: LoggerConfig;
|
||||
private enabled: boolean;
|
||||
private logs: LogData[];
|
||||
constructor() {
|
||||
this.config = {...DEFAULT_CONFIG};
|
||||
this.logs = [];
|
||||
this.enabled = true;
|
||||
this.loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Konfiguracja loggera
|
||||
* @param {Partial<LoggerConfig>} config - Obiekt konfiguracyjny
|
||||
*/
|
||||
configure(config: Partial<LoggerConfig>): this {
|
||||
this.config = {...this.config, ...config};
|
||||
this.saveConfig();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Włącz/wyłącz logger globalnie
|
||||
* @param {boolean} enabled - Czy logger ma być włączony
|
||||
*/
|
||||
setEnabled(enabled: boolean): this {
|
||||
this.enabled = enabled;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustaw globalny poziom logowania
|
||||
* @param {LogLevels} level - Poziom logowania
|
||||
*/
|
||||
setGlobalLevel(level: LogLevels): this {
|
||||
this.config.globalLevel = level;
|
||||
this.saveConfig();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ustaw poziom logowania dla konkretnego modułu
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {LogLevels} level - Poziom logowania
|
||||
*/
|
||||
setModuleLevel(module: string, level: LogLevels): this {
|
||||
this.config.moduleSettings[module] = level;
|
||||
this.saveConfig();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdź, czy dany poziom logowania jest aktywny dla modułu
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {LogLevels} level - Poziom logowania do sprawdzenia
|
||||
* @returns {boolean} - Czy poziom jest aktywny
|
||||
*/
|
||||
isLevelEnabled(module: string, level: LogLevels): boolean {
|
||||
if (!this.enabled) return false;
|
||||
if (this.config.moduleSettings[module] !== undefined) {
|
||||
return level >= this.config.moduleSettings[module];
|
||||
}
|
||||
return level >= this.config.globalLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatuj znacznik czasu
|
||||
* @returns {string} - Sformatowany znacznik czasu
|
||||
*/
|
||||
formatTimestamp(): string {
|
||||
const now = new Date();
|
||||
const format = this.config.timestampFormat;
|
||||
return format
|
||||
.replace('HH', padStart(String(now.getHours()), 2, '0'))
|
||||
.replace('mm', padStart(String(now.getMinutes()), 2, '0'))
|
||||
.replace('ss', padStart(String(now.getSeconds()), 2, '0'))
|
||||
.replace('SSS', padStart(String(now.getMilliseconds()), 3, '0'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisz log
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {LogLevels} level - Poziom logowania
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
log(module: string, level: LogLevels, ...args: any[]): void {
|
||||
if (!this.isLevelEnabled(module, level)) return;
|
||||
|
||||
const timestamp = this.formatTimestamp();
|
||||
const levelName = LEVEL_NAMES[level];
|
||||
const logData: LogData = {
|
||||
timestamp,
|
||||
module,
|
||||
level,
|
||||
levelName,
|
||||
args,
|
||||
time: new Date()
|
||||
};
|
||||
if (this.config.saveToStorage) {
|
||||
this.logs.push(logData);
|
||||
if (this.logs.length > this.config.maxStoredLogs) {
|
||||
this.logs.shift();
|
||||
}
|
||||
this.saveLogs();
|
||||
}
|
||||
this.printToConsole(logData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wyświetl log w konsoli
|
||||
* @param {LogData} logData - Dane logu
|
||||
*/
|
||||
printToConsole(logData: LogData): void {
|
||||
const {timestamp, module, level, levelName, args} = logData;
|
||||
const prefix = `[${timestamp}] [${module}] [${levelName}]`;
|
||||
if (this.config.useColors && typeof console.log === 'function') {
|
||||
const color = COLORS[level] || '#000000';
|
||||
console.log(`%c${prefix}`, `color: ${color}; font-weight: bold;`, ...args);
|
||||
return;
|
||||
}
|
||||
console.log(prefix, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisz logi do localStorage
|
||||
*/
|
||||
saveLogs(): void {
|
||||
if (typeof localStorage !== 'undefined' && this.config.saveToStorage) {
|
||||
try {
|
||||
const simplifiedLogs = this.logs.map((log) => ({
|
||||
t: log.timestamp,
|
||||
m: log.module,
|
||||
l: log.level,
|
||||
a: log.args.map((arg: any) => {
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch (e) {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return arg;
|
||||
})
|
||||
}));
|
||||
|
||||
localStorage.setItem(this.config.storageKey, JSON.stringify(simplifiedLogs));
|
||||
} catch (e) {
|
||||
console.error('Failed to save logs to localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Załaduj logi z localStorage
|
||||
*/
|
||||
loadLogs(): void {
|
||||
if (typeof localStorage !== 'undefined' && this.config.saveToStorage) {
|
||||
try {
|
||||
const storedLogs = localStorage.getItem(this.config.storageKey);
|
||||
if (storedLogs) {
|
||||
this.logs = JSON.parse(storedLogs);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load logs from localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zapisz konfigurację do localStorage
|
||||
*/
|
||||
saveConfig(): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem('layerforge_logger_config', JSON.stringify(this.config));
|
||||
} catch (e) {
|
||||
console.error('Failed to save logger config to localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Załaduj konfigurację z localStorage
|
||||
*/
|
||||
loadConfig(): void {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
const storedConfig = localStorage.getItem('layerforge_logger_config');
|
||||
if (storedConfig) {
|
||||
this.config = {...this.config, ...JSON.parse(storedConfig)};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load logger config from localStorage:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wyczyść wszystkie logi
|
||||
*/
|
||||
clearLogs(): this {
|
||||
this.logs = [];
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(this.config.storageKey);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eksportuj logi do pliku
|
||||
* @param {'json' | 'txt'} format - Format eksportu
|
||||
*/
|
||||
exportLogs(format: 'json' | 'txt' = 'json'): void {
|
||||
if (this.logs.length === 0) {
|
||||
console.warn('No logs to export');
|
||||
return;
|
||||
}
|
||||
|
||||
let content: string;
|
||||
let mimeType: string;
|
||||
let extension: string;
|
||||
|
||||
if (format === 'json') {
|
||||
content = JSON.stringify(this.logs, null, 2);
|
||||
mimeType = 'application/json';
|
||||
extension = 'json';
|
||||
} else {
|
||||
content = this.logs.map((log) => `[${log.timestamp}] [${log.module}] [${log.levelName}] ${log.args.join(' ')}`).join('\n');
|
||||
mimeType = 'text/plain';
|
||||
extension = 'txt';
|
||||
}
|
||||
const blob = new Blob([content], {type: mimeType});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `layerforge_logs_${new Date().toISOString().replace(/[:.]/g, '-')}.${extension}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie DEBUG
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
debug(module: string, ...args: any[]): void {
|
||||
this.log(module, LogLevel.DEBUG, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie INFO
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
info(module: string, ...args: any[]): void {
|
||||
this.log(module, LogLevel.INFO, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie WARN
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
warn(module: string, ...args: any[]): void {
|
||||
this.log(module, LogLevel.WARN, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log na poziomie ERROR
|
||||
* @param {string} module - Nazwa modułu
|
||||
* @param {any[]} args - Argumenty do zalogowania
|
||||
*/
|
||||
error(module: string, ...args: any[]): void {
|
||||
this.log(module, LogLevel.ERROR, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = new Logger();
|
||||
export const debug = (module: string, ...args: any[]) => logger.debug(module, ...args);
|
||||
export const info = (module: string, ...args: any[]) => logger.info(module, ...args);
|
||||
export const warn = (module: string, ...args: any[]) => logger.warn(module, ...args);
|
||||
export const error = (module: string, ...args: any[]) => logger.error(module, ...args);
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
LayerForgeLogger: Logger;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.LayerForgeLogger = logger;
|
||||
}
|
||||
|
||||
export default logger;
|
||||
93
src/state-saver.worker.ts
Normal file
93
src/state-saver.worker.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
console.log('[StateWorker] Worker script loaded and running.');
|
||||
|
||||
const DB_NAME = 'CanvasNodeDB';
|
||||
const STATE_STORE_NAME = 'CanvasState';
|
||||
const DB_VERSION = 3;
|
||||
|
||||
let db: IDBDatabase | null;
|
||||
|
||||
function log(...args: any[]): void {
|
||||
console.log('[StateWorker]', ...args);
|
||||
}
|
||||
|
||||
function error(...args: any[]): void {
|
||||
console.error('[StateWorker]', ...args);
|
||||
}
|
||||
|
||||
function createDBRequest(store: IDBObjectStore, operation: 'put', data: any, errorMessage: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let request: IDBRequest;
|
||||
switch (operation) {
|
||||
case 'put':
|
||||
request = store.put(data);
|
||||
break;
|
||||
default:
|
||||
reject(new Error(`Unknown operation: ${operation}`));
|
||||
return;
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
error(errorMessage, (event.target as IDBRequest).error);
|
||||
reject(errorMessage);
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
resolve((event.target as IDBRequest).result);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function openDB(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (db) {
|
||||
resolve(db);
|
||||
return;
|
||||
}
|
||||
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = (event) => {
|
||||
error("IndexedDB error:", (event.target as IDBOpenDBRequest).error);
|
||||
reject("Error opening IndexedDB.");
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
db = (event.target as IDBOpenDBRequest).result;
|
||||
log("IndexedDB opened successfully in worker.");
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
log("Upgrading IndexedDB in worker...");
|
||||
const tempDb = (event.target as IDBOpenDBRequest).result;
|
||||
if (!tempDb.objectStoreNames.contains(STATE_STORE_NAME)) {
|
||||
tempDb.createObjectStore(STATE_STORE_NAME, {keyPath: 'id'});
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function setCanvasState(id: string, state: any): Promise<void> {
|
||||
const db = await openDB();
|
||||
const transaction = db.transaction([STATE_STORE_NAME], 'readwrite');
|
||||
const store = transaction.objectStore(STATE_STORE_NAME);
|
||||
await createDBRequest(store, 'put', {id, state}, "Error setting canvas state");
|
||||
}
|
||||
|
||||
self.onmessage = async function(e: MessageEvent<{ state: any, nodeId: string }>): Promise<void> {
|
||||
log('Message received from main thread:', e.data ? 'data received' : 'no data');
|
||||
const { state, nodeId } = e.data;
|
||||
|
||||
if (!state || !nodeId) {
|
||||
error('Invalid data received from main thread');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log(`Saving state for node: ${nodeId}`);
|
||||
await setCanvasState(nodeId, state);
|
||||
log(`State saved successfully for node: ${nodeId}`);
|
||||
} catch (err) {
|
||||
error(`Failed to save state for node: ${nodeId}`, err);
|
||||
}
|
||||
};
|
||||
13
src/templates/clipspace_clipboard_tooltip.html
Normal file
13
src/templates/clipspace_clipboard_tooltip.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<h4>📋 ComfyUI Clipspace Mode</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>ComfyUI Clipspace</strong> as flattened image</td></tr>
|
||||
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
|
||||
<tr><td></td><td>1️⃣ Internal clipboard (copied layers)</td></tr>
|
||||
<tr><td></td><td>2️⃣ ComfyUI Clipspace (workflow images)</td></tr>
|
||||
<tr><td></td><td>3️⃣ System clipboard (fallback)</td></tr>
|
||||
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
|
||||
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
|
||||
</table>
|
||||
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
|
||||
💡 <strong>Bestt for:</strong> ComfyUI workflow integration and node-to-node image transfer
|
||||
</div>
|
||||
9
src/templates/mask_shortcuts.html
Normal file
9
src/templates/mask_shortcuts.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<h4>Mask Mode</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Click + Drag</kbd></td><td>Paint on the mask</td></tr>
|
||||
<tr><td><kbd>Middle Mouse Button + Drag</kbd></td><td>Pan canvas view</td></tr>
|
||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
|
||||
<tr><td><strong>Brush Controls</strong></td><td>Use sliders to control brush <strong>Size</strong>, <strong>Strength</strong>, and <strong>Hardness</strong></td></tr>
|
||||
<tr><td><strong>Clear Mask</strong></td><td>Remove the entire mask</td></tr>
|
||||
<tr><td><strong>Exit Mode</strong></td><td>Click the "Draw Mask" button again</td></tr>
|
||||
</table>
|
||||
40
src/templates/standard_shortcuts.html
Normal file
40
src/templates/standard_shortcuts.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<h4>Canvas Control</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Click + Drag</kbd></td><td>Pan canvas view</td></tr>
|
||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
|
||||
<tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr>
|
||||
<tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr>
|
||||
<tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Clipboard & I/O</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layer(s)</td></tr>
|
||||
<tr><td><kbd>Ctrl + V</kbd></td><td>Paste from clipboard (image or internal layers)</td></tr>
|
||||
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Layer Interaction</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
|
||||
<tr><td><kbd>Ctrl + Click</kbd></td><td>Add/Remove layer from selection</td></tr>
|
||||
<tr><td><kbd>Alt + Drag</kbd></td><td>Clone selected layer(s)</td></tr>
|
||||
<tr><td><kbd>Right Click</kbd></td><td>Show blend mode & opacity menu</td></tr>
|
||||
<tr><td><kbd>Mouse Wheel</kbd></td><td>Scale layer (snaps to grid)</td></tr>
|
||||
<tr><td><kbd>Ctrl + Mouse Wheel</kbd></td><td>Fine-scale layer</td></tr>
|
||||
<tr><td><kbd>Shift + Mouse Wheel</kbd></td><td>Rotate layer by 5° steps</td></tr>
|
||||
<tr><td><kbd>Shift + Ctrl + Mouse Wheel</kbd></td><td>Snap rotation to 5° increments</td></tr>
|
||||
<tr><td><kbd>Arrow Keys</kbd></td><td>Nudge layer by 1px</td></tr>
|
||||
<tr><td><kbd>Shift + Arrow Keys</kbd></td><td>Nudge layer by 10px</td></tr>
|
||||
<tr><td><kbd>[</kbd> or <kbd>]</kbd></td><td>Rotate by 1°</td></tr>
|
||||
<tr><td><kbd>Shift + [</kbd> or <kbd>]</kbd></td><td>Rotate by 10°</td></tr>
|
||||
<tr><td><kbd>Delete</kbd></td><td>Delete selected layer(s)</td></tr>
|
||||
</table>
|
||||
|
||||
<h4>Transform Handles (on selected layer)</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Drag Corner/Side</kbd></td><td>Resize layer</td></tr>
|
||||
<tr><td><kbd>Drag Rotation Handle</kbd></td><td>Rotate layer</td></tr>
|
||||
<tr><td><kbd>Hold Shift</kbd></td><td>Keep aspect ratio / Snap rotation to 15°</td></tr>
|
||||
<tr><td><kbd>Hold Ctrl</kbd></td><td>Snap to grid</td></tr>
|
||||
</table>
|
||||
16
src/templates/system_clipboard_tooltip.html
Normal file
16
src/templates/system_clipboard_tooltip.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<h4>📋 System Clipboard Mode</h4>
|
||||
<table>
|
||||
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>system clipboard</strong> as flattened image</td></tr>
|
||||
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
|
||||
<tr><td></td><td>1️⃣ Internal clipboard (copied layers)</td></tr>
|
||||
<tr><td></td><td>2️⃣ System clipboard (images, screenshots)</td></tr>
|
||||
<tr><td></td><td>3️⃣ System clipboard (file paths, URLs)</td></tr>
|
||||
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
|
||||
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
|
||||
</table>
|
||||
<div style="margin-top: 8px; padding: 6px; background: rgba(255,165,0,0.2); border: 1px solid rgba(255,165,0,0.4); border-radius: 4px; font-size: 11px;">
|
||||
⚠️ <strong>Security Note:</strong> "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop.
|
||||
</div>
|
||||
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
|
||||
💡 <strong>Best for:</strong> Working with screenshots, copied images, file paths, and urls.
|
||||
</div>
|
||||
149
src/types.ts
Normal file
149
src/types.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { Canvas as CanvasClass } from './Canvas';
|
||||
import type { CanvasLayers } from './CanvasLayers';
|
||||
|
||||
export interface Layer {
|
||||
id: string;
|
||||
image: HTMLImageElement;
|
||||
imageId: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
rotation: number;
|
||||
zIndex: number;
|
||||
blendMode: string;
|
||||
opacity: number;
|
||||
mask?: Float32Array;
|
||||
flipH?: boolean;
|
||||
flipV?: boolean;
|
||||
}
|
||||
|
||||
export interface ComfyNode {
|
||||
id: number;
|
||||
imgs?: HTMLImageElement[];
|
||||
widgets: any[];
|
||||
size: [number, number];
|
||||
graph: any;
|
||||
canvasWidget?: any;
|
||||
onResize?: () => void;
|
||||
addDOMWidget: (name: string, type: string, element: HTMLElement, options?: any) => any;
|
||||
addWidget: (type: string, name: string, value: any, callback?: (value: any) => void, options?: any) => any;
|
||||
setDirtyCanvas: (force: boolean, dirty: boolean) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
MaskEditorDialog?: {
|
||||
instance?: {
|
||||
getMessageBroker: () => any;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface HTMLElement {
|
||||
getContext?(contextId: '2d', options?: any): CanvasRenderingContext2D | null;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface Canvas {
|
||||
layers: Layer[];
|
||||
selectedLayer: Layer | null;
|
||||
canvasSelection: any;
|
||||
lastMousePosition: Point;
|
||||
width: number;
|
||||
height: number;
|
||||
node: ComfyNode;
|
||||
viewport: { x: number, y: number, zoom: number };
|
||||
canvas: HTMLCanvasElement;
|
||||
offscreenCanvas: HTMLCanvasElement;
|
||||
isMouseOver: boolean;
|
||||
maskTool: any;
|
||||
canvasLayersPanel: any;
|
||||
canvasState: any;
|
||||
widget?: { value: string };
|
||||
imageReferenceManager: any;
|
||||
imageCache: any;
|
||||
dataInitialized: boolean;
|
||||
pendingDataCheck: number | null;
|
||||
pendingBatchContext: any;
|
||||
canvasLayers: any;
|
||||
saveState: () => void;
|
||||
render: () => void;
|
||||
updateSelection: (layers: Layer[]) => void;
|
||||
requestSaveState: (immediate?: boolean) => void;
|
||||
saveToServer: (fileName: string) => Promise<any>;
|
||||
removeLayersByIds: (ids: string[]) => void;
|
||||
batchPreviewManagers: any[];
|
||||
getMouseWorldCoordinates: (e: MouseEvent) => Point;
|
||||
getMouseViewCoordinates: (e: MouseEvent) => Point;
|
||||
updateOutputAreaSize: (width: number, height: number) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
}
|
||||
|
||||
// A simplified interface for the Canvas class, containing only what ClipboardManager needs.
|
||||
export interface CanvasForClipboard {
|
||||
canvasLayers: CanvasLayersForClipboard;
|
||||
node: ComfyNode;
|
||||
}
|
||||
|
||||
// A simplified interface for the CanvasLayers class.
|
||||
export interface CanvasLayersForClipboard {
|
||||
internalClipboard: Layer[];
|
||||
pasteLayers(): void;
|
||||
addLayerWithImage(image: HTMLImageElement, layerProps: Partial<Layer>, addMode: string): Promise<Layer | null>;
|
||||
}
|
||||
|
||||
export type AddMode = 'mouse' | 'fit' | 'center' | 'default';
|
||||
|
||||
export type ClipboardPreference = 'system' | 'clipspace';
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
nodeId?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface AckCallback {
|
||||
resolve: (value: WebSocketMessage | PromiseLike<WebSocketMessage>) => void;
|
||||
reject: (reason?: any) => void;
|
||||
}
|
||||
|
||||
export type AckCallbacks = Map<string, AckCallback>;
|
||||
|
||||
export interface CanvasState {
|
||||
layersUndoStack: Layer[][];
|
||||
layersRedoStack: Layer[][];
|
||||
maskUndoStack: HTMLCanvasElement[];
|
||||
maskRedoStack: HTMLCanvasElement[];
|
||||
saveMaskState(): void;
|
||||
}
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Viewport {
|
||||
x: number;
|
||||
y: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export interface Tensor {
|
||||
data: Float32Array;
|
||||
shape: number[];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface ImageDataPixel {
|
||||
data: Uint8ClampedArray;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
524
src/utils/ClipboardManager.ts
Normal file
524
src/utils/ClipboardManager.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
|
||||
// @ts-ignore
|
||||
import {api} from "../../../scripts/api.js";
|
||||
// @ts-ignore
|
||||
import {app} from "../../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import {ComfyApp} from "../../../scripts/app.js";
|
||||
|
||||
import type { AddMode, CanvasForClipboard, ClipboardPreference } from "../types.js";
|
||||
|
||||
const log = createModuleLogger('ClipboardManager');
|
||||
|
||||
export class ClipboardManager {
|
||||
canvas: CanvasForClipboard;
|
||||
clipboardPreference: ClipboardPreference;
|
||||
constructor(canvas: CanvasForClipboard) {
|
||||
this.canvas = canvas;
|
||||
this.clipboardPreference = 'system'; // 'system', 'clipspace'
|
||||
}
|
||||
|
||||
/**
|
||||
* Main paste handler that delegates to appropriate methods
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @param {ClipboardPreference} preference - Clipboard preference ('system' or 'clipspace')
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async handlePaste(addMode: AddMode = 'mouse', preference: ClipboardPreference = 'system'): Promise<boolean> {
|
||||
try {
|
||||
log.info(`ClipboardManager handling paste with preference: ${preference}`);
|
||||
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
log.info("Found layers in internal clipboard, pasting layers");
|
||||
this.canvas.canvasLayers.pasteLayers();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preference === 'clipspace') {
|
||||
log.info("Attempting paste from ComfyUI Clipspace");
|
||||
const success = await this.tryClipspacePaste(addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
log.info("No image found in ComfyUI Clipspace");
|
||||
}
|
||||
|
||||
log.info("Attempting paste from system clipboard");
|
||||
return await this.trySystemClipboardPaste(addMode);
|
||||
|
||||
} catch (err) {
|
||||
log.error("ClipboardManager paste operation failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to paste from ComfyUI Clipspace
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async tryClipspacePaste(addMode: AddMode): Promise<boolean> {
|
||||
try {
|
||||
log.info("Attempting to paste from ComfyUI Clipspace");
|
||||
ComfyApp.pasteFromClipspace(this.canvas.node);
|
||||
|
||||
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
||||
const clipspaceImage = this.canvas.node.imgs[0];
|
||||
if (clipspaceImage && clipspaceImage.src) {
|
||||
log.info("Successfully got image from ComfyUI Clipspace");
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
};
|
||||
img.src = clipspaceImage.src;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (clipspaceError) {
|
||||
log.warn("ComfyUI Clipspace paste failed:", clipspaceError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* System clipboard paste - handles both image data and text paths
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async trySystemClipboardPaste(addMode: AddMode): Promise<boolean> {
|
||||
log.info("ClipboardManager: Checking system clipboard for images and paths");
|
||||
|
||||
if (navigator.clipboard?.read) {
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
|
||||
for (const item of clipboardItems) {
|
||||
log.debug("Clipboard item types:", item.types);
|
||||
|
||||
const imageType = item.types.find(type => type.startsWith('image/'));
|
||||
if (imageType) {
|
||||
try {
|
||||
const blob = await item.getType(imageType);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from system clipboard");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
};
|
||||
if (event.target?.result) {
|
||||
img.src = event.target.result as string;
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
log.info("Found image data in system clipboard");
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.debug("Error reading image data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const textTypes = ['text/plain', 'text/uri-list'];
|
||||
for (const textType of textTypes) {
|
||||
if (item.types.includes(textType)) {
|
||||
try {
|
||||
const textBlob = await item.getType(textType);
|
||||
const text = await textBlob.text();
|
||||
|
||||
if (this.isValidImagePath(text)) {
|
||||
log.info("Found image path in clipboard:", text);
|
||||
const success = await this.loadImageFromPath(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug(`Error reading ${textType}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug("Modern clipboard API failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.readText) {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
log.debug("Found text in clipboard:", text);
|
||||
|
||||
if (text && this.isValidImagePath(text)) {
|
||||
log.info("Found valid image path in clipboard:", text);
|
||||
const success = await this.loadImageFromPath(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug("Could not read text from clipboard:", error);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("No images or valid image paths found in system clipboard");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validates if a text string is a valid image file path or URL
|
||||
* @param {string} text - The text to validate
|
||||
* @returns {boolean} - True if the text appears to be a valid image file path or URL
|
||||
*/
|
||||
isValidImagePath(text: string): boolean {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
text = text.trim();
|
||||
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) {
|
||||
|
||||
try {
|
||||
new URL(text);
|
||||
log.debug("Detected valid URL:", text);
|
||||
return true;
|
||||
} catch (e) {
|
||||
log.debug("Invalid URL format:", text);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const imageExtensions = [
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp',
|
||||
'.svg', '.tiff', '.tif', '.ico', '.avif'
|
||||
];
|
||||
|
||||
const hasImageExtension = imageExtensions.some(ext =>
|
||||
text.toLowerCase().endsWith(ext)
|
||||
);
|
||||
|
||||
if (!hasImageExtension) {
|
||||
log.debug("No valid image extension found in:", text);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
const pathPatterns = [
|
||||
/^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...)
|
||||
/^[\\\/]/, // Unix absolute path (/...)
|
||||
/^\.{1,2}[\\\/]/, // Relative path (./... or ../...)
|
||||
/^[^\\\/]*[\\\/]/ // Contains path separators
|
||||
];
|
||||
|
||||
const isValidPath = pathPatterns.some(pattern => pattern.test(text)) ||
|
||||
(!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename
|
||||
|
||||
if (isValidPath) {
|
||||
log.debug("Detected valid local file path:", text);
|
||||
} else {
|
||||
log.debug("Invalid local file path format:", text);
|
||||
}
|
||||
|
||||
return isValidPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load an image from a file path using simplified methods
|
||||
* @param {string} filePath - The file path to load
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async loadImageFromPath(filePath: string, addMode: AddMode): Promise<boolean> {
|
||||
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
return new Promise((resolve) => {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from URL");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load image from URL:", filePath);
|
||||
resolve(false);
|
||||
};
|
||||
img.src = filePath;
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn("Error loading image from URL:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("Attempting to load local file via backend");
|
||||
const success = await this.loadFileViaBackend(filePath, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Backend loading failed:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("Falling back to file picker");
|
||||
const success = await this.promptUserForFile(filePath, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("File picker failed:", error);
|
||||
}
|
||||
|
||||
this.showFilePathMessage(filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a local file via the ComfyUI backend endpoint
|
||||
* @param {string} filePath - The file path to load
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async loadFileViaBackend(filePath: string, addMode: AddMode): Promise<boolean> {
|
||||
try {
|
||||
log.info("Loading file via ComfyUI backend:", filePath);
|
||||
|
||||
const response = await api.fetchApi("/ycnode/load_image_from_path", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
log.debug("Backend failed to load image:", errorData.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
log.debug("Backend returned error:", data.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info("Successfully loaded image via ComfyUI backend:", filePath);
|
||||
|
||||
const img = new Image();
|
||||
const success: boolean = await new Promise((resolve) => {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from backend response");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load image from backend response");
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
img.src = data.image_data;
|
||||
});
|
||||
|
||||
return success;
|
||||
|
||||
} catch (error) {
|
||||
log.debug("Error loading file via ComfyUI backend:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to select a file when a local path is detected
|
||||
* @param {string} originalPath - The original file path from clipboard
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async promptUserForFile(originalPath: string, addMode: AddMode): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
|
||||
const fileName = originalPath.split(/[\\\/]/).pop();
|
||||
|
||||
fileInput.onchange = async (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from file picker");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load selected image");
|
||||
resolve(false);
|
||||
};
|
||||
if (e.target?.result) {
|
||||
img.src = e.target.result as string;
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
log.warn("Failed to read selected file");
|
||||
resolve(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
log.warn("Error processing selected file:", error);
|
||||
resolve(false);
|
||||
}
|
||||
} else {
|
||||
log.warn("Selected file is not an image");
|
||||
resolve(false);
|
||||
}
|
||||
|
||||
document.body.removeChild(fileInput);
|
||||
};
|
||||
|
||||
fileInput.oncancel = () => {
|
||||
log.info("File selection cancelled by user");
|
||||
document.body.removeChild(fileInput);
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
|
||||
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a message to the user about file path limitations
|
||||
* @param {string} filePath - The file path that couldn't be loaded
|
||||
*/
|
||||
showFilePathMessage(filePath: string): void {
|
||||
const fileName = filePath.split(/[\\\/]/).pop();
|
||||
const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`;
|
||||
this.showNotification(message, 5000);
|
||||
log.info("Showed file path limitation message to user");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a helpful message when clipboard appears empty and offers file picker
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
*/
|
||||
showEmptyClipboardMessage(addMode: AddMode): void {
|
||||
const message = `Copied a file? Browser can't access file paths for security. Click here to select the file manually.`;
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #2d5aa0;
|
||||
color: white;
|
||||
padding: 14px 18px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
z-index: 10001;
|
||||
max-width: 320px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
border: 2px solid #4a7bc8;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
`;
|
||||
notification.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 18px;">📁</span>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; opacity: 0.9; margin-top: 4px;">
|
||||
💡 Tip: You can also drag & drop files directly onto the canvas
|
||||
</div>
|
||||
`;
|
||||
|
||||
notification.onmouseenter = () => {
|
||||
notification.style.backgroundColor = '#3d6bb0';
|
||||
notification.style.borderColor = '#5a8bd8';
|
||||
notification.style.transform = 'translateY(-1px)';
|
||||
};
|
||||
notification.onmouseleave = () => {
|
||||
notification.style.backgroundColor = '#2d5aa0';
|
||||
notification.style.borderColor = '#4a7bc8';
|
||||
notification.style.transform = 'translateY(0)';
|
||||
};
|
||||
|
||||
notification.onclick = async () => {
|
||||
document.body.removeChild(notification);
|
||||
try {
|
||||
const success = await this.promptUserForFile('image_file.jpg', addMode);
|
||||
if (success) {
|
||||
log.info("Successfully loaded image via empty clipboard file picker");
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Error with empty clipboard file picker:", error);
|
||||
}
|
||||
};
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 12000);
|
||||
|
||||
log.info("Showed enhanced empty clipboard message with file picker option");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a temporary notification to the user
|
||||
* @param {string} message - The message to show
|
||||
* @param {number} duration - Duration in milliseconds
|
||||
*/
|
||||
showNotification(message: string, duration = 3000): void {
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
z-index: 10001;
|
||||
max-width: 300px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
notification.textContent = message;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
291
src/utils/CommonUtils.ts
Normal file
291
src/utils/CommonUtils.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import type { Layer } from '../types';
|
||||
|
||||
/**
|
||||
* CommonUtils - Wspólne funkcje pomocnicze
|
||||
* Eliminuje duplikację funkcji używanych w różnych modułach
|
||||
*/
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generuje unikalny identyfikator UUID
|
||||
* @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
*/
|
||||
export function generateUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja snap do siatki
|
||||
* @param {number} value - Wartość do przyciągnięcia
|
||||
* @param {number} gridSize - Rozmiar siatki (domyślnie 64)
|
||||
* @returns {number} Wartość przyciągnięta do siatki
|
||||
*/
|
||||
export function snapToGrid(value: number, gridSize = 64): number {
|
||||
return Math.round(value / gridSize) * gridSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Oblicza dostosowanie snap dla warstwy
|
||||
* @param {Object} layer - Obiekt warstwy
|
||||
* @param {number} gridSize - Rozmiar siatki
|
||||
* @param {number} snapThreshold - Próg przyciągania
|
||||
* @returns {Point} Obiekt z dx i dy
|
||||
*/
|
||||
export function getSnapAdjustment(layer: Layer, gridSize = 64, snapThreshold = 10): Point {
|
||||
if (!layer) {
|
||||
return {x: 0, y: 0};
|
||||
}
|
||||
|
||||
const layerEdges = {
|
||||
left: layer.x,
|
||||
right: layer.x + layer.width,
|
||||
top: layer.y,
|
||||
bottom: layer.y + layer.height
|
||||
};
|
||||
|
||||
const x_adjustments = [
|
||||
{type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left},
|
||||
{type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right}
|
||||
].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
|
||||
|
||||
const y_adjustments = [
|
||||
{type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top},
|
||||
{type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom}
|
||||
].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
|
||||
|
||||
const bestXSnap = x_adjustments
|
||||
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
||||
.sort((a, b) => a.abs - b.abs)[0];
|
||||
const bestYSnap = y_adjustments
|
||||
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
||||
.sort((a, b) => a.abs - b.abs)[0];
|
||||
|
||||
return {
|
||||
x: bestXSnap ? bestXSnap.delta : 0,
|
||||
y: bestYSnap ? bestYSnap.delta : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje współrzędne świata na lokalne
|
||||
* @param {number} worldX - Współrzędna X w świecie
|
||||
* @param {number} worldY - Współrzędna Y w świecie
|
||||
* @param {any} layerProps - Właściwości warstwy
|
||||
* @returns {Point} Lokalne współrzędne {x, y}
|
||||
*/
|
||||
export function worldToLocal(worldX: number, worldY: number, layerProps: { centerX: number, centerY: number, rotation: number }): Point {
|
||||
const dx = worldX - layerProps.centerX;
|
||||
const dy = worldY - layerProps.centerY;
|
||||
const rad = -layerProps.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
return {
|
||||
x: dx * cos - dy * sin,
|
||||
y: dx * sin + dy * cos
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje współrzędne lokalne na świat
|
||||
* @param {number} localX - Lokalna współrzędna X
|
||||
* @param {number} localY - Lokalna współrzędna Y
|
||||
* @param {any} layerProps - Właściwości warstwy
|
||||
* @returns {Point} Współrzędne świata {x, y}
|
||||
*/
|
||||
export function localToWorld(localX: number, localY: number, layerProps: { centerX: number, centerY: number, rotation: number }): Point {
|
||||
const rad = layerProps.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
return {
|
||||
x: layerProps.centerX + localX * cos - localY * sin,
|
||||
y: layerProps.centerY + localX * sin + localY * cos
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Klonuje warstwy (bez klonowania obiektów Image dla oszczędności pamięci)
|
||||
* @param {Layer[]} layers - Tablica warstw do sklonowania
|
||||
* @returns {Layer[]} Sklonowane warstwy
|
||||
*/
|
||||
export function cloneLayers(layers: Layer[]): Layer[] {
|
||||
return layers.map(layer => ({ ...layer }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy sygnaturę stanu warstw (dla porównań)
|
||||
* @param {Layer[]} layers - Tablica warstw
|
||||
* @returns {string} Sygnatura JSON
|
||||
*/
|
||||
export function getStateSignature(layers: Layer[]): string {
|
||||
return JSON.stringify(layers.map((layer, index) => {
|
||||
const sig: any = {
|
||||
index: index,
|
||||
x: Math.round(layer.x * 100) / 100, // Round to avoid floating point precision issues
|
||||
y: Math.round(layer.y * 100) / 100,
|
||||
width: Math.round(layer.width * 100) / 100,
|
||||
height: Math.round(layer.height * 100) / 100,
|
||||
rotation: Math.round((layer.rotation || 0) * 100) / 100,
|
||||
zIndex: layer.zIndex,
|
||||
blendMode: layer.blendMode || 'normal',
|
||||
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1,
|
||||
flipH: !!layer.flipH,
|
||||
flipV: !!layer.flipV
|
||||
};
|
||||
|
||||
if (layer.imageId) {
|
||||
sig.imageId = layer.imageId;
|
||||
}
|
||||
|
||||
if (layer.image && layer.image.src) {
|
||||
sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures
|
||||
}
|
||||
|
||||
return sig;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce funkcja - opóźnia wykonanie funkcji
|
||||
* @param {Function} func - Funkcja do wykonania
|
||||
* @param {number} wait - Czas oczekiwania w ms
|
||||
* @param {boolean} immediate - Czy wykonać natychmiast
|
||||
* @returns {(...args: any[]) => void} Funkcja z debounce
|
||||
*/
|
||||
export function debounce(func: (...args: any[]) => void, wait: number, immediate?: boolean): (...args: any[]) => void {
|
||||
let timeout: number | null;
|
||||
return function executedFunction(this: any, ...args: any[]) {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
if (!immediate) func.apply(this, args);
|
||||
};
|
||||
const callNow = immediate && !timeout;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = window.setTimeout(later, wait);
|
||||
if (callNow) func.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle funkcja - ogranicza częstotliwość wykonania
|
||||
* @param {Function} func - Funkcja do wykonania
|
||||
* @param {number} limit - Limit czasu w ms
|
||||
* @returns {(...args: any[]) => void} Funkcja z throttle
|
||||
*/
|
||||
export function throttle(func: (...args: any[]) => void, limit: number): (...args: any[]) => void {
|
||||
let inThrottle: boolean;
|
||||
return function(this: any, ...args: any[]) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ogranicza wartość do zakresu
|
||||
* @param {number} value - Wartość do ograniczenia
|
||||
* @param {number} min - Minimalna wartość
|
||||
* @param {number} max - Maksymalna wartość
|
||||
* @returns {number} Ograniczona wartość
|
||||
*/
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolacja liniowa między dwoma wartościami
|
||||
* @param {number} start - Wartość początkowa
|
||||
* @param {number} end - Wartość końcowa
|
||||
* @param {number} factor - Współczynnik interpolacji (0-1)
|
||||
* @returns {number} Interpolowana wartość
|
||||
*/
|
||||
export function lerp(start: number, end: number, factor: number): number {
|
||||
return start + (end - start) * factor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje stopnie na radiany
|
||||
* @param {number} degrees - Stopnie
|
||||
* @returns {number} Radiany
|
||||
*/
|
||||
export function degreesToRadians(degrees: number): number {
|
||||
return degrees * Math.PI / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje radiany na stopnie
|
||||
* @param {number} radians - Radiany
|
||||
* @returns {number} Stopnie
|
||||
*/
|
||||
export function radiansToDegrees(radians: number): number {
|
||||
return radians * 180 / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy canvas z kontekstem - eliminuje duplikaty w kodzie
|
||||
* @param {number} width - Szerokość canvas
|
||||
* @param {number} height - Wysokość canvas
|
||||
* @param {string} contextType - Typ kontekstu (domyślnie '2d')
|
||||
* @param {object} contextOptions - Opcje kontekstu
|
||||
* @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null}} Obiekt z canvas i ctx
|
||||
*/
|
||||
export function createCanvas(width: number, height: number, contextType = '2d', contextOptions: any = {}): { canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null } {
|
||||
const canvas = document.createElement('canvas');
|
||||
if (width) canvas.width = width;
|
||||
if (height) canvas.height = height;
|
||||
const ctx = canvas.getContext(contextType, contextOptions) as CanvasRenderingContext2D | null;
|
||||
return { canvas, ctx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizuje wartość do zakresu Uint8 (0-255)
|
||||
* @param {number} value - Wartość do znormalizowania (0-1)
|
||||
* @returns {number} Wartość w zakresie 0-255
|
||||
*/
|
||||
export function normalizeToUint8(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generuje unikalną nazwę pliku z identyfikatorem node-a
|
||||
* @param {string} baseName - Podstawowa nazwa pliku
|
||||
* @param {string | number} nodeId - Identyfikator node-a
|
||||
* @returns {string} Unikalna nazwa pliku
|
||||
*/
|
||||
export function generateUniqueFileName(baseName: string, nodeId: string | number): string {
|
||||
const nodePattern = new RegExp(`_node_${nodeId}(?:_node_\\d+)*`);
|
||||
if (nodePattern.test(baseName)) {
|
||||
const cleanName = baseName.replace(/_node_\d+/g, '');
|
||||
const extension = cleanName.split('.').pop();
|
||||
const nameWithoutExt = cleanName.replace(`.${extension}`, '');
|
||||
return `${nameWithoutExt}_node_${nodeId}.${extension}`;
|
||||
}
|
||||
const extension = baseName.split('.').pop();
|
||||
const nameWithoutExt = baseName.replace(`.${extension}`, '');
|
||||
return `${nameWithoutExt}_node_${nodeId}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdza czy punkt jest w prostokącie
|
||||
* @param {number} pointX - X punktu
|
||||
* @param {number} pointY - Y punktu
|
||||
* @param {number} rectX - X prostokąta
|
||||
* @param {number} rectY - Y prostokąta
|
||||
* @param {number} rectWidth - Szerokość prostokąta
|
||||
* @param {number} rectHeight - Wysokość prostokąta
|
||||
* @returns {boolean} Czy punkt jest w prostokącie
|
||||
*/
|
||||
export function isPointInRect(pointX: number, pointY: number, rectX: number, rectY: number, rectWidth: number, rectHeight: number): boolean {
|
||||
return pointX >= rectX && pointX <= rectX + rectWidth &&
|
||||
pointY >= rectY && pointY <= rectY + rectHeight;
|
||||
}
|
||||
353
src/utils/ImageUtils.ts
Normal file
353
src/utils/ImageUtils.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
|
||||
import type { Tensor, ImageDataPixel } from '../types';
|
||||
|
||||
const log = createModuleLogger('ImageUtils');
|
||||
|
||||
export function validateImageData(data: any): boolean {
|
||||
log.debug("Validating data structure:", {
|
||||
hasData: !!data,
|
||||
type: typeof data,
|
||||
isArray: Array.isArray(data),
|
||||
keys: data ? Object.keys(data) : null,
|
||||
shape: data?.shape,
|
||||
dataType: data?.data ? data.data.constructor.name : null,
|
||||
fullData: data
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
log.info("Data is null or undefined");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
log.debug("Data is array, getting first element");
|
||||
data = data[0];
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
log.info("Invalid data type");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.data) {
|
||||
log.info("Missing data property");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(data.data instanceof Float32Array)) {
|
||||
try {
|
||||
data.data = new Float32Array(data.data);
|
||||
} catch (e) {
|
||||
log.error("Failed to convert data to Float32Array:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function convertImageData(data: any): ImageDataPixel {
|
||||
log.info("Converting image data:", data);
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
data = data[0];
|
||||
}
|
||||
|
||||
const shape = data.shape;
|
||||
const height = shape[1];
|
||||
const width = shape[2];
|
||||
const channels = shape[3];
|
||||
const floatData = new Float32Array(data.data);
|
||||
|
||||
log.debug("Processing dimensions:", {height, width, channels});
|
||||
|
||||
const rgbaData = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
for (let h = 0; h < height; h++) {
|
||||
for (let w = 0; w < width; w++) {
|
||||
const pixelIndex = (h * width + w) * 4;
|
||||
const tensorIndex = (h * width + w) * channels;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const value = floatData[tensorIndex + c];
|
||||
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
rgbaData[pixelIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: rgbaData,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMaskToImageData(imageData: ImageDataPixel, maskData: Tensor): ImageDataPixel {
|
||||
log.info("Applying mask to image data");
|
||||
|
||||
const rgbaData = new Uint8ClampedArray(imageData.data);
|
||||
const width = imageData.width;
|
||||
const height = imageData.height;
|
||||
|
||||
const maskShape = maskData.shape;
|
||||
const maskFloatData = new Float32Array(maskData.data);
|
||||
|
||||
log.debug(`Applying mask of shape: ${maskShape}`);
|
||||
|
||||
for (let h = 0; h < height; h++) {
|
||||
for (let w = 0; w < width; w++) {
|
||||
const pixelIndex = (h * width + w) * 4;
|
||||
const maskIndex = h * width + w;
|
||||
|
||||
const alpha = maskFloatData[maskIndex];
|
||||
rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255)));
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Mask application completed");
|
||||
|
||||
return {
|
||||
data: rgbaData,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
|
||||
export const prepareImageForCanvas = withErrorHandling(function (inputImage: any): ImageDataPixel {
|
||||
log.info("Preparing image for canvas:", inputImage);
|
||||
|
||||
if (Array.isArray(inputImage)) {
|
||||
inputImage = inputImage[0];
|
||||
}
|
||||
|
||||
if (!inputImage || !inputImage.shape || !inputImage.data) {
|
||||
throw createValidationError("Invalid input image format", {inputImage});
|
||||
}
|
||||
|
||||
const shape = inputImage.shape;
|
||||
const height = shape[1];
|
||||
const width = shape[2];
|
||||
const channels = shape[3];
|
||||
const floatData = new Float32Array(inputImage.data);
|
||||
|
||||
log.debug("Image dimensions:", {height, width, channels});
|
||||
|
||||
const rgbaData = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
for (let h = 0; h < height; h++) {
|
||||
for (let w = 0; w < width; w++) {
|
||||
const pixelIndex = (h * width + w) * 4;
|
||||
const tensorIndex = (h * width + w) * channels;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const value = floatData[tensorIndex + c];
|
||||
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
rgbaData[pixelIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: rgbaData,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}, 'prepareImageForCanvas');
|
||||
|
||||
export const imageToTensor = withErrorHandling(async function (image: HTMLImageElement | HTMLCanvasElement): Promise<Tensor> {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = new Float32Array(canvas.width * canvas.height * 3);
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
const pixelIndex = i / 4;
|
||||
data[pixelIndex * 3] = imageData.data[i] / 255;
|
||||
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
|
||||
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
|
||||
}
|
||||
|
||||
return {
|
||||
data: data,
|
||||
shape: [1, canvas.height, canvas.width, 3],
|
||||
width: canvas.width,
|
||||
height: canvas.height
|
||||
};
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'imageToTensor');
|
||||
|
||||
export const tensorToImage = withErrorHandling(async function (tensor: Tensor): Promise<HTMLImageElement> {
|
||||
if (!tensor || !tensor.data || !tensor.shape) {
|
||||
throw createValidationError("Invalid tensor format", {tensor});
|
||||
}
|
||||
|
||||
const [, height, width, channels] = tensor.shape;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = tensor.data;
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
const tensorIndex = i * channels;
|
||||
|
||||
imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255);
|
||||
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255);
|
||||
imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
|
||||
imageData.data[pixelIndex + 3] = 255;
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'tensorToImage');
|
||||
|
||||
export const resizeImage = withErrorHandling(async function (image: HTMLImageElement, maxWidth: number, maxHeight: number): Promise<HTMLImageElement> {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
const originalWidth = image.width;
|
||||
const originalHeight = image.height;
|
||||
const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
|
||||
const newWidth = Math.round(originalWidth * scale);
|
||||
const newHeight = Math.round(originalHeight * scale);
|
||||
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
|
||||
if (ctx) {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(image, 0, 0, newWidth, newHeight);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'resizeImage');
|
||||
|
||||
export const createThumbnail = withErrorHandling(async function (image: HTMLImageElement, size = 128): Promise<HTMLImageElement> {
|
||||
return resizeImage(image, size, size);
|
||||
}, 'createThumbnail');
|
||||
|
||||
export const imageToBase64 = withErrorHandling(function (image: HTMLImageElement | HTMLCanvasElement, format = 'png', quality = 0.9): string {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
canvas.width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
|
||||
canvas.height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
|
||||
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const mimeType = `image/${format}`;
|
||||
return canvas.toDataURL(mimeType, quality);
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'imageToBase64');
|
||||
|
||||
export const base64ToImage = withErrorHandling(function (base64: string): Promise<HTMLImageElement> {
|
||||
if (!base64) {
|
||||
throw createValidationError("Base64 string is required");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Failed to load image from base64"));
|
||||
img.src = base64;
|
||||
});
|
||||
}, 'base64ToImage');
|
||||
|
||||
export function isValidImage(image: any): image is HTMLImageElement | HTMLCanvasElement {
|
||||
return image &&
|
||||
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
|
||||
image.width > 0 &&
|
||||
image.height > 0;
|
||||
}
|
||||
|
||||
export function getImageInfo(image: HTMLImageElement | HTMLCanvasElement): {width: number, height: number, aspectRatio: number, area: number} | null {
|
||||
if (!isValidImage(image)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
|
||||
const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
aspectRatio: width / height,
|
||||
area: width * height
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageFromSource(source: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
export const createEmptyImage = withErrorHandling(function (width: number, height: number, color = 'transparent'): Promise<HTMLImageElement> {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
if (ctx) {
|
||||
if (color !== 'transparent') {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'createEmptyImage');
|
||||
92
src/utils/LoggerUtils.ts
Normal file
92
src/utils/LoggerUtils.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* LoggerUtils - Centralizacja inicjalizacji loggerów
|
||||
* Eliminuje powtarzalny kod inicjalizacji loggera w każdym module
|
||||
*/
|
||||
|
||||
import {logger, LogLevel} from "../logger.js";
|
||||
import { LOG_LEVEL } from '../config.js';
|
||||
|
||||
export interface Logger {
|
||||
debug: (...args: any[]) => void;
|
||||
info: (...args: any[]) => void;
|
||||
warn: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy obiekt loggera dla modułu z predefiniowanymi metodami
|
||||
* @param {string} moduleName - Nazwa modułu
|
||||
* @returns {Logger} Obiekt z metodami logowania
|
||||
*/
|
||||
export function createModuleLogger(moduleName: string): Logger {
|
||||
logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL as keyof typeof LogLevel]);
|
||||
|
||||
return {
|
||||
debug: (...args: any[]) => logger.debug(moduleName, ...args),
|
||||
info: (...args: any[]) => logger.info(moduleName, ...args),
|
||||
warn: (...args: any[]) => logger.warn(moduleName, ...args),
|
||||
error: (...args: any[]) => logger.error(moduleName, ...args)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL
|
||||
* @returns {Logger} Obiekt z metodami logowania
|
||||
*/
|
||||
export function createAutoLogger(): Logger {
|
||||
const stack = new Error().stack;
|
||||
const match = stack?.match(/\/([^\/]+)\.js/);
|
||||
const moduleName = match ? match[1] : 'Unknown';
|
||||
|
||||
return createModuleLogger(moduleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper dla operacji z automatycznym logowaniem błędów
|
||||
* @param {Function} operation - Operacja do wykonania
|
||||
* @param {Logger} log - Obiekt loggera
|
||||
* @param {string} operationName - Nazwa operacji (dla logów)
|
||||
* @returns {Function} Opakowana funkcja
|
||||
*/
|
||||
export function withErrorLogging<T extends (...args: any[]) => any>(
|
||||
operation: T,
|
||||
log: Logger,
|
||||
operationName: string
|
||||
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
|
||||
return async function(this: any, ...args: Parameters<T>): Promise<ReturnType<T>> {
|
||||
try {
|
||||
log.debug(`Starting ${operationName}`);
|
||||
const result = await operation.apply(this, args);
|
||||
log.debug(`Completed ${operationName}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
log.error(`Error in ${operationName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator dla metod klasy z automatycznym logowaniem
|
||||
* @param {Logger} log - Obiekt loggera
|
||||
* @param {string} methodName - Nazwa metody
|
||||
*/
|
||||
export function logMethod(log: Logger, methodName?: string) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
try {
|
||||
log.debug(`${methodName || propertyKey} started`);
|
||||
const result = await originalMethod.apply(this, args);
|
||||
log.debug(`${methodName || propertyKey} completed`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
log.error(`${methodName || propertyKey} failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
32
src/utils/ResourceManager.ts
Normal file
32
src/utils/ResourceManager.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// @ts-ignore
|
||||
import { $el } from "../../../scripts/ui.js";
|
||||
|
||||
export function addStylesheet(url: string): void {
|
||||
if (url.endsWith(".js")) {
|
||||
url = url.substr(0, url.length - 2) + "css";
|
||||
}
|
||||
$el("link", {
|
||||
parent: document.head,
|
||||
rel: "stylesheet",
|
||||
type: "text/css",
|
||||
href: url.startsWith("http") ? url : getUrl(url),
|
||||
});
|
||||
}
|
||||
|
||||
export function getUrl(path: string, baseUrl?: string | URL): string {
|
||||
if (baseUrl) {
|
||||
return new URL(path, baseUrl).toString();
|
||||
} else {
|
||||
// @ts-ignore
|
||||
return new URL("../" + path, import.meta.url).toString();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadTemplate(path: string, baseUrl?: string | URL): Promise<string> {
|
||||
const url = getUrl(path, baseUrl);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load template: ${url}`);
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
166
src/utils/WebSocketManager.ts
Normal file
166
src/utils/WebSocketManager.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import type { WebSocketMessage, AckCallbacks } from "../types.js";
|
||||
|
||||
const log = createModuleLogger('WebSocketManager');
|
||||
|
||||
class WebSocketManager {
|
||||
private socket: WebSocket | null;
|
||||
private messageQueue: string[];
|
||||
private isConnecting: boolean;
|
||||
private reconnectAttempts: number;
|
||||
private readonly maxReconnectAttempts: number;
|
||||
private readonly reconnectInterval: number;
|
||||
private ackCallbacks: AckCallbacks;
|
||||
private messageIdCounter: number;
|
||||
|
||||
constructor(private url: string) {
|
||||
this.socket = null;
|
||||
this.messageQueue = [];
|
||||
this.isConnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectInterval = 5000; // 5 seconds
|
||||
this.ackCallbacks = new Map();
|
||||
this.messageIdCounter = 0;
|
||||
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
log.debug("WebSocket is already open.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isConnecting) {
|
||||
log.debug("Connection attempt already in progress.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
log.info(`Connecting to WebSocket at ${this.url}...`);
|
||||
|
||||
try {
|
||||
this.socket = new WebSocket(this.url);
|
||||
|
||||
this.socket.onopen = () => {
|
||||
this.isConnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
log.info("WebSocket connection established.");
|
||||
this.flushMessageQueue();
|
||||
};
|
||||
|
||||
this.socket.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data: WebSocketMessage = JSON.parse(event.data);
|
||||
log.debug("Received message:", data);
|
||||
|
||||
if (data.type === 'ack' && data.nodeId) {
|
||||
const callback = this.ackCallbacks.get(data.nodeId);
|
||||
if (callback) {
|
||||
log.debug(`ACK received for nodeId: ${data.nodeId}, resolving promise.`);
|
||||
callback.resolve(data);
|
||||
this.ackCallbacks.delete(data.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log.error("Error parsing incoming WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onclose = (event: CloseEvent) => {
|
||||
this.isConnecting = false;
|
||||
if (event.wasClean) {
|
||||
log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`);
|
||||
} else {
|
||||
log.warn("WebSocket connection died. Attempting to reconnect...");
|
||||
this.handleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onerror = (error: Event) => {
|
||||
this.isConnecting = false;
|
||||
log.error("WebSocket error:", error);
|
||||
};
|
||||
} catch (error) {
|
||||
this.isConnecting = false;
|
||||
log.error("Failed to create WebSocket connection:", error);
|
||||
this.handleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
handleReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`);
|
||||
setTimeout(() => this.connect(), this.reconnectInterval);
|
||||
} else {
|
||||
log.error("Max reconnect attempts reached. Giving up.");
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(data: WebSocketMessage, requiresAck = false): Promise<WebSocketMessage | void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const nodeId = data.nodeId;
|
||||
if (requiresAck && !nodeId) {
|
||||
return reject(new Error("A nodeId is required for messages that need acknowledgment."));
|
||||
}
|
||||
|
||||
const message = JSON.stringify(data);
|
||||
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(message);
|
||||
log.debug("Sent message:", data);
|
||||
if (requiresAck && nodeId) {
|
||||
log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.ackCallbacks.delete(nodeId);
|
||||
reject(new Error(`ACK timeout for nodeId ${nodeId}`));
|
||||
log.warn(`ACK timeout for nodeId ${nodeId}.`);
|
||||
}, 10000); // 10-second timeout
|
||||
|
||||
this.ackCallbacks.set(nodeId, {
|
||||
resolve: (responseData: WebSocketMessage | PromiseLike<WebSocketMessage>) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(responseData);
|
||||
},
|
||||
reject: (error: any) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve(); // Resolve immediately if no ACK is needed
|
||||
}
|
||||
} else {
|
||||
log.warn("WebSocket not open. Queuing message.");
|
||||
this.messageQueue.push(message);
|
||||
if (!this.isConnecting) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
if (requiresAck) {
|
||||
reject(new Error("Cannot send message with ACK required while disconnected."));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
flushMessageQueue() {
|
||||
log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
|
||||
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift();
|
||||
if (this.socket && message) {
|
||||
this.socket.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`;
|
||||
export const webSocketManager = new WebSocketManager(wsUrl);
|
||||
196
src/utils/mask_utils.ts
Normal file
196
src/utils/mask_utils.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import type { Canvas } from '../Canvas.js';
|
||||
// @ts-ignore
|
||||
import {ComfyApp} from "../../../scripts/app.js";
|
||||
|
||||
const log = createModuleLogger('MaskUtils');
|
||||
|
||||
export function new_editor(app: ComfyApp): boolean {
|
||||
if (!app) return false;
|
||||
return !!app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
|
||||
}
|
||||
|
||||
function get_mask_editor_element(app: ComfyApp): HTMLElement | null {
|
||||
return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement ?? null;
|
||||
}
|
||||
|
||||
export function mask_editor_showing(app: ComfyApp): boolean {
|
||||
const editor = get_mask_editor_element(app);
|
||||
return !!editor && editor.style.display !== "none";
|
||||
}
|
||||
|
||||
export function hide_mask_editor(app: ComfyApp): void {
|
||||
if (mask_editor_showing(app)) {
|
||||
const editor = document.getElementById('maskEditor');
|
||||
if (editor) {
|
||||
editor.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function get_mask_editor_cancel_button(app: ComfyApp): HTMLElement | null {
|
||||
const cancelButton = document.getElementById("maskEditor_topBarCancelButton");
|
||||
if (cancelButton) {
|
||||
log.debug("Found cancel button by ID: maskEditor_topBarCancelButton");
|
||||
return cancelButton;
|
||||
}
|
||||
|
||||
const cancelSelectors = [
|
||||
'button[onclick*="cancel"]',
|
||||
'button[onclick*="Cancel"]',
|
||||
'input[value="Cancel"]'
|
||||
];
|
||||
|
||||
for (const selector of cancelSelectors) {
|
||||
try {
|
||||
const button = document.querySelector<HTMLElement>(selector);
|
||||
if (button) {
|
||||
log.debug("Found cancel button with selector:", selector);
|
||||
return button;
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn("Invalid selector:", selector, e);
|
||||
}
|
||||
}
|
||||
|
||||
const allButtons = document.querySelectorAll('button, input[type="button"]');
|
||||
for (const button of allButtons) {
|
||||
const text = (button as HTMLElement).textContent || (button as HTMLInputElement).value || '';
|
||||
if (text.toLowerCase().includes('cancel')) {
|
||||
log.debug("Found cancel button by text content:", text);
|
||||
return button as HTMLElement;
|
||||
}
|
||||
}
|
||||
|
||||
const editorElement = get_mask_editor_element(app);
|
||||
if (editorElement) {
|
||||
const childNodes = editorElement?.parentElement?.lastChild?.childNodes;
|
||||
if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) {
|
||||
return childNodes[2];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function get_mask_editor_save_button(app: ComfyApp): HTMLElement | null {
|
||||
const saveButton = document.getElementById("maskEditor_topBarSaveButton");
|
||||
if (saveButton) {
|
||||
return saveButton;
|
||||
}
|
||||
const editorElement = get_mask_editor_element(app);
|
||||
if (editorElement) {
|
||||
const childNodes = editorElement?.parentElement?.lastChild?.childNodes;
|
||||
if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) {
|
||||
return childNodes[2];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mask_editor_listen_for_cancel(app: ComfyApp, callback: () => void): void {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 sekund
|
||||
|
||||
const findAndAttachListener = () => {
|
||||
attempts++;
|
||||
const cancel_button = get_mask_editor_cancel_button(app);
|
||||
|
||||
if (cancel_button instanceof HTMLElement && !(cancel_button as any).filter_listener_added) {
|
||||
log.info("Cancel button found, attaching listener");
|
||||
cancel_button.addEventListener('click', callback);
|
||||
(cancel_button as any).filter_listener_added = true;
|
||||
} else if (attempts < maxAttempts) {
|
||||
|
||||
setTimeout(findAndAttachListener, 100);
|
||||
} else {
|
||||
log.warn("Could not find cancel button after", maxAttempts, "attempts");
|
||||
|
||||
const globalClickHandler = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const text = target.textContent || (target as HTMLInputElement).value || '';
|
||||
if (target && (text.toLowerCase().includes('cancel') ||
|
||||
target.id.toLowerCase().includes('cancel') ||
|
||||
target.className.toLowerCase().includes('cancel'))) {
|
||||
log.info("Cancel detected via global click handler");
|
||||
callback();
|
||||
document.removeEventListener('click', globalClickHandler);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', globalClickHandler);
|
||||
log.debug("Added global click handler for cancel detection");
|
||||
}
|
||||
};
|
||||
|
||||
findAndAttachListener();
|
||||
}
|
||||
|
||||
export function press_maskeditor_save(app: ComfyApp): void {
|
||||
const button = get_mask_editor_save_button(app);
|
||||
if (button instanceof HTMLElement) {
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
|
||||
export function press_maskeditor_cancel(app: ComfyApp): void {
|
||||
const button = get_mask_editor_cancel_button(app);
|
||||
if (button instanceof HTMLElement) {
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uruchamia mask editor z predefiniowaną maską
|
||||
* @param {Canvas} canvasInstance - Instancja Canvas
|
||||
* @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia
|
||||
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
|
||||
*/
|
||||
export function start_mask_editor_with_predefined_mask(canvasInstance: Canvas, maskImage: HTMLImageElement | HTMLCanvasElement, sendCleanImage = true): void {
|
||||
if (!canvasInstance || !maskImage) {
|
||||
log.error('Canvas instance and mask image are required');
|
||||
return;
|
||||
}
|
||||
|
||||
canvasInstance.startMaskEditor(maskImage, sendCleanImage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska)
|
||||
* @param {Canvas} canvasInstance - Instancja Canvas
|
||||
*/
|
||||
export function start_mask_editor_auto(canvasInstance: Canvas): void {
|
||||
if (!canvasInstance) {
|
||||
log.error('Canvas instance is required');
|
||||
return;
|
||||
}
|
||||
canvasInstance.startMaskEditor(null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy maskę z obrazu dla użycia w mask editorze
|
||||
* @param {string} imageSrc - Źródło obrazu (URL lub data URL)
|
||||
* @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
|
||||
*/
|
||||
export function create_mask_from_image_src(imageSrc: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = imageSrc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje canvas do Image dla użycia jako maska
|
||||
* @param {HTMLCanvasElement} canvas - Canvas do konwersji
|
||||
* @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
|
||||
*/
|
||||
export function canvas_to_mask_image(canvas: HTMLCanvasElement): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user