34 Commits
v1.5.3 ... dev

Author SHA1 Message Date
Dariusz L
4966069b67 Refactor mask editor cropping and formatting in Canvas.js
Updated the mask processing logic to crop the mask based on the viewport pan position instead of scaling, ensuring accurate mask editing. Also standardized object formatting in logging and context creation for consistency.
2025-07-02 00:11:45 +02:00
Dariusz L
faa60d4c28 Add LayerForge badge, workflows, and example workflow
Introduces a dynamic LayerForge downloads badge (LAYERFORGE.md), new GitHub Actions for badge generation and improved release versioning, and example workflow files. Updates README to use the new badge and bumps version to 1.3.0 in pyproject.toml. Adds issue templates for bug reports and documentation requests.
2025-07-01 18:02:52 +02:00
Dariusz L
70ab561c3c Set default log level to NONE in logger configuration
Changed the default log level from DEBUG to NONE in both Python and JavaScript logger utilities. This reduces log output unless explicitly configured otherwise.
2025-07-01 16:53:53 +02:00
Dariusz L
e4da6e4d31 Remove comments and cleanup event handling code
Removed redundant and explanatory comments from CanvasInteractions.js, CanvasLayers.js, and ClipboardManager.js to improve code readability. Deleted the REFACTORING_GUIDE.md documentation file. Minor code cleanups were made to event handler logic and UI widget setup, with no changes to core functionality.
2025-07-01 16:42:48 +02:00
Dariusz L
b2ff5666f9 Add draggable blend mode menu and improve right-click UX
Introduces a draggable title bar to the blend mode menu for better usability and restricts its movement within the viewport. Right-clicking on selected layers now opens the blend mode menu, and the default context menu is suppressed on the canvas. Also refines tooltip table cell sizing for improved display.
2025-07-01 16:37:18 +02:00
Dariusz L
5a473cc14a Group clipboard buttons and add styles in CanvasView
Clipboard-related buttons are now grouped in a styled .painter-clipboard-group container for improved UI clarity. New CSS styles were added for the group, and the DOM structure was updated to wrap the 'Paste Image' and clipboard toggle buttons together.
2025-07-01 11:28:30 +02:00
Dariusz L
0512200b92 Enhance clipboard and drag & drop image handling
Adds robust clipboard and drag & drop support for images, including ComfyUI Clipspace integration, system clipboard fallback, and improved user feedback. Clipboard and paste logic is centralized and clarified, with priority handling for internal clipboard, ComfyUI Clipspace, and system clipboard. Drag & drop is now handled at the canvas level, and tooltips and notifications provide clearer guidance for users.
2025-07-01 11:15:40 +02:00
Dariusz L
0f05e36333 Enhance image path validation to support URLs
Updated isValidImagePath to recognize and validate image URLs in addition to local file paths. The function now checks for valid URL formats and logs debug information for both URLs and local paths.
2025-07-01 08:01:37 +02:00
Dariusz L
94ffc64f6e Refactor clipboard paste logic into ClipboardManager
Moved all system clipboard paste and image path handling logic from CanvasLayers.js into a new ClipboardManager utility class. This improves code organization and separation of concerns, making clipboard-related functionality easier to maintain and extend.
2025-07-01 07:57:05 +02:00
Dariusz L
03e76d5ecd Add support for loading images from file paths
Introduces a backend API endpoint to load images from local file paths and return them as base64-encoded PNGs. Updates CanvasLayers.js to detect image file paths in the clipboard, validate them, and attempt to load images using multiple strategies, including the new backend endpoint, ComfyUI view endpoints, and a file picker fallback. Also updates canvas context creation to use the 'willReadFrequently' option for improved performance.
2025-07-01 07:46:06 +02:00
Dariusz L
02bac6c624 Use willReadFrequently in 2D canvas contexts
Updated all calls to getContext('2d') to include the { willReadFrequently: true } option. This change improves performance for frequent pixel read operations, particularly for mask and image processing tasks.
2025-07-01 07:44:14 +02:00
Dariusz L
cf10322101 Replace console statements with structured logging
Refactored Canvas.js to use the 'log' object for debug, info, and warn messages instead of console.log, console.warn, and console.error. Added more detailed and structured logging throughout the file to improve traceability and debugging, including context information for key operations such as state changes, layer management, mask editor interactions, and image processing.
2025-07-01 07:02:28 +02:00
Dariusz L
d7701fd989 Remove unused handleMaskEditorClose method and clean logs
Deleted the unused handleMaskEditorClose method and removed excessive console logging from widget visibility and mask application logic. Increased the default timeout for waitForWidget to 20000ms for improved reliability.
2025-07-01 06:56:17 +02:00
Dariusz L
b89956d2ba Refactor code style and remove mask editor examples
This commit applies consistent code style changes (mainly spacing and formatting) across multiple JS files for improved readability and maintainability. Additionally, it removes the unused 'js/examples/mask_editor_examples.js' file.
2025-07-01 06:44:26 +02:00
Dariusz L
a0ceb3b97c Remove auto mask loading docs and clean up code comments
Deleted documentation files related to automatic mask loading. Cleaned up and streamlined comments across Canvas.js, CanvasInteractions.js, CanvasLayers.js, CanvasView.js, MaskTool.js, mask_utils.js, and example scripts for improved clarity and maintainability. No functional changes to core logic.
2025-07-01 06:42:20 +02:00
Dariusz L
30fb89451f Refactor canvas preview visibility and widget loading
Refactored Canvas.js to use an async waitForWidget method for reliably finding widgets before manipulating them. The setPreviewVisibility method is now async and simplified, with excessive debug logging removed. CanvasState.js now triggers a render after saving state. In CanvasView.js, initial state loading is deferred and preview visibility is managed in the Canvas constructor, removing redundant calls.
2025-07-01 06:32:06 +02:00
Dariusz L
40c1dbfb5d Add show/hide preview option for canvas image preview
Introduces a new 'show_preview' boolean option to CanvasNode and UI, allowing users to toggle the visibility of the canvas image preview widget. Updates Canvas.js with a setPreviewVisibility method to control the preview's display and size, and hooks this logic into CanvasView.js to respond to the new option. Adds detailed debug logging for widget state and size changes.
2025-07-01 05:09:48 +02:00
Dariusz L
688acd72fd Add cancel support to mask editor with state restore
Implemented logic in Canvas.js to save and restore mask state when the mask editor is cancelled. Added robust detection and event listener setup for the cancel button in mask_utils.js, including multiple selector strategies and fallback mechanisms. This improves user experience by allowing users to revert changes if they cancel out of the mask editor.
2025-06-30 22:57:06 +02:00
Dariusz L
acef58291c Add automatic mask loading to mask editor
Introduces functionality for automatically applying predefined masks in the ComfyUI mask editor. Adds new API methods and utilities in Canvas.js and mask_utils.js, including support for sending a clean image, handling both new and old mask editors, and flexible mask formats. Includes documentation, usage examples, and helper functions for mask processing and error handling.
2025-06-30 21:52:40 +02:00
Dariusz L
8a800a4bee Replace MaskEditor menu option with custom handler
Removes the default 'Open in MaskEditor' option from the menu and adds a new custom option with improved error handling and logging. This ensures the MaskEditor is opened through a controlled callback, providing better user feedback in case of errors.
2025-06-30 01:49:06 +02:00
Dariusz L
ed62d8df78 Add documentation for LitegraphService and MaskEditor
Introduced two new documentation files: one detailing the main functions and class structure of LitegraphService, and another describing the classes and methods in MaskEditor. These documents provide an overview of available APIs, internal logic, and UI management for developers.
2025-06-30 01:32:20 +02:00
Dariusz L
2624cf02a2 Add clipboard source toggle for paste operations
Introduces a clipboardPreference setting to choose between system clipboard and ComfyUI Clipspace as the source for paste operations in CanvasLayers. Adds a UI button in CanvasView to toggle the clipboard source, improving user control over paste behavior.
2025-06-30 01:14:13 +02:00
Dariusz L
6718198a27 Add documentation summaries for ComfyApi and ComfyApp
Introduced two new documentation files: ComfyApi and ComfyApp. These files provide concise summaries of the main functions, properties, and event types for the ComfyApi and ComfyApp classes, aiding developers in understanding their usage and structure.
2025-06-30 00:35:37 +02:00
Dariusz L
62a5af4287 Enhance paste functionality and clipboard handling
Improves the paste operation in CanvasLayers to prioritize internal clipboard, then ComfyUI Clipspace, and finally the system clipboard, pasting images at the mouse position. Also clears the internal clipboard when the mouse leaves the canvas in CanvasInteractions.
2025-06-30 00:35:25 +02:00
Dariusz L
2eaa3d6620 Improve mask editor integration and mask application logic
Replaces the mask editor's image preparation to use a new method that combines the full image with the current mask, ensuring the editor starts with the correct state. Updates mask application logic to fully replace the mask area instead of blending, and refactors mask extraction and application in CanvasLayers for consistency and correctness, including a new getFlattenedCanvasForMaskEditor method.
2025-06-29 23:16:22 +02:00
Dariusz L
abb0f8ef53 Add export of canvas with mask as alpha channel
Introduces a new method to export the flattened canvas with the mask applied as the alpha channel. Updates UI actions to allow previewing, copying, and saving the image with mask alpha, and ensures node previews use the new export method. This enhances workflows that require the mask to be embedded as transparency in the output image.
2025-06-29 21:26:53 +02:00
Dariusz L
0bb54a0a6d Refactor image layer addition to use addLayer method
Replaces manual image layer creation with calls to canvas.addLayer, passing options and addMode. This streamlines image addition logic and ensures consistent handling of layer properties.
2025-06-29 20:56:58 +02:00
Dariusz L
8efb9d91b0 Remove legacy delegating methods from Canvas.js
Removed all remaining delegating methods from Canvas.js, including handleMouseMove and garbage collection helpers, as part of finalizing the refactor to a pure facade. Updated REFACTORING_GUIDE.md to reflect the completion of this refactor and clarify the new architecture.
2025-06-29 20:43:02 +02:00
Dariusz L
0b3bdaf769 Remove CanvasLayers delegation methods from Canvas.js
Eliminated 14 delegation methods to CanvasLayers from Canvas.js. Updated all relevant calls in CanvasRenderer.js, CanvasIO.js, and CanvasInteractions.js to use canvas.canvasLayers directly. This streamlines the Canvas class to only expose core facade operations and necessary helpers.
2025-06-29 20:39:31 +02:00
Dariusz L
1bb4909438 Remove CanvasState delegating methods from Canvas
Removed delegating methods to CanvasState from Canvas.js and updated all references to use canvas.canvasState directly for state operations (undo, redo, saveStateToDB). Updated CanvasInteractions.js, CanvasIO.js, CanvasLayers.js, and CanvasView.js accordingly. Updated REFACTORING_GUIDE.md to reflect the completion of this refactor.
2025-06-29 14:11:34 +02:00
Dariusz L
fd611c5777 Refactor CanvasLayers to use consistent canvas reference
Renamed the CanvasLayers constructor parameter from 'canvasLayers' to 'canvas' and updated all internal references accordingly for clarity and consistency. Adjusted CanvasView.js to call layer operations via canvas.canvasLayers. Updated REFACTORING_GUIDE.md to document these architectural changes and ensure all modules follow a unified naming convention.
2025-06-29 13:57:53 +02:00
Dariusz L
22627b7532 Delegate layer resize and rotate to CanvasLayers
Moved the resizeLayer and rotateLayer logic from Canvas.js to CanvasLayers.js, improving modularity and consistency. Updated REFACTORING_GUIDE.md to reflect these changes and document the current architecture and status.
2025-06-29 13:47:08 +02:00
Dariusz L
b4a662b036 Refactor CanvasView.js to use new Canvas facade modules
Updated all CanvasView.js method calls to use the new modular structure (canvasIO, canvasLayers, imageReferenceManager) as part of the Canvas facade refactor. Updated REFACTORING_GUIDE.md to reflect completed migration, document new usage patterns, and outline next steps. This improves code clarity and modularity while maintaining backward compatibility.
2025-06-29 13:42:52 +02:00
Dariusz L
d50a0443c3 Set default log level to DEBUG and add refactoring guide
Changed the default log level from NONE to DEBUG in both canvas_node.py and LoggerUtils.js to improve logging visibility during development. Added js/REFACTORING_GUIDE.md with detailed documentation on the recent Canvas module refactor, outlining architectural changes, migration instructions, and developer notes.
2025-06-29 04:44:08 +02:00
28 changed files with 3224 additions and 503 deletions

98
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
name: 🐞 Bug Report
description: Report an error or unexpected behavior
title: "[BUG] "
labels: [bug]
body:
- type: markdown
attributes:
value: |
**Thank you for reporting a bug!**
Please follow these steps to capture useful info:
### How to gather the necessary information:
🌐 **Browser & Version:**
- Chrome: Click the three dots → Help → About Google Chrome
- Firefox: Click the three bars → Help → About Firefox
- Edge: Click the three dots → Help and feedback → About Microsoft Edge
🔗 **Where to find the latest versions of ComfyUI and LayerForge:**
- [ComfyUI Github](https://github.com/comfyanonymous/ComfyUI/releases)
- [LayerForge Github](https://github.com/Azornes/Comfyui-LayerForge/releases/tag/v1.2.4) or [LayerForge from manager Comfyui](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
Make sure you have the latest versions before reporting an issue.
- type: input
id: environment
attributes:
label: Environment (OS, ComfyUI version, LayerForge version)
placeholder: e.g. Windows 11, ComfyUI v0.3.43, LayerForge v1.2.4
validations:
required: true
- type: input
id: browser
attributes:
label: Browser & Version
placeholder: e.g. Chrome 115.0.0, Firefox 120.1.0
validations:
required: true
- type: textarea
id: what_happened
attributes:
label: What Happened?
placeholder: Describe the issue you encountered
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
placeholder: |
1. …
2. …
3. …
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
placeholder: Describe what you expected to happen
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
placeholder: Describe what happened instead
validations:
required: true
- type: textarea
id: console_logs
attributes:
label: Browser Console Logs
description: |
**How to capture logs:**
- **Open console:**
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
Mac: `Cmd+Option+J`
- Firefox (Win/Linux): `Ctrl+Shift+K`
Mac: `Cmd+Option+K`
- Safari (Mac): enable **Develop** menu in Preferences → Advanced, then `Cmd+Option+C`
- **Clear console** before reproducing:
- Chrome/Edge: click “🚫 Clear console” or press `Ctrl+L` (Win/Linux) / `Cmd+K` (Mac)
- Firefox: `Ctrl+Shift+L` (newer) or `Ctrl+L` (older) (Win/Linux), Mac: `Cmd+K`
- Safari: click 🗑 icon or press `Cmd+K` / `Ctrl+L`
- Reproduce the issue and paste new logs here.
validations:
required: true
- type: markdown
attributes:
value: |
**Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually.
To add media, simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files.

24
.github/ISSUE_TEMPLATE/docs_request.yml vendored Normal file
View 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
View 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 "[![Top LayerForge Node]($shields$url)]($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 }}

View File

@@ -1,4 +1,4 @@
name: Auto Release with Version Patch and Commit Message name: Auto Release with Version Check
on: on:
push: push:
@@ -19,28 +19,24 @@ jobs:
base=$(grep '^version *= *"' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/') base=$(grep '^version *= *"' pyproject.toml | sed -E 's/version *= *"([^"]+)"/\1/')
echo "base_version=$base" >> $GITHUB_OUTPUT echo "base_version=$base" >> $GITHUB_OUTPUT
- name: Determine unique version tag - name: Check if tag for this version already exists
run: |
TAG="v${{ steps.version.outputs.base_version }}"
git fetch --tags
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists. Skipping release."
exit 0
fi
- name: Set version tag
id: unique_tag id: unique_tag
run: | run: |
BASE="v${{ steps.version.outputs.base_version }}" echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT
TAG=$BASE
COUNT=0
# Fetch remote tags
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
- name: Get latest commit message - name: Get latest commit message
id: last_commit id: last_commit
run: | run: |
msg=$(git log -1 --pretty=%B) msg=$(git log -1 --pretty=%B)
# Zamiana nowych linii na \n aby ładnie wyświetlać w release body
msg=${msg//$'\n'/\\n} msg=${msg//$'\n'/\\n}
echo "commit_msg=$msg" >> $GITHUB_OUTPUT echo "commit_msg=$msg" >> $GITHUB_OUTPUT
@@ -51,7 +47,6 @@ jobs:
name: Release ${{ steps.unique_tag.outputs.final_tag }} name: Release ${{ steps.unique_tag.outputs.final_tag }}
body: | body: |
📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}` 📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}`
🔁 Auto-postfix to avoid duplicate tag: `${{ steps.unique_tag.outputs.final_tag }}`
📝 Last commit message: 📝 Last commit message:
``` ```

96
Doc/ComfyApi Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,13 @@
**Markdown**
```markdown
[![Top LayerForge Node](https://img.shields.io/badge/dynamic/json?color=informational&label=TopLayerForge&query=downloads&url=https://gist.githubusercontent.com/Azornes/912463d4edd123956066a7aaaa3ef835/raw/top_layerforge.json)](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>
```

View File

@@ -1,16 +1,17 @@
<h1 align="center">LayerForge Advanced Canvas Editor for ComfyUI 🎨</h1> <h1 align="center">LayerForge Advanced Canvas Editor for ComfyUI 🎨</h1>
<p align="center"><i>LayerForge is an advanced canvas node for ComfyUI, providing a Photoshop-like layer-based editing experience directly within your workflow. It extends the concept of a simple canvas with multi-layer support, masking, blend modes, precise transformations, and seamless integration with other nodes.</i></p> <p align="center"><i>LayerForge is an advanced canvas node for ComfyUI, providing a Photoshop-like layer-based editing experience directly within your workflow. It extends the concept of a simple canvas with multi-layer support, masking, blend modes, precise transformations, and seamless integration with other nodes.</i></p>
<p align="center"> <p align="center">
<a href="https://registry.comfy.org/publishers/azornes/nodes/layerforge" style="display:inline-flex; align-items:center; gap:6px;"> <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="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://api.comfy.org/nodes/layerforge&style=for-the-badge" /> <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>
<a href='https://github.com/Azornes/Comfyui-LayerForge'> <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'> <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>
<a href="https://visitorbadge.io/status?path=https%3A%2F%2Fgithub.com%2FAzornes%2FComfyui-LayerForge"> <a href="https://visitorbadge.io/status?path=https%3A%2F%2Fgithub.com%2FAzornes%2FComfyui-LayerForge">
<img src="https://api.visitorbadge.io/api/combined?path=https%3A%2F%2Fgithub.com%2FAzornes%2FComfyui-LayerForge&countColor=%2337d67a&style=for-the-badge&labelStyle=none" /> <img src="https://api.visitorbadge.io/api/combined?path=https%3A%2F%2Fgithub.com%2FAzornes%2FComfyui-LayerForge&countColor=%2337d67a&style=for-the-badge&labelStyle=none" />
</a> </a>
@@ -18,9 +19,6 @@
<img alt="JavaScript" src="https://img.shields.io/badge/-JavaScript-000000?logo=javascript&logoColor=F7DF1E&style=for-the-badge&logoWidth=20"> <img alt="JavaScript" src="https://img.shields.io/badge/-JavaScript-000000?logo=javascript&logoColor=F7DF1E&style=for-the-badge&logoWidth=20">
</p> </p>
### Why LayerForge? ### Why LayerForge?
- **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without - **Full Creative Control:** Move beyond simple image inputs. Composite, mask, and blend multiple elements without
@@ -149,7 +147,7 @@ optional feature and requires a model.
> - **Download from**: > - **Download from**:
> >
- [Hugging Face](https://huggingface.co/ZhengPeng7/BiRefNet/tree/main) (Recommended) - [Hugging Face](https://huggingface.co/ZhengPeng7/BiRefNet/tree/main) (Recommended)
> - [Google Drive](https://drive.google.com/drive/folders/1BCLInCLH89fmTpYoP8Sgs_Eqww28f_wq?usp=sharing) - [Google Drive](https://drive.google.com/drive/folders/1BCLInCLH89fmTpYoP8Sgs_Eqww28f_wq?usp=sharing)
> - **Installation Path**: Place the model file in `ComfyUI/models/BiRefNet/`. > - **Installation Path**: Place the model file in `ComfyUI/models/BiRefNet/`.
--- ---

View File

@@ -168,6 +168,7 @@ class CanvasNode:
return { return {
"required": { "required": {
"fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}), "fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}),
"show_preview": ("BOOLEAN", {"default": False, "label_on": "Show Preview", "label_off": "Hide Preview"}),
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}), "trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}),
"node_id": ("STRING", {"default": "0", "hidden": True}), "node_id": ("STRING", {"default": "0", "hidden": True}),
}, },
@@ -231,7 +232,7 @@ class CanvasNode:
_processing_lock = threading.Lock() _processing_lock = threading.Lock()
def process_canvas_image(self, fit_on_add, trigger, node_id, prompt=None, unique_id=None): def process_canvas_image(self, fit_on_add, show_preview, trigger, node_id, prompt=None, unique_id=None):
try: try:
@@ -470,6 +471,70 @@ class CanvasNode:
'error': str(e) 'error': str(e)
}, status=500) }, status=500)
@PromptServer.instance.routes.post("/ycnode/load_image_from_path")
async def load_image_from_path_route(request):
try:
data = await request.json()
file_path = data.get('file_path')
if not file_path:
return web.json_response({
'success': False,
'error': 'file_path is required'
}, status=400)
log_info(f"Attempting to load image from path: {file_path}")
# Check if file exists and is accessible
if not os.path.exists(file_path):
log_warn(f"File not found: {file_path}")
return web.json_response({
'success': False,
'error': f'File not found: {file_path}'
}, status=404)
# Check if it's an image file
valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.avif')
if not file_path.lower().endswith(valid_extensions):
return web.json_response({
'success': False,
'error': f'Invalid image file extension. Supported: {valid_extensions}'
}, status=400)
# Try to load and convert the image
try:
with Image.open(file_path) as img:
# Convert to RGB if necessary
if img.mode != 'RGB':
img = img.convert('RGB')
# Convert to base64
buffered = io.BytesIO()
img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode('utf-8')
log_info(f"Successfully loaded image from path: {file_path}")
return web.json_response({
'success': True,
'image_data': f"data:image/png;base64,{img_str}",
'width': img.width,
'height': img.height
})
except Exception as img_error:
log_error(f"Error processing image file {file_path}: {str(img_error)}")
return web.json_response({
'success': False,
'error': f'Error processing image file: {str(img_error)}'
}, status=500)
except Exception as e:
log_error(f"Error in load_image_from_path_route: {str(e)}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
def store_image(self, image_data): def store_image(self, image_data):
if isinstance(image_data, str) and image_data.startswith('data:image'): if isinstance(image_data, str) and image_data.startswith('data:image'):

View 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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

View File

@@ -1,5 +1,5 @@
import { app, ComfyApp } from "../../scripts/app.js"; import {app, ComfyApp} from "../../scripts/app.js";
import { api } from "../../scripts/api.js"; import {api} from "../../scripts/api.js";
import {removeImage} from "./db.js"; import {removeImage} from "./db.js";
import {MaskTool} from "./MaskTool.js"; import {MaskTool} from "./MaskTool.js";
import {CanvasState} from "./CanvasState.js"; import {CanvasState} from "./CanvasState.js";
@@ -9,7 +9,7 @@ import {CanvasRenderer} from "./CanvasRenderer.js";
import {CanvasIO} from "./CanvasIO.js"; import {CanvasIO} from "./CanvasIO.js";
import {ImageReferenceManager} from "./ImageReferenceManager.js"; import {ImageReferenceManager} from "./ImageReferenceManager.js";
import {createModuleLogger} from "./utils/LoggerUtils.js"; import {createModuleLogger} from "./utils/LoggerUtils.js";
import { mask_editor_showing } from "./utils/mask_utils.js"; import {mask_editor_showing, mask_editor_listen_for_cancel} from "./utils/mask_utils.js";
const log = createModuleLogger('Canvas'); const log = createModuleLogger('Canvas');
@@ -26,7 +26,7 @@ export class Canvas {
this.node = node; this.node = node;
this.widget = widget; this.widget = widget;
this.canvas = document.createElement('canvas'); this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d'); this.ctx = this.canvas.getContext('2d', {willReadFrequently: true});
this.width = 512; this.width = 512;
this.height = 512; this.height = 512;
this.layers = []; this.layers = [];
@@ -51,14 +51,87 @@ export class Canvas {
this.pendingDataCheck = null; this.pendingDataCheck = null;
this.imageCache = new Map(); this.imageCache = new Map();
// Inicjalizacja modułów
this._initializeModules(callbacks); this._initializeModules(callbacks);
// Podstawowa konfiguracja
this._setupCanvas(); this._setupCanvas();
// Delegacja interaction dla kompatybilności wstecznej
this.interaction = this.canvasInteractions.interaction; this.interaction = this.canvasInteractions.interaction;
log.debug('Canvas widget element:', this.node);
log.info('Canvas initialized', {
nodeId: this.node.id,
dimensions: {width: this.width, height: this.height},
viewport: this.viewport
});
this.setPreviewVisibility(false);
}
async waitForWidget(name, node, interval = 100, timeout = 20000) {
const startTime = Date.now();
return new Promise((resolve, reject) => {
const check = () => {
const widget = node.widgets.find(w => w.name === name);
if (widget) {
resolve(widget);
} else if (Date.now() - startTime > timeout) {
reject(new Error(`Widget "${name}" not found within timeout.`));
} else {
setTimeout(check, interval);
}
};
check();
});
}
/**
* Kontroluje widoczność podglądu canvas
* @param {boolean} visible - Czy podgląd ma być widoczny
*/
async setPreviewVisibility(visible) {
this.previewVisible = visible;
log.info("Canvas preview visibility set to:", visible);
const imagePreviewWidget = await this.waitForWidget("$$canvas-image-preview", this.node);
if (imagePreviewWidget) {
log.debug("Found $$canvas-image-preview widget, controlling visibility");
if (visible) {
if (imagePreviewWidget.options) {
imagePreviewWidget.options.hidden = false;
}
if ('visible' in imagePreviewWidget) {
imagePreviewWidget.visible = true;
}
if ('hidden' in imagePreviewWidget) {
imagePreviewWidget.hidden = false;
}
imagePreviewWidget.computeSize = function () {
return [0, 250]; // Szerokość 0 (auto), wysokość 250
};
} else {
if (imagePreviewWidget.options) {
imagePreviewWidget.options.hidden = true;
}
if ('visible' in imagePreviewWidget) {
imagePreviewWidget.visible = false;
}
if ('hidden' in imagePreviewWidget) {
imagePreviewWidget.hidden = true;
}
imagePreviewWidget.computeSize = function () {
return [0, 0]; // Szerokość 0, wysokość 0
};
}
this.render()
} else {
log.warn("$$canvas-image-preview widget not found in Canvas.js");
}
} }
/** /**
@@ -66,7 +139,8 @@ export class Canvas {
* @private * @private
*/ */
_initializeModules(callbacks) { _initializeModules(callbacks) {
// Moduły są publiczne dla bezpośredniego dostępu gdy potrzebne log.debug('Initializing Canvas modules...');
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange}); this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
this.canvasState = new CanvasState(this); this.canvasState = new CanvasState(this);
this.canvasInteractions = new CanvasInteractions(this); this.canvasInteractions = new CanvasInteractions(this);
@@ -74,6 +148,8 @@ export class Canvas {
this.canvasRenderer = new CanvasRenderer(this); this.canvasRenderer = new CanvasRenderer(this);
this.canvasIO = new CanvasIO(this); this.canvasIO = new CanvasIO(this);
this.imageReferenceManager = new ImageReferenceManager(this); this.imageReferenceManager = new ImageReferenceManager(this);
log.debug('Canvas modules initialized successfully');
} }
/** /**
@@ -85,16 +161,12 @@ export class Canvas {
this.canvasInteractions.setupEventListeners(); this.canvasInteractions.setupEventListeners();
this.canvasIO.initNodeData(); this.canvasIO.initNodeData();
// Inicjalizacja warstw z domyślną przezroczystością
this.layers = this.layers.map(layer => ({ this.layers = this.layers.map(layer => ({
...layer, ...layer,
opacity: 1 opacity: 1
})); }));
} }
// ==========================================
// GŁÓWNE OPERACJE FASADY
// ==========================================
/** /**
* Ładuje stan canvas z bazy danych * Ładuje stan canvas z bazy danych
@@ -115,6 +187,7 @@ export class Canvas {
* @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii * @param {boolean} replaceLast - Czy zastąpić ostatni stan w historii
*/ */
saveState(replaceLast = false) { saveState(replaceLast = false) {
log.debug('Saving canvas state', {replaceLast, layersCount: this.layers.length});
this.canvasState.saveState(replaceLast); this.canvasState.saveState(replaceLast);
this.incrementOperationCount(); this.incrementOperationCount();
this._notifyStateChange(); this._notifyStateChange();
@@ -124,18 +197,31 @@ export class Canvas {
* Cofnij ostatnią operację * Cofnij ostatnią operację
*/ */
undo() { undo() {
log.info('Performing undo operation');
const historyInfo = this.canvasState.getHistoryInfo();
log.debug('History state before undo:', historyInfo);
this.canvasState.undo(); this.canvasState.undo();
this.incrementOperationCount(); this.incrementOperationCount();
this._notifyStateChange(); this._notifyStateChange();
log.debug('Undo completed, layers count:', this.layers.length);
} }
/** /**
* Ponów cofniętą operację * Ponów cofniętą operację
*/ */
redo() { redo() {
log.info('Performing redo operation');
const historyInfo = this.canvasState.getHistoryInfo();
log.debug('History state before redo:', historyInfo);
this.canvasState.redo(); this.canvasState.redo();
this.incrementOperationCount(); this.incrementOperationCount();
this._notifyStateChange(); this._notifyStateChange();
log.debug('Redo completed, layers count:', this.layers.length);
} }
/** /**
@@ -160,11 +246,20 @@ export class Canvas {
*/ */
removeSelectedLayers() { removeSelectedLayers() {
if (this.selectedLayers.length > 0) { if (this.selectedLayers.length > 0) {
log.info('Removing selected layers', {
layersToRemove: this.selectedLayers.length,
totalLayers: this.layers.length
});
this.saveState(); this.saveState();
this.layers = this.layers.filter(l => !this.selectedLayers.includes(l)); this.layers = this.layers.filter(l => !this.selectedLayers.includes(l));
this.updateSelection([]); this.updateSelection([]);
this.render(); this.render();
this.saveState(); this.saveState();
log.debug('Layers removed successfully, remaining layers:', this.layers.length);
} else {
log.debug('No layers selected for removal');
} }
} }
@@ -173,8 +268,16 @@ export class Canvas {
* @param {Array} newSelection - Nowa lista zaznaczonych warstw * @param {Array} newSelection - Nowa lista zaznaczonych warstw
*/ */
updateSelection(newSelection) { updateSelection(newSelection) {
const previousSelection = this.selectedLayers.length;
this.selectedLayers = newSelection || []; this.selectedLayers = newSelection || [];
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null; this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
log.debug('Selection updated', {
previousCount: previousSelection,
newCount: this.selectedLayers.length,
selectedLayerIds: this.selectedLayers.map(l => l.id || 'unknown')
});
if (this.onSelectionChange) { if (this.onSelectionChange) {
this.onSelectionChange(); this.onSelectionChange();
} }
@@ -197,6 +300,13 @@ export class Canvas {
return this.canvasLayers.getFlattenedCanvasAsBlob(); return this.canvasLayers.getFlattenedCanvasAsBlob();
} }
/**
* Eksportuje spłaszczony canvas z maską jako kanałem alpha
*/
async getFlattenedCanvasWithMaskAsBlob() {
return this.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
}
/** /**
* Importuje najnowszy obraz * Importuje najnowszy obraz
*/ */
@@ -204,20 +314,50 @@ export class Canvas {
return this.canvasIO.importLatestImage(); return this.canvasIO.importLatestImage();
} }
// ==========================================
// OPERACJE NA MASCE
// ==========================================
/** /**
* Uruchamia edytor masek * 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() { async startMaskEditor(predefinedMask = null, sendCleanImage = true) {
const blob = await this.canvasLayers.getFlattenedCanvasAsBlob(); log.info('Starting mask editor', {
hasPredefinedMask: !!predefinedMask,
sendCleanImage,
layersCount: this.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.canvasLayers.getFlattenedCanvasAsBlob();
} else {
log.debug('Getting flattened canvas for mask editor (with mask)');
blob = await this.canvasLayers.getFlattenedCanvasForMaskEditor();
}
if (!blob) { if (!blob) {
log.warn("Canvas is empty, cannot open mask editor."); log.warn("Canvas is empty, cannot open mask editor.");
return; return;
} }
log.debug('Canvas blob created successfully, size:', blob.size);
try { try {
const formData = new FormData(); const formData = new FormData();
const filename = `layerforge-mask-edit-${+new Date()}.png`; const filename = `layerforge-mask-edit-${+new Date()}.png`;
@@ -225,6 +365,8 @@ export class Canvas {
formData.append("overwrite", "true"); formData.append("overwrite", "true");
formData.append("type", "temp"); formData.append("type", "temp");
log.debug('Uploading image to server:', filename);
const response = await api.fetchApi("/upload/image", { const response = await api.fetchApi("/upload/image", {
method: "POST", method: "POST",
body: formData, body: formData,
@@ -235,6 +377,8 @@ export class Canvas {
} }
const data = await response.json(); const data = await response.json();
log.debug('Image uploaded successfully:', data);
const img = new Image(); const img = new Image();
img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
await new Promise((res, rej) => { await new Promise((res, rej) => {
@@ -244,6 +388,7 @@ export class Canvas {
this.node.imgs = [img]; this.node.imgs = [img];
log.info('Opening ComfyUI mask editor');
ComfyApp.copyToClipspace(this.node); ComfyApp.copyToClipspace(this.node);
ComfyApp.clipspace_return_node = this.node; ComfyApp.clipspace_return_node = this.node;
ComfyApp.open_maskeditor(); ComfyApp.open_maskeditor();
@@ -251,15 +396,19 @@ export class Canvas {
this.editorWasShowing = false; this.editorWasShowing = false;
this.waitWhileMaskEditing(); this.waitWhileMaskEditing();
this.setupCancelListener();
if (predefinedMask) {
log.debug('Will apply predefined mask when editor is ready');
this.waitForMaskEditorAndApplyMask();
}
} catch (error) { } catch (error) {
log.error("Error preparing image for mask editor:", error); log.error("Error preparing image for mask editor:", error);
alert(`Error: ${error.message}`); alert(`Error: ${error.message}`);
} }
} }
// ==========================================
// METODY POMOCNICZE
// ==========================================
/** /**
* Inicjalizuje podstawowe właściwości canvas * Inicjalizuje podstawowe właściwości canvas
@@ -313,7 +462,7 @@ export class Canvas {
const mouseX_Canvas = mouseX_DOM * scaleX; const mouseX_Canvas = mouseX_DOM * scaleX;
const mouseY_Canvas = mouseY_DOM * scaleY; const mouseY_Canvas = mouseY_DOM * scaleY;
return { x: mouseX_Canvas, y: mouseY_Canvas }; return {x: mouseX_Canvas, y: mouseY_Canvas};
} }
/** /**
@@ -372,9 +521,255 @@ export class Canvas {
} }
} }
// ==========================================
// METODY DLA EDYTORA MASEK /**
// ========================================== * Czeka na otwarcie mask editora i automatycznie nakłada predefiniowaną maskę
*/
waitForMaskEditorAndApplyMask() {
let attempts = 0;
const maxAttempts = 100; // Zwiększone do 10 sekund oczekiwania
const checkEditor = () => {
attempts++;
if (mask_editor_showing(app)) {
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
let editorReady = false;
if (useNewEditor) {
const MaskEditorDialog = window.MaskEditorDialog;
if (MaskEditorDialog && MaskEditorDialog.instance) {
try {
const messageBroker = MaskEditorDialog.instance.getMessageBroker();
if (messageBroker) {
editorReady = true;
log.info("New mask editor detected as ready via MessageBroker");
}
} catch (e) {
editorReady = false;
}
}
if (!editorReady) {
const maskEditorElement = document.getElementById('maskEditor');
if (maskEditorElement && maskEditorElement.style.display !== 'none') {
const canvas = maskEditorElement.querySelector('canvas');
if (canvas) {
editorReady = true;
log.info("New mask editor detected as ready via DOM element");
}
}
}
} else {
const maskCanvas = document.getElementById('maskCanvas');
editorReady = maskCanvas && maskCanvas.getContext && maskCanvas.width > 0;
if (editorReady) {
log.info("Old mask editor detected as ready");
}
}
if (editorReady) {
log.info("Applying mask to editor after", attempts * 100, "ms wait");
setTimeout(() => {
this.applyMaskToEditor(this.pendingMask);
this.pendingMask = null;
}, 300);
} else if (attempts < maxAttempts) {
if (attempts % 10 === 0) {
log.info("Waiting for mask editor to be ready... attempt", attempts, "/", maxAttempts);
}
setTimeout(checkEditor, 100);
} else {
log.warn("Mask editor timeout - editor not ready after", maxAttempts * 100, "ms");
log.info("Attempting to apply mask anyway...");
setTimeout(() => {
this.applyMaskToEditor(this.pendingMask);
this.pendingMask = null;
}, 100);
}
} else if (attempts < maxAttempts) {
setTimeout(checkEditor, 100);
} else {
log.warn("Mask editor timeout - editor not showing after", maxAttempts * 100, "ms");
this.pendingMask = null;
}
};
checkEditor();
}
/**
* Nakłada maskę na otwarty mask editor
* @param {Image|HTMLCanvasElement} maskData - Dane maski do nałożenia
*/
async applyMaskToEditor(maskData) {
try {
const useNewEditor = app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
if (useNewEditor) {
const MaskEditorDialog = window.MaskEditorDialog;
if (MaskEditorDialog && MaskEditorDialog.instance) {
await this.applyMaskToNewEditor(maskData);
} else {
log.warn("New editor setting enabled but instance not found, trying old editor");
await this.applyMaskToOldEditor(maskData);
}
} else {
await this.applyMaskToOldEditor(maskData);
}
log.info("Predefined mask applied to mask editor successfully");
} catch (error) {
log.error("Failed to apply predefined mask to editor:", error);
try {
log.info("Trying alternative mask application method...");
await this.applyMaskToOldEditor(maskData);
log.info("Alternative method succeeded");
} catch (fallbackError) {
log.error("Alternative method also failed:", fallbackError);
}
}
}
/**
* Nakłada maskę na nowy mask editor (przez MessageBroker)
* @param {Image|HTMLCanvasElement} maskData - Dane maski
*/
async applyMaskToNewEditor(maskData) {
const MaskEditorDialog = window.MaskEditorDialog;
if (!MaskEditorDialog || !MaskEditorDialog.instance) {
throw new Error("New mask editor instance not found");
}
const editor = MaskEditorDialog.instance;
const messageBroker = editor.getMessageBroker();
const maskCanvas = await messageBroker.pull('maskCanvas');
const maskCtx = await messageBroker.pull('maskCtx');
const maskColor = await messageBroker.pull('getMaskColor');
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(processedMask, 0, 0);
messageBroker.publish('saveState');
}
/**
* Nakłada maskę na stary mask editor
* @param {Image|HTMLCanvasElement} maskData - Dane maski
*/
async applyMaskToOldEditor(maskData) {
const maskCanvas = document.getElementById('maskCanvas');
if (!maskCanvas) {
throw new Error("Old mask editor canvas not found");
}
const maskCtx = maskCanvas.getContext('2d', {willReadFrequently: true});
const maskColor = {r: 255, g: 255, b: 255};
const processedMask = await this.processMaskForEditor(maskData, maskCanvas.width, maskCanvas.height, maskColor);
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(processedMask, 0, 0);
}
/**
* Przetwarza maskę do odpowiedniego formatu dla editora
* @param {Image|HTMLCanvasElement} maskData - Oryginalne dane maski
* @param {number} targetWidth - Docelowa szerokość
* @param {number} targetHeight - Docelowa wysokość
* @param {Object} maskColor - Kolor maski {r, g, b}
* @returns {HTMLCanvasElement} Przetworzona maska
*/async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) {
// Współrzędne przesunięcia (pan) widoku edytora
const panX = this.maskTool.x;
const panY = this.maskTool.y;
log.info("Processing mask for editor:", {
sourceSize: {width: maskData.width, height: maskData.height},
targetSize: {width: targetWidth, height: targetHeight},
viewportPan: {x: panX, y: panY}
});
const tempCanvas = document.createElement('canvas');
tempCanvas.width = targetWidth;
tempCanvas.height = targetHeight;
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
const sourceX = -panX;
const sourceY = -panY;
tempCtx.drawImage(
maskData, // Źródło: pełna maska z "output area"
sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000)
sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000)
targetWidth, // sWidth: Szerokość wycinanego fragmentu
targetHeight, // sHeight: Wysokość wycinanego fragmentu
0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0)
0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0)
targetWidth, // dWidth: Szerokość wklejanego obrazu
targetHeight // dHeight: Wysokość wklejanego obrazu
);
log.info("Mask viewport cropped correctly.", {
source: "maskData",
cropArea: {x: sourceX, y: sourceY, width: targetWidth, height: targetHeight}
});
// Reszta kodu (zmiana koloru) pozostaje bez zmian
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0) {
data[i] = maskColor.r;
data[i + 1] = maskColor.g;
data[i + 2] = maskColor.b;
}
}
tempCtx.putImageData(imageData, 0, 0);
log.info("Mask processing completed - color applied.");
return tempCanvas;
}
/**
* Tworzy obiekt Image z obecnej maski canvas
* @returns {Promise<Image>} Promise zwracający obiekt Image z maską
*/
async createMaskFromCurrentMask() {
if (!this.maskTool || !this.maskTool.maskCanvas) {
throw new Error("No mask canvas available");
}
return new Promise((resolve, reject) => {
const maskImage = new Image();
maskImage.onload = () => resolve(maskImage);
maskImage.onerror = reject;
maskImage.src = this.maskTool.maskCanvas.toDataURL();
});
}
waitWhileMaskEditing() { waitWhileMaskEditing() {
if (mask_editor_showing(app)) { if (mask_editor_showing(app)) {
@@ -382,20 +777,99 @@ export class Canvas {
} }
if (!mask_editor_showing(app) && this.editorWasShowing) { if (!mask_editor_showing(app) && this.editorWasShowing) {
this.editorWasShowing = false; this.editorWasShowing = false;
setTimeout(() => this.handleMaskEditorClose(), 100); setTimeout(() => this.handleMaskEditorClose(), 100);
} else { } else {
setTimeout(this.waitWhileMaskEditing.bind(this), 100); setTimeout(this.waitWhileMaskEditing.bind(this), 100);
} }
} }
/**
* Zapisuje obecny stan maski przed otwarciem editora
* @returns {Object} Zapisany stan maski
*/
async saveMaskState() {
if (!this.maskTool || !this.maskTool.maskCanvas) {
return null;
}
const maskCanvas = this.maskTool.maskCanvas;
const savedCanvas = document.createElement('canvas');
savedCanvas.width = maskCanvas.width;
savedCanvas.height = maskCanvas.height;
const savedCtx = savedCanvas.getContext('2d', {willReadFrequently: true});
savedCtx.drawImage(maskCanvas, 0, 0);
return {
maskData: savedCanvas,
maskPosition: {
x: this.maskTool.x,
y: this.maskTool.y
}
};
}
/**
* Przywraca zapisany stan maski
* @param {Object} savedState - Zapisany stan maski
*/
async restoreMaskState(savedState) {
if (!savedState || !this.maskTool) {
return;
}
if (savedState.maskData) {
const maskCtx = this.maskTool.maskCtx;
maskCtx.clearRect(0, 0, this.maskTool.maskCanvas.width, this.maskTool.maskCanvas.height);
maskCtx.drawImage(savedState.maskData, 0, 0);
}
if (savedState.maskPosition) {
this.maskTool.x = savedState.maskPosition.x;
this.maskTool.y = savedState.maskPosition.y;
}
this.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() { async handleMaskEditorClose() {
console.log("Node object after mask editor close:", this.node); 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) { if (!this.node.imgs || !this.node.imgs.length === 0 || !this.node.imgs[0].src) {
log.warn("Mask editor was closed without a result."); log.warn("Mask editor was closed without a result.");
return; return;
} }
log.debug("Processing mask editor result, image source:", this.node.imgs[0].src.substring(0, 100) + '...');
const resultImage = new Image(); const resultImage = new Image();
resultImage.src = this.node.imgs[0].src; resultImage.src = this.node.imgs[0].src;
@@ -404,25 +878,32 @@ export class Canvas {
resultImage.onload = resolve; resultImage.onload = resolve;
resultImage.onerror = reject; resultImage.onerror = reject;
}); });
log.debug("Result image loaded successfully", {
width: resultImage.width,
height: resultImage.height
});
} catch (error) { } catch (error) {
log.error("Failed to load image from mask editor.", error); log.error("Failed to load image from mask editor.", error);
this.node.imgs = []; this.node.imgs = [];
return; return;
} }
log.debug("Creating temporary canvas for mask processing");
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.width; tempCanvas.width = this.width;
tempCanvas.height = this.height; tempCanvas.height = this.height;
const tempCtx = tempCanvas.getContext('2d'); const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
tempCtx.drawImage(resultImage, 0, 0, this.width, this.height); tempCtx.drawImage(resultImage, 0, 0, this.width, this.height);
log.debug("Processing image data to create mask");
const imageData = tempCtx.getImageData(0, 0, this.width, this.height); const imageData = tempCtx.getImageData(0, 0, this.width, this.height);
const data = imageData.data; const data = imageData.data;
for (let i = 0; i < data.length; i += 4) { for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3]; const originalAlpha = data[i + 3];
data[i] = 255; data[i] = 255;
data[i + 1] = 255; data[i + 1] = 255;
data[i + 2] = 255; data[i + 2] = 255;
data[i + 3] = 255 - originalAlpha; data[i + 3] = 255 - originalAlpha;
@@ -430,6 +911,7 @@ export class Canvas {
tempCtx.putImageData(imageData, 0, 0); tempCtx.putImageData(imageData, 0, 0);
log.debug("Converting processed mask to image");
const maskAsImage = new Image(); const maskAsImage = new Image();
maskAsImage.src = tempCanvas.toDataURL(); maskAsImage.src = tempCanvas.toDataURL();
await new Promise(resolve => maskAsImage.onload = resolve); await new Promise(resolve => maskAsImage.onload = resolve);
@@ -438,91 +920,33 @@ export class Canvas {
const destX = -this.maskTool.x; const destX = -this.maskTool.x;
const destY = -this.maskTool.y; const destY = -this.maskTool.y;
maskCtx.globalCompositeOperation = 'screen'; log.debug("Applying mask to canvas", {destX, destY});
maskCtx.drawImage(maskAsImage, destX, destY);
maskCtx.globalCompositeOperation = 'source-over'; maskCtx.globalCompositeOperation = 'source-over';
maskCtx.clearRect(destX, destY, this.width, this.height);
maskCtx.drawImage(maskAsImage, destX, destY);
this.render(); this.render();
this.saveState(); this.saveState();
log.debug("Creating new preview image");
const new_preview = new Image(); const new_preview = new Image();
const blob = await this.canvasLayers.getFlattenedCanvasAsBlob();
const blob = await this.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (blob) { if (blob) {
new_preview.src = URL.createObjectURL(blob); new_preview.src = URL.createObjectURL(blob);
await new Promise(r => new_preview.onload = r); await new Promise(r => new_preview.onload = r);
this.node.imgs = [new_preview]; this.node.imgs = [new_preview];
log.debug("New preview image created successfully");
} else { } else {
this.node.imgs = []; this.node.imgs = [];
log.warn("Failed to create preview blob");
} }
this.render(); this.render();
}
// ========================================== this.savedMaskState = null;
// METODY DELEGUJĄCE DLA KOMPATYBILNOŚCI log.info("Mask editor result processed successfully");
// ==========================================
/**
* Te metody są zachowane tymczasowo dla kompatybilności wstecznej.
* W nowych implementacjach należy używać bezpośrednio odpowiednich modułów:
* - this.canvasLayers dla operacji na warstwach
* - this.canvasInteractions dla obsługi interakcji
* - this.canvasIO dla operacji I/O
* - this.canvasState dla zarządzania stanem
*/
// Delegacje do CanvasState
async saveStateToDB(immediate = false) { return this.canvasState.saveStateToDB(immediate); }
// Delegacje do CanvasLayers
async copySelectedLayers() { return this.canvasLayers.copySelectedLayers(); }
async handlePaste(addMode) { return this.canvasLayers.handlePaste(addMode); }
async addLayerWithImage(image, layerProps = {}, addMode = 'default') {
return this.canvasLayers.addLayerWithImage(image, layerProps, addMode);
}
moveLayerUp() { return this.canvasLayers.moveLayerUp(); }
moveLayerDown() { return this.canvasLayers.moveLayerDown(); }
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();
}
getLayerAtPosition(worldX, worldY) { return this.canvasLayers.getLayerAtPosition(worldX, worldY); }
getHandles(layer) { return this.canvasLayers.getHandles(layer); }
getHandleAtPosition(worldX, worldY) { return this.canvasLayers.getHandleAtPosition(worldX, worldY); }
async mirrorHorizontal() { return this.canvasLayers.mirrorHorizontal(); }
async mirrorVertical() { return this.canvasLayers.mirrorVertical(); }
async getLayerImageData(layer) { return this.canvasLayers.getLayerImageData(layer); }
showBlendModeMenu(x, y) { return this.canvasLayers.showBlendModeMenu(x, y); }
// Delegacje do CanvasInteractions
handleMouseMove(e) { this.canvasInteractions.handleMouseMove(e); }
// Delegacje do ImageReferenceManager
async runGarbageCollection() {
if (this.imageReferenceManager) {
await this.imageReferenceManager.manualGarbageCollection();
}
}
getGarbageCollectionStats() {
if (this.imageReferenceManager) {
const stats = this.imageReferenceManager.getStats();
return {
...stats,
operationCount: this.imageReferenceManager.operationCount,
operationThreshold: this.imageReferenceManager.operationThreshold
};
}
return null;
} }
} }

View File

@@ -46,7 +46,7 @@ export class CanvasIO {
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`); log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
return Promise.resolve(true); return Promise.resolve(true);
} }
await this.canvas.saveStateToDB(true); await this.canvas.canvasState.saveStateToDB(true);
const nodeId = this.canvas.node.id; const nodeId = this.canvas.node.id;
const delay = (nodeId % 10) * 50; const delay = (nodeId % 10) * 50;
if (delay > 0) { if (delay > 0) {
@@ -102,7 +102,7 @@ export class CanvasIO {
const tempMaskCanvas = document.createElement('canvas'); const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width; tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height; tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d'); const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
@@ -279,7 +279,7 @@ export class CanvasIO {
const tempMaskCanvas = document.createElement('canvas'); const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width; tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height; tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d'); const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
@@ -374,7 +374,7 @@ export class CanvasIO {
this.canvas.height / inputImage.height * 0.8 this.canvas.height / inputImage.height * 0.8
); );
const layer = await this.canvas.addLayerWithImage(image, { const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
x: (this.canvas.width - inputImage.width * scale) / 2, x: (this.canvas.width - inputImage.width * scale) / 2,
y: (this.canvas.height - inputImage.height * scale) / 2, y: (this.canvas.height - inputImage.height * scale) / 2,
width: inputImage.width * scale, width: inputImage.width * scale,
@@ -403,7 +403,7 @@ export class CanvasIO {
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = tensor.width; canvas.width = tensor.width;
canvas.height = tensor.height; canvas.height = tensor.height;
@@ -611,7 +611,7 @@ export class CanvasIO {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = imageData.width; canvas.width = imageData.width;
canvas.height = imageData.height; canvas.height = imageData.height;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.putImageData(imageData, 0, 0); ctx.putImageData(imageData, 0, 0);
const img = new Image(); const img = new Image();
@@ -684,7 +684,7 @@ export class CanvasIO {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = img.width; tempCanvas.width = img.width;
tempCanvas.height = img.height; tempCanvas.height = img.height;
const tempCtx = tempCanvas.getContext('2d'); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCtx.drawImage(img, 0, 0); tempCtx.drawImage(img, 0, 0);
@@ -693,7 +693,7 @@ export class CanvasIO {
const maskCanvas = document.createElement('canvas'); const maskCanvas = document.createElement('canvas');
maskCanvas.width = img.width; maskCanvas.width = img.width;
maskCanvas.height = img.height; maskCanvas.height = img.height;
const maskCtx = maskCanvas.getContext('2d'); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.drawImage(mask, 0, 0); maskCtx.drawImage(mask, 0, 0);
const maskData = maskCtx.getImageData(0, 0, img.width, img.height); const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
@@ -744,7 +744,7 @@ export class CanvasIO {
img.src = result.image_data; img.src = result.image_data;
}); });
await this.canvas.addLayerWithImage(img, { await this.canvas.canvasLayers.addLayerWithImage(img, {
x: 0, x: 0,
y: 0, y: 0,
width: this.canvas.width, width: this.canvas.width,

View File

@@ -34,6 +34,8 @@ export class CanvasInteractions {
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this)); this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this)); this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
document.addEventListener('paste', this.handlePasteEvent.bind(this));
this.canvas.canvas.addEventListener('mouseenter', (e) => { this.canvas.canvas.addEventListener('mouseenter', (e) => {
this.canvas.isMouseOver = true; this.canvas.isMouseOver = true;
this.handleMouseEnter(e); this.handleMouseEnter(e);
@@ -42,6 +44,13 @@ export class CanvasInteractions {
this.canvas.isMouseOver = false; this.canvas.isMouseOver = false;
this.handleMouseLeave(e); this.handleMouseLeave(e);
}); });
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
} }
resetInteractionState() { resetInteractionState() {
@@ -86,26 +95,34 @@ export class CanvasInteractions {
} }
this.interaction.lastClickTime = currentTime; this.interaction.lastClickTime = currentTime;
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y); if (e.button === 2) {
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
e.preventDefault(); // Prevent context menu
this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x ,viewCoords.y);
return;
}
}
if (e.shiftKey) {
this.startCanvasResize(worldCoords);
this.canvas.render();
return;
}
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) { if (transformTarget) {
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords); this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
return; return;
} }
const clickedLayerResult = this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y); const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
if (clickedLayerResult) { if (clickedLayerResult) {
if (e.shiftKey && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
this.canvas.showBlendModeMenu(e.clientX, e.clientY);
return;
}
this.startLayerDrag(clickedLayerResult.layer, worldCoords); this.startLayerDrag(clickedLayerResult.layer, worldCoords);
return; return;
} }
if (e.shiftKey) {
this.startCanvasResize(worldCoords); this.startPanning(e);
} else {
this.startPanning(e);
}
this.canvas.render(); this.canvas.render();
} }
@@ -176,7 +193,7 @@ export class CanvasInteractions {
if (interactionEnded) { if (interactionEnded) {
this.canvas.saveState(); this.canvas.saveState();
this.canvas.saveStateToDB(true); this.canvas.canvasState.saveStateToDB(true);
} }
} }
@@ -194,6 +211,11 @@ export class CanvasInteractions {
this.resetInteractionState(); this.resetInteractionState();
this.canvas.render(); this.canvas.render();
} }
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.internalClipboard = [];
log.info("Internal clipboard cleared - mouse left canvas");
}
} }
handleMouseEnter(e) { handleMouseEnter(e) {
@@ -202,6 +224,11 @@ export class CanvasInteractions {
} }
} }
handleContextMenu(e) {
e.preventDefault();
}
handleWheel(e) { handleWheel(e) {
e.preventDefault(); e.preventDefault();
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
@@ -297,16 +324,16 @@ export class CanvasInteractions {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (e.shiftKey) { if (e.shiftKey) {
this.canvas.redo(); this.canvas.canvasState.redo();
} else { } else {
this.canvas.undo(); this.canvas.canvasState.undo();
} }
return; return;
} }
if (e.key.toLowerCase() === 'y') { if (e.key.toLowerCase() === 'y') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.canvas.redo(); this.canvas.canvasState.redo();
return; return;
} }
} }
@@ -324,30 +351,27 @@ export class CanvasInteractions {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (e.shiftKey) { if (e.shiftKey) {
this.canvas.redo(); this.canvas.canvasState.redo();
} else { } else {
this.canvas.undo(); this.canvas.canvasState.undo();
} }
return; return;
} }
if (e.key.toLowerCase() === 'y') { if (e.key.toLowerCase() === 'y') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.canvas.redo(); this.canvas.canvasState.redo();
return; return;
} }
if (e.key.toLowerCase() === 'c') { if (e.key.toLowerCase() === 'c') {
if (this.canvas.selectedLayers.length > 0) { if (this.canvas.selectedLayers.length > 0) {
e.preventDefault(); this.canvas.canvasLayers.copySelectedLayers();
e.stopPropagation();
this.canvas.copySelectedLayers();
} }
return; return;
} }
if (e.key.toLowerCase() === 'v') { if (e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
this.canvas.handlePaste('mouse');
return; return;
} }
} }
@@ -399,7 +423,7 @@ export class CanvasInteractions {
} }
updateCursor(worldCoords) { updateCursor(worldCoords) {
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y); const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
if (transformTarget) { if (transformTarget) {
const handleName = transformTarget.handle; const handleName = transformTarget.handle;
@@ -409,7 +433,7 @@ export class CanvasInteractions {
'rot': 'grab' 'rot': 'grab'
}; };
this.canvas.canvas.style.cursor = cursorMap[handleName]; this.canvas.canvas.style.cursor = cursorMap[handleName];
} else if (this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y)) { } else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) {
this.canvas.canvas.style.cursor = 'move'; this.canvas.canvas.style.cursor = 'move';
} else { } else {
this.canvas.canvas.style.cursor = 'default'; this.canvas.canvas.style.cursor = 'default';
@@ -432,7 +456,7 @@ export class CanvasInteractions {
} else { } else {
this.interaction.mode = 'resizing'; this.interaction.mode = 'resizing';
this.interaction.resizeHandle = handle; this.interaction.resizeHandle = handle;
const handles = this.canvas.getHandles(layer); const handles = this.canvas.canvasLayers.getHandles(layer);
const oppositeHandleKey = { const oppositeHandleKey = {
'n': 's', 's': 'n', 'e': 'w', 'w': 'e', 'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne' 'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
@@ -712,4 +736,130 @@ export class CanvasInteractions {
this.canvas.viewport.y -= rectY; this.canvas.viewport.y -= rectY;
} }
} }
handleDragOver(e) {
e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event
e.dataTransfer.dropEffect = 'copy';
}
handleDragEnter(e) {
e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event
this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)';
this.canvas.canvas.style.border = '2px dashed #2d5aa0';
}
handleDragLeave(e) {
e.preventDefault();
e.stopPropagation(); // Prevent ComfyUI from handling this event
if (!this.canvas.canvas.contains(e.relatedTarget)) {
this.canvas.canvas.style.backgroundColor = '';
this.canvas.canvas.style.border = '';
}
}
async handleDrop(e) {
e.preventDefault();
e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow
log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading");
this.canvas.canvas.style.backgroundColor = '';
this.canvas.canvas.style.border = '';
const files = Array.from(e.dataTransfer.files);
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
log.info(`Dropped ${files.length} file(s) onto canvas at position (${worldCoords.x}, ${worldCoords.y})`);
for (const file of files) {
if (file.type.startsWith('image/')) {
try {
await this.loadDroppedImageFile(file, worldCoords);
log.info(`Successfully loaded dropped image: ${file.name}`);
} catch (error) {
log.error(`Failed to load dropped image ${file.name}:`, error);
}
} else {
log.warn(`Skipped non-image file: ${file.name} (${file.type})`);
}
}
}
async loadDroppedImageFile(file, worldCoords) {
const reader = new FileReader();
reader.onload = async (e) => {
const img = new Image();
img.onload = async () => {
const fitOnAddWidget = this.canvas.node.widgets.find(w => w.name === "fit_on_add");
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
await this.canvas.addLayer(img, {}, addMode);
};
img.onerror = () => {
log.error(`Failed to load dropped image: ${file.name}`);
};
img.src = e.target.result;
};
reader.onerror = () => {
log.error(`Failed to read dropped file: ${file.name}`);
};
reader.readAsDataURL(file);
}
async handlePasteEvent(e) {
const shouldHandle = this.canvas.isMouseOver ||
this.canvas.canvas.contains(document.activeElement) ||
document.activeElement === this.canvas.canvas ||
document.activeElement === document.body;
if (!shouldHandle) {
log.debug("Paste event ignored - not focused on canvas");
return;
}
log.info("Paste event detected, checking clipboard preference");
const preference = this.canvas.canvasLayers.clipboardPreference;
if (preference === 'clipspace') {
log.info("Clipboard preference is clipspace, delegating to ClipboardManager");
e.preventDefault();
e.stopPropagation();
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
return;
}
const clipboardData = e.clipboardData;
if (clipboardData && clipboardData.items) {
for (const item of clipboardData.items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
e.stopPropagation();
const file = item.getAsFile();
if (file) {
log.info("Found direct image data in paste event");
const reader = new FileReader();
reader.onload = async (event) => {
const img = new Image();
img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse');
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
return;
}
}
}
}
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
}
} }

View File

@@ -2,12 +2,15 @@ import {saveImage, removeImage} from "./db.js";
import {createModuleLogger} from "./utils/LoggerUtils.js"; import {createModuleLogger} from "./utils/LoggerUtils.js";
import {generateUUID, generateUniqueFileName} from "./utils/CommonUtils.js"; import {generateUUID, generateUniqueFileName} from "./utils/CommonUtils.js";
import {withErrorHandling, createValidationError} from "./ErrorHandler.js"; import {withErrorHandling, createValidationError} from "./ErrorHandler.js";
import {app, ComfyApp} from "../../scripts/app.js";
import {ClipboardManager} from "./utils/ClipboardManager.js";
const log = createModuleLogger('CanvasLayers'); const log = createModuleLogger('CanvasLayers');
export class CanvasLayers { export class CanvasLayers {
constructor(canvasLayers) { constructor(canvas) {
this.canvasLayers = canvasLayers; this.canvas = canvas;
this.clipboardManager = new ClipboardManager(canvas);
this.blendModes = [ this.blendModes = [
{name: 'normal', label: 'Normal'}, {name: 'normal', label: 'Normal'},
{name: 'multiply', label: 'Multiply'}, {name: 'multiply', label: 'Multiply'},
@@ -26,85 +29,120 @@ export class CanvasLayers {
this.blendOpacity = 100; this.blendOpacity = 100;
this.isAdjustingOpacity = false; this.isAdjustingOpacity = false;
this.internalClipboard = []; this.internalClipboard = [];
this.clipboardPreference = 'system'; // 'system', 'clipspace'
} }
async copySelectedLayers() { async copySelectedLayers() {
if (this.canvasLayers.selectedLayers.length === 0) return; if (this.canvas.selectedLayers.length === 0) return;
this.internalClipboard = this.canvasLayers.selectedLayers.map(layer => ({...layer}));
this.internalClipboard = this.canvas.selectedLayers.map(layer => ({...layer}));
log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`); log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`);
try {
const blob = await this.getFlattenedSelectionAsBlob(); const blob = await this.getFlattenedSelectionAsBlob();
if (blob) { if (!blob) {
log.warn("Failed to create flattened selection blob");
return;
}
if (this.clipboardPreference === 'clipspace') {
try {
const dataURL = await new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
const img = new Image();
img.onload = () => {
if (this.canvas.node.imgs) {
this.canvas.node.imgs = [img];
} else {
this.canvas.node.imgs = [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}); const item = new ClipboardItem({'image/png': blob});
await navigator.clipboard.write([item]); await navigator.clipboard.write([item]);
log.info("Flattened selection copied to the system clipboard."); log.info("Flattened selection copied to system clipboard.");
} catch (error) {
log.error("Failed to copy image to system clipboard:", error);
} }
} catch (error) {
log.error("Failed to copy image to system clipboard:", error);
} }
} }
pasteLayers() { pasteLayers() {
if (this.internalClipboard.length === 0) return; if (this.internalClipboard.length === 0) return;
this.canvasLayers.saveState(); this.canvas.saveState();
const newLayers = []; const newLayers = [];
const pasteOffset = 20;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.internalClipboard.forEach(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 mouseX = this.canvas.lastMousePosition.x;
const mouseY = this.canvas.lastMousePosition.y;
const offsetX = mouseX - centerX;
const offsetY = mouseY - centerY;
this.internalClipboard.forEach(clipboardLayer => { this.internalClipboard.forEach(clipboardLayer => {
const newLayer = { const newLayer = {
...clipboardLayer, ...clipboardLayer,
x: clipboardLayer.x + pasteOffset / this.canvasLayers.viewport.zoom, x: clipboardLayer.x + offsetX,
y: clipboardLayer.y + pasteOffset / this.canvasLayers.viewport.zoom, y: clipboardLayer.y + offsetY,
zIndex: this.canvasLayers.layers.length zIndex: this.canvas.layers.length
}; };
this.canvasLayers.layers.push(newLayer); this.canvas.layers.push(newLayer);
newLayers.push(newLayer); newLayers.push(newLayer);
}); });
this.canvasLayers.updateSelection(newLayers); this.canvas.updateSelection(newLayers);
this.canvasLayers.render(); this.canvas.render();
log.info(`Pasted ${newLayers.length} layer(s).`); log.info(`Pasted ${newLayers.length} layer(s) at mouse position (${mouseX}, ${mouseY}).`);
} }
async handlePaste(addMode = 'mouse') { async handlePaste(addMode = 'mouse') {
try { try {
if (!navigator.clipboard?.read) { log.info(`Paste operation started with preference: ${this.clipboardPreference}`);
log.info("Browser does not support clipboard read API. Falling back to internal paste.");
this.pasteLayers();
return;
}
const clipboardItems = await navigator.clipboard.read(); await this.clipboardManager.handlePaste(addMode, this.clipboardPreference);
let imagePasted = false;
for (const item of clipboardItems) {
const imageType = item.types.find(type => type.startsWith('image/'));
if (imageType) {
const blob = await item.getType(imageType);
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = async () => {
await this.addLayerWithImage(img, {}, addMode);
};
img.src = event.target.result;
};
reader.readAsDataURL(blob);
imagePasted = true;
break;
}
}
if (!imagePasted) {
this.pasteLayers();
}
} catch (err) { } catch (err) {
log.error("Paste operation failed, falling back to internal paste. Error:", err); log.error("Paste operation failed:", err);
this.pasteLayers();
} }
} }
addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default') => { addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default') => {
if (!image) { if (!image) {
throw createValidationError("Image is required for layer creation"); throw createValidationError("Image is required for layer creation");
@@ -113,24 +151,24 @@ export class CanvasLayers {
log.debug("Adding layer with image:", image, "with mode:", addMode); log.debug("Adding layer with image:", image, "with mode:", addMode);
const imageId = generateUUID(); const imageId = generateUUID();
await saveImage(imageId, image.src); await saveImage(imageId, image.src);
this.canvasLayers.imageCache.set(imageId, image.src); this.canvas.imageCache.set(imageId, image.src);
let finalWidth = image.width; let finalWidth = image.width;
let finalHeight = image.height; let finalHeight = image.height;
let finalX, finalY; let finalX, finalY;
if (addMode === 'fit') { if (addMode === 'fit') {
const scale = Math.min(this.canvasLayers.width / image.width, this.canvasLayers.height / image.height); const scale = Math.min(this.canvas.width / image.width, this.canvas.height / image.height);
finalWidth = image.width * scale; finalWidth = image.width * scale;
finalHeight = image.height * scale; finalHeight = image.height * scale;
finalX = (this.canvasLayers.width - finalWidth) / 2; finalX = (this.canvas.width - finalWidth) / 2;
finalY = (this.canvasLayers.height - finalHeight) / 2; finalY = (this.canvas.height - finalHeight) / 2;
} else if (addMode === 'mouse') { } else if (addMode === 'mouse') {
finalX = this.canvasLayers.lastMousePosition.x - finalWidth / 2; finalX = this.canvas.lastMousePosition.x - finalWidth / 2;
finalY = this.canvasLayers.lastMousePosition.y - finalHeight / 2; finalY = this.canvas.lastMousePosition.y - finalHeight / 2;
} else { // 'center' or 'default' } else { // 'center' or 'default'
finalX = (this.canvasLayers.width - finalWidth) / 2; finalX = (this.canvas.width - finalWidth) / 2;
finalY = (this.canvasLayers.height - finalHeight) / 2; finalY = (this.canvas.height - finalHeight) / 2;
} }
const layer = { const layer = {
@@ -143,16 +181,16 @@ export class CanvasLayers {
originalWidth: image.width, originalWidth: image.width,
originalHeight: image.height, originalHeight: image.height,
rotation: 0, rotation: 0,
zIndex: this.canvasLayers.layers.length, zIndex: this.canvas.layers.length,
blendMode: 'normal', blendMode: 'normal',
opacity: 1, opacity: 1,
...layerProps ...layerProps
}; };
this.canvasLayers.layers.push(layer); this.canvas.layers.push(layer);
this.canvasLayers.updateSelection([layer]); this.canvas.updateSelection([layer]);
this.canvasLayers.render(); this.canvas.render();
this.canvasLayers.saveState(); this.canvas.saveState();
log.info("Layer added successfully"); log.info("Layer added successfully");
return layer; return layer;
@@ -163,26 +201,26 @@ export class CanvasLayers {
} }
moveLayerUp() { moveLayerUp() {
if (this.canvasLayers.selectedLayers.length === 0) return; if (this.canvas.selectedLayers.length === 0) return;
const selectedIndicesSet = new Set(this.canvasLayers.selectedLayers.map(layer => this.canvasLayers.layers.indexOf(layer))); const selectedIndicesSet = new Set(this.canvas.selectedLayers.map(layer => this.canvas.layers.indexOf(layer)));
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => b - a); const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => b - a);
sortedIndices.forEach(index => { sortedIndices.forEach(index => {
const targetIndex = index + 1; const targetIndex = index + 1;
if (targetIndex < this.canvasLayers.layers.length && !selectedIndicesSet.has(targetIndex)) { if (targetIndex < this.canvas.layers.length && !selectedIndicesSet.has(targetIndex)) {
[this.canvasLayers.layers[index], this.canvasLayers.layers[targetIndex]] = [this.canvasLayers.layers[targetIndex], this.canvasLayers.layers[index]]; [this.canvas.layers[index], this.canvas.layers[targetIndex]] = [this.canvas.layers[targetIndex], this.canvas.layers[index]];
} }
}); });
this.canvasLayers.layers.forEach((layer, i) => layer.zIndex = i); this.canvas.layers.forEach((layer, i) => layer.zIndex = i);
this.canvasLayers.render(); this.canvas.render();
this.canvasLayers.saveState(); this.canvas.saveState();
} }
moveLayerDown() { moveLayerDown() {
if (this.canvasLayers.selectedLayers.length === 0) return; if (this.canvas.selectedLayers.length === 0) return;
const selectedIndicesSet = new Set(this.canvasLayers.selectedLayers.map(layer => this.canvasLayers.layers.indexOf(layer))); const selectedIndicesSet = new Set(this.canvas.selectedLayers.map(layer => this.canvas.layers.indexOf(layer)));
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => a - b); const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => a - b);
@@ -190,17 +228,46 @@ export class CanvasLayers {
const targetIndex = index - 1; const targetIndex = index - 1;
if (targetIndex >= 0 && !selectedIndicesSet.has(targetIndex)) { if (targetIndex >= 0 && !selectedIndicesSet.has(targetIndex)) {
[this.canvasLayers.layers[index], this.canvasLayers.layers[targetIndex]] = [this.canvasLayers.layers[targetIndex], this.canvasLayers.layers[index]]; [this.canvas.layers[index], this.canvas.layers[targetIndex]] = [this.canvas.layers[targetIndex], this.canvas.layers[index]];
} }
}); });
this.canvasLayers.layers.forEach((layer, i) => layer.zIndex = i); this.canvas.layers.forEach((layer, i) => layer.zIndex = i);
this.canvasLayers.render(); this.canvas.render();
this.canvasLayers.saveState(); this.canvas.saveState();
}
/**
* Zmienia rozmiar wybranych warstw
* @param {number} scale - Skala zmiany rozmiaru
*/
resizeLayer(scale) {
if (this.canvas.selectedLayers.length === 0) return;
this.canvas.selectedLayers.forEach(layer => {
layer.width *= scale;
layer.height *= scale;
});
this.canvas.render();
this.canvas.saveState();
}
/**
* Obraca wybrane warstwy
* @param {number} angle - Kąt obrotu w stopniach
*/
rotateLayer(angle) {
if (this.canvas.selectedLayers.length === 0) return;
this.canvas.selectedLayers.forEach(layer => {
layer.rotation += angle;
});
this.canvas.render();
this.canvas.saveState();
} }
getLayerAtPosition(worldX, worldY) { getLayerAtPosition(worldX, worldY) {
for (let i = this.canvasLayers.layers.length - 1; i >= 0; i--) { for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
const layer = this.canvasLayers.layers[i]; const layer = this.canvas.layers[i];
const centerX = layer.x + layer.width / 2; const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2; const centerY = layer.y + layer.height / 2;
@@ -227,12 +294,12 @@ export class CanvasLayers {
} }
async mirrorHorizontal() { async mirrorHorizontal() {
if (this.canvasLayers.selectedLayers.length === 0) return; if (this.canvas.selectedLayers.length === 0) return;
const promises = this.canvasLayers.selectedLayers.map(layer => { const promises = this.canvas.selectedLayers.map(layer => {
return new Promise(resolve => { return new Promise(resolve => {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d'); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCanvas.width = layer.image.width; tempCanvas.width = layer.image.width;
tempCanvas.height = layer.image.height; tempCanvas.height = layer.image.height;
@@ -250,17 +317,17 @@ export class CanvasLayers {
}); });
await Promise.all(promises); await Promise.all(promises);
this.canvasLayers.render(); this.canvas.render();
this.canvasLayers.saveState(); this.canvas.saveState();
} }
async mirrorVertical() { async mirrorVertical() {
if (this.canvasLayers.selectedLayers.length === 0) return; if (this.canvas.selectedLayers.length === 0) return;
const promises = this.canvasLayers.selectedLayers.map(layer => { const promises = this.canvas.selectedLayers.map(layer => {
return new Promise(resolve => { return new Promise(resolve => {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d'); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCanvas.width = layer.image.width; tempCanvas.width = layer.image.width;
tempCanvas.height = layer.image.height; tempCanvas.height = layer.image.height;
@@ -278,14 +345,14 @@ export class CanvasLayers {
}); });
await Promise.all(promises); await Promise.all(promises);
this.canvasLayers.render(); this.canvas.render();
this.canvasLayers.saveState(); this.canvas.saveState();
} }
async getLayerImageData(layer) { async getLayerImageData(layer) {
try { try {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d'); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCanvas.width = layer.width; tempCanvas.width = layer.width;
tempCanvas.height = layer.height; tempCanvas.height = layer.height;
@@ -316,22 +383,21 @@ export class CanvasLayers {
} }
} }
updateOutputAreaSize(width, height, saveHistory = true) { updateOutputAreaSize(width, height, saveHistory = true) {
if (saveHistory) { if (saveHistory) {
this.canvasLayers.saveState(); this.canvas.saveState();
} }
this.canvasLayers.width = width; this.canvas.width = width;
this.canvasLayers.height = height; this.canvas.height = height;
this.canvasLayers.maskTool.resize(width, height); this.canvas.maskTool.resize(width, height);
this.canvasLayers.canvasLayers.width = width; this.canvas.canvas.width = width;
this.canvasLayers.canvasLayers.height = height; this.canvas.canvas.height = height;
this.canvasLayers.render(); this.canvas.render();
if (saveHistory) { if (saveHistory) {
this.canvasLayers.saveStateToDB(); this.canvas.canvasState.saveStateToDB();
} }
} }
@@ -355,7 +421,7 @@ export class CanvasLayers {
'sw': {x: -halfW, y: halfH}, 'sw': {x: -halfW, y: halfH},
'w': {x: -halfW, y: 0}, 'w': {x: -halfW, y: 0},
'nw': {x: -halfW, y: -halfH}, 'nw': {x: -halfW, y: -halfH},
'rot': {x: 0, y: -halfH - 20 / this.canvasLayers.viewport.zoom} 'rot': {x: 0, y: -halfH - 20 / this.canvas.viewport.zoom}
}; };
const worldHandles = {}; const worldHandles = {};
@@ -370,11 +436,11 @@ export class CanvasLayers {
} }
getHandleAtPosition(worldX, worldY) { getHandleAtPosition(worldX, worldY) {
if (this.canvasLayers.selectedLayers.length === 0) return null; if (this.canvas.selectedLayers.length === 0) return null;
const handleRadius = 8 / this.canvasLayers.viewport.zoom; const handleRadius = 8 / this.canvas.viewport.zoom;
for (let i = this.canvasLayers.selectedLayers.length - 1; i >= 0; i--) { for (let i = this.canvas.selectedLayers.length - 1; i >= 0; i--) {
const layer = this.canvasLayers.selectedLayers[i]; const layer = this.canvas.selectedLayers[i];
const handles = this.getHandles(layer); const handles = this.getHandles(layer);
for (const key in handles) { for (const key in handles) {
@@ -401,11 +467,69 @@ export class CanvasLayers {
background: #2a2a2a; background: #2a2a2a;
border: 1px solid #3a3a3a; border: 1px solid #3a3a3a;
border-radius: 4px; border-radius: 4px;
padding: 5px;
z-index: 10000; z-index: 10000;
box-shadow: 0 2px 10px rgba(0,0,0,0.3); 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) => {
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) => {
isDragging = true;
dragOffset.x = e.clientX - parseInt(menu.style.left);
dragOffset.y = e.clientY - parseInt(menu.style.top);
e.preventDefault();
e.stopPropagation();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
this.blendModes.forEach(mode => { this.blendModes.forEach(mode => {
const container = document.createElement('div'); const container = document.createElement('div');
container.className = 'blend-mode-container'; container.className = 'blend-mode-container';
@@ -427,58 +551,58 @@ export class CanvasLayers {
slider.min = '0'; slider.min = '0';
slider.max = '100'; slider.max = '100';
slider.value = this.canvasLayers.selectedLayer.opacity ? Math.round(this.canvasLayers.selectedLayer.opacity * 100) : 100; slider.value = this.canvas.selectedLayer.opacity ? Math.round(this.canvas.selectedLayer.opacity * 100) : 100;
slider.style.cssText = ` slider.style.cssText = `
width: 100%; width: 100%;
margin: 5px 0; margin: 5px 0;
display: none; display: none;
`; `;
if (this.canvasLayers.selectedLayer.blendMode === mode.name) { if (this.canvas.selectedLayer.blendMode === mode.name) {
slider.style.display = 'block'; slider.style.display = 'block';
option.style.backgroundColor = '#3a3a3a'; option.style.backgroundColor = '#3a3a3a';
} }
option.onclick = () => { option.onclick = () => {
menu.querySelectorAll('input[type="range"]').forEach(s => { content.querySelectorAll('input[type="range"]').forEach(s => {
s.style.display = 'none'; s.style.display = 'none';
}); });
menu.querySelectorAll('.blend-mode-container div').forEach(d => { content.querySelectorAll('.blend-mode-container div').forEach(d => {
d.style.backgroundColor = ''; d.style.backgroundColor = '';
}); });
slider.style.display = 'block'; slider.style.display = 'block';
option.style.backgroundColor = '#3a3a3a'; option.style.backgroundColor = '#3a3a3a';
if (this.canvasLayers.selectedLayer) { if (this.canvas.selectedLayer) {
this.canvasLayers.selectedLayer.blendMode = mode.name; this.canvas.selectedLayer.blendMode = mode.name;
this.canvasLayers.render(); this.canvas.render();
} }
}; };
slider.addEventListener('input', () => { slider.addEventListener('input', () => {
if (this.canvasLayers.selectedLayer) { if (this.canvas.selectedLayer) {
this.canvasLayers.selectedLayer.opacity = slider.value / 100; this.canvas.selectedLayer.opacity = slider.value / 100;
this.canvasLayers.render(); this.canvas.render();
} }
}); });
slider.addEventListener('change', async () => { slider.addEventListener('change', async () => {
if (this.canvasLayers.selectedLayer) { if (this.canvas.selectedLayer) {
this.canvasLayers.selectedLayer.opacity = slider.value / 100; this.canvas.selectedLayer.opacity = slider.value / 100;
this.canvasLayers.render(); this.canvas.render();
const saveWithFallback = async (fileName) => { const saveWithFallback = async (fileName) => {
try { try {
const uniqueFileName = generateUniqueFileName(fileName, this.canvasLayers.node.id); const uniqueFileName = generateUniqueFileName(fileName, this.canvas.node.id);
return await this.canvasLayers.saveToServer(uniqueFileName); return await this.canvas.saveToServer(uniqueFileName);
} catch (error) { } catch (error) {
console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error); console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error);
return await this.canvasLayers.saveToServer(fileName); return await this.canvas.saveToServer(fileName);
} }
}; };
await saveWithFallback(this.canvasLayers.widget.value); await saveWithFallback(this.canvas.widget.value);
if (this.canvasLayers.node) { if (this.canvas.node) {
app.graph.runStep(); app.graph.runStep();
} }
} }
@@ -486,14 +610,14 @@ export class CanvasLayers {
container.appendChild(option); container.appendChild(option);
container.appendChild(slider); container.appendChild(slider);
menu.appendChild(container); content.appendChild(container);
}); });
const container = this.canvasLayers.canvas.parentElement || document.body; const container = this.canvas.canvas.parentElement || document.body;
container.appendChild(menu); container.appendChild(menu);
const closeMenu = (e) => { const closeMenu = (e) => {
if (!menu.contains(e.target)) { if (!menu.contains(e.target) && !isDragging) {
this.closeBlendModeMenu(); this.closeBlendModeMenu();
document.removeEventListener('mousedown', closeMenu); document.removeEventListener('mousedown', closeMenu);
} }
@@ -531,11 +655,11 @@ export class CanvasLayers {
async getFlattenedCanvasAsBlob() { async getFlattenedCanvasAsBlob() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.canvasLayers.width; tempCanvas.width = this.canvas.width;
tempCanvas.height = this.canvasLayers.height; tempCanvas.height = this.canvas.height;
const tempCtx = tempCanvas.getContext('2d'); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
const sortedLayers = [...this.canvasLayers.layers].sort((a, b) => a.zIndex - b.zIndex); const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => { sortedLayers.forEach(layer => {
if (!layer.image) return; if (!layer.image) return;
@@ -568,14 +692,216 @@ export class CanvasLayers {
}); });
} }
async getFlattenedCanvasWithMaskAsBlob() {
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', { willReadFrequently: true });
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => {
if (!layer.image) return;
tempCtx.save();
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
tempCtx.translate(centerX, centerY);
tempCtx.rotate(layer.rotation * Math.PI / 180);
tempCtx.drawImage(
layer.image,
-layer.width / 2,
-layer.height / 2,
layer.width,
layer.height
);
tempCtx.restore();
});
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', { willReadFrequently: true });
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); // 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) {
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);
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; // Użyj kanału alpha maski
const invertedMaskAlpha = 1 - maskAlpha;
data[i + 3] = originalAlpha * invertedMaskAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
}
tempCanvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Canvas toBlob failed.'));
}
}, 'image/png');
});
}
async getFlattenedCanvasForMaskEditor() {
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', { willReadFrequently: true });
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => {
if (!layer.image) return;
tempCtx.save();
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
tempCtx.translate(centerX, centerY);
tempCtx.rotate(layer.rotation * Math.PI / 180);
tempCtx.drawImage(
layer.image,
-layer.width / 2,
-layer.height / 2,
layer.width,
layer.height
);
tempCtx.restore();
});
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', { willReadFrequently: true });
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 {
reject(new Error('Canvas toBlob failed.'));
}
}, 'image/png');
});
}
async getFlattenedSelectionAsBlob() { async getFlattenedSelectionAsBlob() {
if (this.canvasLayers.selectedLayers.length === 0) { if (this.canvas.selectedLayers.length === 0) {
return null; return null;
} }
return new Promise((resolve) => { return new Promise((resolve) => {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.canvasLayers.selectedLayers.forEach(layer => { this.canvas.selectedLayers.forEach(layer => {
const centerX = layer.x + layer.width / 2; const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2; const centerY = layer.y + layer.height / 2;
const rad = layer.rotation * Math.PI / 180; const rad = layer.rotation * Math.PI / 180;
@@ -613,11 +939,11 @@ export class CanvasLayers {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = newWidth; tempCanvas.width = newWidth;
tempCanvas.height = newHeight; tempCanvas.height = newHeight;
const tempCtx = tempCanvas.getContext('2d'); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCtx.translate(-minX, -minY); tempCtx.translate(-minX, -minY);
const sortedSelection = [...this.canvasLayers.selectedLayers].sort((a, b) => a.zIndex - b.zIndex); const sortedSelection = [...this.canvas.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
sortedSelection.forEach(layer => { sortedSelection.forEach(layer => {
if (!layer.image) return; if (!layer.image) return;

View File

@@ -301,7 +301,7 @@ export class CanvasRenderer {
ctx.moveTo(0, -layer.height / 2); ctx.moveTo(0, -layer.height / 2);
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom); ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
ctx.stroke(); ctx.stroke();
const handles = this.canvas.getHandles(layer); const handles = this.canvas.canvasLayers.getHandles(layer);
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000'; ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom; ctx.lineWidth = 1 / this.canvas.viewport.zoom;

View File

@@ -216,6 +216,7 @@ export class CanvasState {
await setCanvasState(this.canvas.node.id, state); await setCanvasState(this.canvas.node.id, state);
log.info("Canvas state saved to IndexedDB."); log.info("Canvas state saved to IndexedDB.");
this.lastSavedStateSignature = currentStateSignature; this.lastSavedStateSignature = currentStateSignature;
this.canvas.render();
}, 'CanvasState.saveStateToDB'); }, 'CanvasState.saveStateToDB');
if (immediate) { if (immediate) {
@@ -292,7 +293,7 @@ export class CanvasState {
const clonedCanvas = document.createElement('canvas'); const clonedCanvas = document.createElement('canvas');
clonedCanvas.width = maskCanvas.width; clonedCanvas.width = maskCanvas.width;
clonedCanvas.height = maskCanvas.height; clonedCanvas.height = maskCanvas.height;
const clonedCtx = clonedCanvas.getContext('2d'); const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
clonedCtx.drawImage(maskCanvas, 0, 0); clonedCtx.drawImage(maskCanvas, 0, 0);
this.maskUndoStack.push(clonedCanvas); this.maskUndoStack.push(clonedCanvas);
@@ -352,7 +353,7 @@ export class CanvasState {
if (this.maskUndoStack.length > 0) { if (this.maskUndoStack.length > 0) {
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1]; const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
const maskCanvas = this.canvas.maskTool.getMask(); const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d'); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(prevState, 0, 0); maskCtx.drawImage(prevState, 0, 0);
@@ -368,7 +369,7 @@ export class CanvasState {
const nextState = this.maskRedoStack.pop(); const nextState = this.maskRedoStack.pop();
this.maskUndoStack.push(nextState); this.maskUndoStack.push(nextState);
const maskCanvas = this.canvas.maskTool.getMask(); const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d'); const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(nextState, 0, 0); maskCtx.drawImage(nextState, 0, 0);

View File

@@ -96,6 +96,33 @@ async function createCanvasWidget(node, widget, app) {
border-radius: 6px; border-radius: 6px;
} }
.painter-clipboard-group {
display: flex;
align-items: center;
gap: 2px;
background-color: rgba(0,0,0,0.15);
padding: 3px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
position: relative;
}
.painter-clipboard-group::before {
content: "";
position: absolute;
top: -2px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(74, 108, 212, 0.6), transparent);
border-radius: 1px;
}
.painter-clipboard-group .painter-button {
margin: 1px;
}
.painter-separator { .painter-separator {
width: 1px; width: 1px;
height: 28px; height: 28px;
@@ -214,12 +241,13 @@ async function createCanvasWidget(node, widget, app) {
} }
.painter-tooltip table td:first-child { .painter-tooltip table td:first-child {
width: 45%; width: auto;
white-space: nowrap; white-space: nowrap;
min-width: fit-content;
} }
.painter-tooltip table td:last-child { .painter-tooltip table td:last-child {
width: 55%; width: auto;
} }
.painter-tooltip table tr:nth-child(odd) td { .painter-tooltip table tr:nth-child(odd) td {
@@ -368,7 +396,7 @@ async function createCanvasWidget(node, widget, app) {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
background-color: rgba(0, 0, 0, 0.8); background-color: rgba(0, 0, 0, 0.8);
z-index: 9998; z-index: 111;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -385,6 +413,8 @@ async function createCanvasWidget(node, widget, app) {
flex-direction: column; flex-direction: column;
position: relative; position: relative;
} }
`; `;
document.head.appendChild(style); document.head.appendChild(style);
@@ -484,7 +514,6 @@ async function createCanvasWidget(node, widget, app) {
helpTooltip.innerHTML = standardShortcuts; helpTooltip.innerHTML = standardShortcuts;
} }
// Najpierw wyświetlamy tooltip z visibility: hidden aby obliczyć jego wymiary
helpTooltip.style.visibility = 'hidden'; helpTooltip.style.visibility = 'hidden';
helpTooltip.style.display = 'block'; helpTooltip.style.display = 'block';
@@ -493,28 +522,22 @@ async function createCanvasWidget(node, widget, app) {
const viewportWidth = window.innerWidth; const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight; const viewportHeight = window.innerHeight;
// Obliczamy pozycję
let left = buttonRect.left; let left = buttonRect.left;
let top = buttonRect.bottom + 5; let top = buttonRect.bottom + 5;
// Sprawdzamy czy tooltip wychodzi poza prawy brzeg ekranu
if (left + tooltipRect.width > viewportWidth) { if (left + tooltipRect.width > viewportWidth) {
left = viewportWidth - tooltipRect.width - 10; left = viewportWidth - tooltipRect.width - 10;
} }
// Sprawdzamy czy tooltip wychodzi poza dolny brzeg ekranu
if (top + tooltipRect.height > viewportHeight) { if (top + tooltipRect.height > viewportHeight) {
// Wyświetlamy nad przyciskiem zamiast pod
top = buttonRect.top - tooltipRect.height - 5; top = buttonRect.top - tooltipRect.height - 5;
} }
// Upewniamy się, że tooltip nie wychodzi poza lewy brzeg
if (left < 10) left = 10; if (left < 10) left = 10;
// Upewniamy się, że tooltip nie wychodzi poza górny brzeg
if (top < 10) top = 10; if (top < 10) top = 10;
// Ustawiamy finalną pozycję i pokazujemy tooltip
helpTooltip.style.left = `${left}px`; helpTooltip.style.left = `${left}px`;
helpTooltip.style.top = `${top}px`; helpTooltip.style.top = `${top}px`;
helpTooltip.style.visibility = 'visible'; helpTooltip.style.visibility = 'visible';
@@ -539,7 +562,7 @@ async function createCanvasWidget(node, widget, app) {
reader.onload = (event) => { reader.onload = (event) => {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
canvas.addLayer(img, addMode); canvas.addLayer(img, {}, addMode);
}; };
img.src = event.target.result; img.src = event.target.result;
}; };
@@ -552,17 +575,116 @@ async function createCanvasWidget(node, widget, app) {
$el("button.painter-button.primary", { $el("button.painter-button.primary", {
textContent: "Import Input", textContent: "Import Input",
title: "Import image from another node", title: "Import image from another node",
onclick: () => canvas.importLatestImage() onclick: () => canvas.canvasIO.importLatestImage()
}),
$el("button.painter-button.primary", {
textContent: "Paste Image",
title: "Paste image from clipboard",
onclick: () => {
const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add");
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
canvas.handlePaste(addMode);
}
}), }),
$el("div.painter-clipboard-group", {}, [
$el("button.painter-button.primary", {
textContent: "Paste Image",
title: "Paste image from clipboard",
onclick: () => {
const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add");
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
canvas.canvasLayers.handlePaste(addMode);
}
}),
$el("button.painter-button", {
id: `clipboard-toggle-${node.id}`,
textContent: "📋 System",
title: "Toggle clipboard source: System Clipboard",
style: {
minWidth: "100px",
fontSize: "11px",
backgroundColor: "#4a4a4a"
},
onclick: (e) => {
const button = e.target;
if (canvas.canvasLayers.clipboardPreference === 'system') {
canvas.canvasLayers.clipboardPreference = 'clipspace';
button.textContent = "📋 Clipspace";
button.title = "Toggle clipboard source: ComfyUI Clipspace";
button.style.backgroundColor = "#4a6cd4";
} else {
canvas.canvasLayers.clipboardPreference = 'system';
button.textContent = "📋 System";
button.title = "Toggle clipboard source: System Clipboard";
button.style.backgroundColor = "#4a4a4a";
}
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
},
onmouseenter: (e) => {
const currentPreference = canvas.canvasLayers.clipboardPreference;
let tooltipContent = '';
if (currentPreference === 'system') {
tooltipContent = `
<h4>📋 System Clipboard Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>system clipboard</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ System clipboard (images, screenshots)</td></tr>
<tr><td></td><td>3⃣ System clipboard (file paths, URLs)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(255,165,0,0.2); border: 1px solid rgba(255,165,0,0.4); border-radius: 4px; font-size: 11px;">
⚠️ <strong>Security Note:</strong> "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop.
</div>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Best for:</strong> Working with screenshots, copied images, file paths, and urls.
</div>
`;
} else {
tooltipContent = `
<h4>📋 ComfyUI Clipspace Mode</h4>
<table>
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>ComfyUI Clipspace</strong> as flattened image</td></tr>
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
<tr><td></td><td>1⃣ Internal clipboard (copied layers)</td></tr>
<tr><td></td><td>2⃣ ComfyUI Clipspace (workflow images)</td></tr>
<tr><td></td><td>3⃣ System clipboard (fallback)</td></tr>
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
</table>
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
💡 <strong>Best for:</strong> ComfyUI workflow integration and node-to-node image transfer
</div>
`;
}
helpTooltip.innerHTML = tooltipContent;
helpTooltip.style.visibility = 'hidden';
helpTooltip.style.display = 'block';
const buttonRect = e.target.getBoundingClientRect();
const tooltipRect = helpTooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = buttonRect.left;
let top = buttonRect.bottom + 5;
if (left + tooltipRect.width > viewportWidth) {
left = viewportWidth - tooltipRect.width - 10;
}
if (top + tooltipRect.height > viewportHeight) {
top = buttonRect.top - tooltipRect.height - 5;
}
if (left < 10) left = 10;
if (top < 10) top = 10;
helpTooltip.style.left = `${left}px`;
helpTooltip.style.top = `${top}px`;
helpTooltip.style.visibility = 'visible';
},
onmouseleave: () => {
helpTooltip.style.display = 'none';
}
})
]),
]), ]),
$el("div.painter-separator"), $el("div.painter-separator"),
@@ -644,7 +766,7 @@ async function createCanvasWidget(node, widget, app) {
const height = parseInt(document.getElementById('canvas-height').value) || canvas.height; const height = parseInt(document.getElementById('canvas-height').value) || canvas.height;
canvas.updateOutputAreaSize(width, height); canvas.updateOutputAreaSize(width, height);
document.body.removeChild(dialog); document.body.removeChild(dialog);
// updateOutput is triggered by saveState in updateOutputAreaSize
}; };
document.getElementById('cancel-size').onclick = () => { document.getElementById('cancel-size').onclick = () => {
@@ -660,12 +782,12 @@ async function createCanvasWidget(node, widget, app) {
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Layer Up", textContent: "Layer Up",
title: "Move selected layer(s) up", title: "Move selected layer(s) up",
onclick: () => canvas.moveLayerUp() onclick: () => canvas.canvasLayers.moveLayerUp()
}), }),
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Layer Down", textContent: "Layer Down",
title: "Move selected layer(s) down", title: "Move selected layer(s) down",
onclick: () => canvas.moveLayerDown() onclick: () => canvas.canvasLayers.moveLayerDown()
}), }),
]), ]),
@@ -674,27 +796,27 @@ async function createCanvasWidget(node, widget, app) {
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Rotate +90°", textContent: "Rotate +90°",
title: "Rotate selected layer(s) by +90 degrees", title: "Rotate selected layer(s) by +90 degrees",
onclick: () => canvas.rotateLayer(90) onclick: () => canvas.canvasLayers.rotateLayer(90)
}), }),
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Scale +5%", textContent: "Scale +5%",
title: "Increase size of selected layer(s) by 5%", title: "Increase size of selected layer(s) by 5%",
onclick: () => canvas.resizeLayer(1.05) onclick: () => canvas.canvasLayers.resizeLayer(1.05)
}), }),
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Scale -5%", textContent: "Scale -5%",
title: "Decrease size of selected layer(s) by 5%", title: "Decrease size of selected layer(s) by 5%",
onclick: () => canvas.resizeLayer(0.95) onclick: () => canvas.canvasLayers.resizeLayer(0.95)
}), }),
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Mirror H", textContent: "Mirror H",
title: "Mirror selected layer(s) horizontally", title: "Mirror selected layer(s) horizontally",
onclick: () => canvas.mirrorHorizontal() onclick: () => canvas.canvasLayers.mirrorHorizontal()
}), }),
$el("button.painter-button.requires-selection", { $el("button.painter-button.requires-selection", {
textContent: "Mirror V", textContent: "Mirror V",
title: "Mirror selected layer(s) vertically", title: "Mirror selected layer(s) vertically",
onclick: () => canvas.mirrorVertical() onclick: () => canvas.canvasLayers.mirrorVertical()
}), }),
]), ]),
@@ -716,7 +838,7 @@ async function createCanvasWidget(node, widget, app) {
const selectedLayer = canvas.selectedLayers[0]; const selectedLayer = canvas.selectedLayers[0];
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer); const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
const imageData = await canvas.getLayerImageData(selectedLayer); const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer);
const response = await fetch("/matting", { const response = await fetch("/matting", {
method: "POST", method: "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
@@ -749,14 +871,14 @@ async function createCanvasWidget(node, widget, app) {
textContent: "Undo", textContent: "Undo",
title: "Undo last action", title: "Undo last action",
disabled: true, disabled: true,
onclick: () => canvas.undo() onclick: () => canvas.canvasState.undo()
}), }),
$el("button.painter-button", { $el("button.painter-button", {
id: `redo-button-${node.id}`, id: `redo-button-${node.id}`,
textContent: "Redo", textContent: "Redo",
title: "Redo last undone action", title: "Redo last undone action",
disabled: true, disabled: true,
onclick: () => canvas.redo() onclick: () => canvas.canvasState.redo()
}), }),
]), ]),
$el("div.painter-separator"), $el("div.painter-separator"),
@@ -845,15 +967,15 @@ async function createCanvasWidget(node, widget, app) {
style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"}, style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"},
onclick: async () => { onclick: async () => {
try { try {
const stats = canvas.getGarbageCollectionStats(); const stats = canvas.imageReferenceManager.getStats();
log.info("GC Stats before cleanup:", stats); log.info("GC Stats before cleanup:", stats);
await canvas.runGarbageCollection(); await canvas.imageReferenceManager.manualGarbageCollection();
const newStats = canvas.getGarbageCollectionStats(); const newStats = canvas.imageReferenceManager.getStats();
log.info("GC Stats after cleanup:", newStats); log.info("GC Stats after cleanup:", newStats);
alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${newStats.operationCount}/${newStats.operationThreshold}`); alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${canvas.imageReferenceManager.operationCount}/${canvas.imageReferenceManager.operationThreshold}`);
} catch (e) { } catch (e) {
log.error("Failed to run garbage collection:", e); log.error("Failed to run garbage collection:", e);
alert("Error running garbage collection. Check the console for details."); alert("Error running garbage collection. Check the console for details.");
@@ -917,9 +1039,23 @@ async function createCanvasWidget(node, widget, app) {
const triggerWidget = node.widgets.find(w => w.name === "trigger"); const triggerWidget = node.widgets.find(w => w.name === "trigger");
const updateOutput = () => { const updateOutput = async () => {
triggerWidget.value = (triggerWidget.value + 1) % 99999999; triggerWidget.value = (triggerWidget.value + 1) % 99999999;
// app.graph.runStep(); // Potentially not needed if we just want to mark dirty
try {
const new_preview = new Image();
const blob = await canvas.getFlattenedCanvasWithMaskAsBlob();
if (blob) {
new_preview.src = URL.createObjectURL(blob);
await new Promise(r => new_preview.onload = r);
node.imgs = [new_preview];
} else {
node.imgs = [];
}
} catch (error) {
console.error("Error updating node preview:", error);
}
}; };
const canvasContainer = $el("div.painterCanvasContainer.painter-container", { const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
@@ -955,70 +1091,8 @@ async function createCanvasWidget(node, widget, app) {
height: "100%" height: "100%"
} }
}, [controlPanel, canvasContainer]); }, [controlPanel, canvasContainer]);
const handleFileLoad = async (file) => {
log.info("File dropped:", file.name);
if (!file.type.startsWith('image/')) {
log.info("Dropped file is not an image.");
return;
}
const reader = new FileReader();
reader.onload = async (event) => {
log.debug("FileReader finished loading dropped file as data:URL.");
const img = new Image();
img.onload = async () => {
log.debug("Image object loaded from dropped data:URL.");
const scale = Math.min(
canvas.width / img.width,
canvas.height / img.height
);
const layer = {
image: img,
x: (canvas.width - img.width * scale) / 2,
y: (canvas.height - img.height * scale) / 2,
width: img.width * scale,
height: img.height * scale,
rotation: 0,
zIndex: canvas.layers.length,
blendMode: 'normal',
opacity: 1
};
canvas.layers.push(layer);
canvas.updateSelection([layer]);
canvas.render();
canvas.saveState();
log.info("Dropped layer added and state saved.");
};
img.src = event.target.result;
};
reader.readAsDataURL(file);
};
mainContainer.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
canvasContainer.classList.add('drag-over');
});
mainContainer.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
canvasContainer.classList.remove('drag-over');
});
mainContainer.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
canvasContainer.classList.remove('drag-over');
if (e.dataTransfer.files) {
for (const file of e.dataTransfer.files) {
await handleFileLoad(file);
}
}
});
const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer); const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer);
@@ -1079,14 +1153,34 @@ async function createCanvasWidget(node, widget, app) {
if (!window.canvasExecutionStates) { if (!window.canvasExecutionStates) {
window.canvasExecutionStates = new Map(); window.canvasExecutionStates = new Map();
} }
node.canvasWidget = canvas; node.canvasWidget = canvas;
setTimeout(() => { setTimeout(() => {
canvas.loadInitialState(); canvas.loadInitialState();
}, 100); }, 100);
const showPreviewWidget = node.widgets.find(w => w.name === "show_preview");
if (showPreviewWidget) {
const originalCallback = showPreviewWidget.callback;
showPreviewWidget.callback = function (value) {
if (originalCallback) {
originalCallback.call(this, value);
}
if (canvas && canvas.setPreviewVisibility) {
canvas.setPreviewVisibility(value);
}
if (node.graph && node.graph.canvas) {
node.setDirtyCanvas(true, true);
}
};
}
return { return {
canvas: canvas, canvas: canvas,
panel: controlPanel panel: controlPanel
@@ -1161,7 +1255,6 @@ app.registerExtension({
return; return;
} }
// Iterate through every widget attached to this node
this.widgets.forEach(w => { this.widgets.forEach(w => {
log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`); log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`);
}); });
@@ -1213,7 +1306,32 @@ app.registerExtension({
originalGetExtraMenuOptions?.apply(this, arguments); originalGetExtraMenuOptions?.apply(this, arguments);
const self = this; const self = this;
const maskEditorIndex = options.findIndex(option =>
option && option.content === "Open in MaskEditor"
);
if (maskEditorIndex !== -1) {
options.splice(maskEditorIndex, 1);
}
const newOptions = [ const newOptions = [
{
content: "Open in MaskEditor",
callback: async () => {
try {
log.info("Opening LayerForge canvas in MaskEditor");
if (self.canvasWidget && self.canvasWidget.startMaskEditor) {
await self.canvasWidget.startMaskEditor();
} else {
log.error("Canvas widget not available");
alert("Canvas not ready. Please try again.");
}
} catch (e) {
log.error("Error opening MaskEditor:", e);
alert(`Failed to open MaskEditor: ${e.message}`);
}
},
},
{ {
content: "Open Image", content: "Open Image",
callback: async () => { callback: async () => {
@@ -1227,6 +1345,19 @@ app.registerExtension({
} }
}, },
}, },
{
content: "Open Image with Mask Alpha",
callback: async () => {
try {
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
log.error("Error opening image with mask:", e);
}
},
},
{ {
content: "Copy Image", content: "Copy Image",
callback: async () => { callback: async () => {
@@ -1241,6 +1372,20 @@ app.registerExtension({
} }
}, },
}, },
{
content: "Copy Image with Mask Alpha",
callback: async () => {
try {
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
const item = new ClipboardItem({'image/png': blob});
await navigator.clipboard.write([item]);
log.info("Image with mask alpha copied to clipboard.");
} catch (e) {
log.error("Error copying image with mask:", e);
alert("Failed to copy image with mask to clipboard.");
}
},
},
{ {
content: "Save Image", content: "Save Image",
callback: async () => { callback: async () => {
@@ -1259,6 +1404,24 @@ app.registerExtension({
} }
}, },
}, },
{
content: "Save Image with Mask Alpha",
callback: async () => {
try {
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'canvas_output_with_mask.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
log.error("Error saving image with mask:", e);
}
},
},
]; ];
if (options.length > 0) { if (options.length > 0) {
options.unshift({content: "___", disabled: true}); options.unshift({content: "___", disabled: true});

View File

@@ -8,7 +8,7 @@ export class MaskTool {
this.mainCanvas = canvasInstance.canvas; this.mainCanvas = canvasInstance.canvas;
this.onStateChange = callbacks.onStateChange || null; this.onStateChange = callbacks.onStateChange || null;
this.maskCanvas = document.createElement('canvas'); this.maskCanvas = document.createElement('canvas');
this.maskCtx = this.maskCanvas.getContext('2d'); this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
this.x = 0; this.x = 0;
this.y = 0; this.y = 0;
@@ -21,7 +21,7 @@ export class MaskTool {
this.lastPosition = null; this.lastPosition = null;
this.previewCanvas = document.createElement('canvas'); this.previewCanvas = document.createElement('canvas');
this.previewCtx = this.previewCanvas.getContext('2d'); this.previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true });
this.previewVisible = false; this.previewVisible = false;
this.previewCanvasInitialized = false; this.previewCanvasInitialized = false;
@@ -162,7 +162,7 @@ export class MaskTool {
if (this.brushHardness === 1) { if (this.brushHardness === 1) {
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`; this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
} else { } else {
// hardness: 1 = hard edge, 0 = soft edge
const innerRadius = gradientRadius * this.brushHardness; const innerRadius = gradientRadius * this.brushHardness;
const gradient = this.maskCtx.createRadialGradient( const gradient = this.maskCtx.createRadialGradient(
canvasX, canvasY, innerRadius, canvasX, canvasY, innerRadius,
@@ -220,7 +220,7 @@ export class MaskTool {
const tempCanvas = document.createElement('canvas'); const tempCanvas = document.createElement('canvas');
tempCanvas.width = this.maskCanvas.width; tempCanvas.width = this.maskCanvas.width;
tempCanvas.height = this.maskCanvas.height; tempCanvas.height = this.maskCanvas.height;
const tempCtx = tempCanvas.getContext('2d'); const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCtx.drawImage(this.maskCanvas, 0, 0); tempCtx.drawImage(this.maskCanvas, 0, 0);
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height); const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
const data = imageData.data; const data = imageData.data;
@@ -258,7 +258,7 @@ export class MaskTool {
this.maskCanvas.width = newWidth; this.maskCanvas.width = newWidth;
this.maskCanvas.height = newHeight; this.maskCanvas.height = newHeight;
this.maskCtx = this.maskCanvas.getContext('2d'); this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
if (oldMask.width > 0 && oldMask.height > 0) { if (oldMask.width > 0 && oldMask.height > 0) {
@@ -281,18 +281,15 @@ export class MaskTool {
} }
setMask(image) { setMask(image) {
// `this.x` i `this.y` przechowują pozycję lewego górnego rogu płótna maski
// względem lewego górnego rogu widoku. Zatem (-this.x, -this.y) to pozycja
// lewego górnego rogu widoku na płótnie maski.
const destX = -this.x; const destX = -this.x;
const destY = -this.y; const destY = -this.y;
// Wyczyść tylko ten obszar na dużym płótnie maski, który odpowiada
// widocznemu obszarowi wyjściowemu.
this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height); this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height);
// Narysuj nowy obraz maski (który ma rozmiar obszaru wyjściowego)
// dokładnie w tym wyczyszczonym miejscu.
this.maskCtx.drawImage(image, destX, destY); this.maskCtx.drawImage(image, destX, destY);
if (this.onStateChange) { if (this.onStateChange) {

View File

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

View File

@@ -8,7 +8,7 @@
* @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx * @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
*/ */
export function generateUUID() { export function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8); const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16); return v.toString(16);
}); });
@@ -179,7 +179,7 @@ export function debounce(func, wait, immediate) {
*/ */
export function throttle(func, limit) { export function throttle(func, limit) {
let inThrottle; let inThrottle;
return function(...args) { return function (...args) {
if (!inThrottle) { if (!inThrottle) {
func.apply(this, args); func.apply(this, args);
inThrottle = true; inThrottle = true;
@@ -241,7 +241,7 @@ export function createCanvas(width, height, contextType = '2d', contextOptions =
if (width) canvas.width = width; if (width) canvas.width = width;
if (height) canvas.height = height; if (height) canvas.height = height;
const ctx = canvas.getContext(contextType, contextOptions); const ctx = canvas.getContext(contextType, contextOptions);
return { canvas, ctx }; return {canvas, ctx};
} }
/** /**
@@ -284,5 +284,5 @@ export function generateUniqueFileName(baseName, nodeId) {
*/ */
export function isPointInRect(pointX, pointY, rectX, rectY, rectWidth, rectHeight) { export function isPointInRect(pointX, pointY, rectX, rectY, rectWidth, rectHeight) {
return pointX >= rectX && pointX <= rectX + rectWidth && return pointX >= rectX && pointX <= rectX + rectWidth &&
pointY >= rectY && pointY <= rectY + rectHeight; pointY >= rectY && pointY <= rectY + rectHeight;
} }

View File

@@ -1,5 +1,6 @@
import {createModuleLogger} from "./LoggerUtils.js"; import {createModuleLogger} from "./LoggerUtils.js";
import {withErrorHandling, createValidationError} from "../ErrorHandler.js"; import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
const log = createModuleLogger('ImageUtils'); const log = createModuleLogger('ImageUtils');
export function validateImageData(data) { export function validateImageData(data) {
@@ -114,7 +115,7 @@ export function applyMaskToImageData(imageData, maskData) {
}; };
} }
export const prepareImageForCanvas = withErrorHandling(function(inputImage) { export const prepareImageForCanvas = withErrorHandling(function (inputImage) {
log.info("Preparing image for canvas:", inputImage); log.info("Preparing image for canvas:", inputImage);
if (Array.isArray(inputImage)) { if (Array.isArray(inputImage)) {
@@ -122,7 +123,7 @@ export const prepareImageForCanvas = withErrorHandling(function(inputImage) {
} }
if (!inputImage || !inputImage.shape || !inputImage.data) { if (!inputImage || !inputImage.shape || !inputImage.data) {
throw createValidationError("Invalid input image format", { inputImage }); throw createValidationError("Invalid input image format", {inputImage});
} }
const shape = inputImage.shape; const shape = inputImage.shape;
@@ -161,13 +162,13 @@ export const prepareImageForCanvas = withErrorHandling(function(inputImage) {
* @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji * @param {HTMLImageElement|HTMLCanvasElement} image - Obraz do konwersji
* @returns {Promise<Object>} Tensor z danymi obrazu * @returns {Promise<Object>} Tensor z danymi obrazu
*/ */
export const imageToTensor = withErrorHandling(async function(image) { export const imageToTensor = withErrorHandling(async function (image) {
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image.width || image.naturalWidth; canvas.width = image.width || image.naturalWidth;
canvas.height = image.height || image.naturalHeight; canvas.height = image.height || image.naturalHeight;
@@ -197,14 +198,14 @@ export const imageToTensor = withErrorHandling(async function(image) {
* @param {Object} tensor - Tensor z danymi obrazu * @param {Object} tensor - Tensor z danymi obrazu
* @returns {Promise<HTMLImageElement>} Obraz HTML * @returns {Promise<HTMLImageElement>} Obraz HTML
*/ */
export const tensorToImage = withErrorHandling(async function(tensor) { export const tensorToImage = withErrorHandling(async function (tensor) {
if (!tensor || !tensor.data || !tensor.shape) { if (!tensor || !tensor.data || !tensor.shape) {
throw createValidationError("Invalid tensor format", { tensor }); throw createValidationError("Invalid tensor format", {tensor});
} }
const [, height, width, channels] = tensor.shape; const [, height, width, channels] = tensor.shape;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
@@ -239,13 +240,13 @@ export const tensorToImage = withErrorHandling(async function(tensor) {
* @param {number} maxHeight - Maksymalna wysokość * @param {number} maxHeight - Maksymalna wysokość
* @returns {Promise<HTMLImageElement>} Przeskalowany obraz * @returns {Promise<HTMLImageElement>} Przeskalowany obraz
*/ */
export const resizeImage = withErrorHandling(async function(image, maxWidth, maxHeight) { export const resizeImage = withErrorHandling(async function (image, maxWidth, maxHeight) {
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
const originalWidth = image.width || image.naturalWidth; const originalWidth = image.width || image.naturalWidth;
const originalHeight = image.height || image.naturalHeight; const originalHeight = image.height || image.naturalHeight;
@@ -274,7 +275,7 @@ export const resizeImage = withErrorHandling(async function(image, maxWidth, max
* @param {number} size - Rozmiar miniatury (kwadrat) * @param {number} size - Rozmiar miniatury (kwadrat)
* @returns {Promise<HTMLImageElement>} Miniatura * @returns {Promise<HTMLImageElement>} Miniatura
*/ */
export const createThumbnail = withErrorHandling(async function(image, size = 128) { export const createThumbnail = withErrorHandling(async function (image, size = 128) {
return resizeImage(image, size, size); return resizeImage(image, size, size);
}, 'createThumbnail'); }, 'createThumbnail');
@@ -285,13 +286,13 @@ export const createThumbnail = withErrorHandling(async function(image, size = 12
* @param {number} quality - Jakość (0-1) dla formatów stratnych * @param {number} quality - Jakość (0-1) dla formatów stratnych
* @returns {string} Base64 string * @returns {string} Base64 string
*/ */
export const imageToBase64 = withErrorHandling(function(image, format = 'png', quality = 0.9) { export const imageToBase64 = withErrorHandling(function (image, format = 'png', quality = 0.9) {
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image.width || image.naturalWidth; canvas.width = image.width || image.naturalWidth;
canvas.height = image.height || image.naturalHeight; canvas.height = image.height || image.naturalHeight;
@@ -307,7 +308,7 @@ export const imageToBase64 = withErrorHandling(function(image, format = 'png', q
* @param {string} base64 - Base64 string * @param {string} base64 - Base64 string
* @returns {Promise<HTMLImageElement>} Obraz * @returns {Promise<HTMLImageElement>} Obraz
*/ */
export const base64ToImage = withErrorHandling(function(base64) { export const base64ToImage = withErrorHandling(function (base64) {
if (!base64) { if (!base64) {
throw createValidationError("Base64 string is required"); throw createValidationError("Base64 string is required");
} }
@@ -327,9 +328,9 @@ export const base64ToImage = withErrorHandling(function(base64) {
*/ */
export function isValidImage(image) { export function isValidImage(image) {
return image && return image &&
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) && (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
image.width > 0 && image.width > 0 &&
image.height > 0; image.height > 0;
} }
/** /**
@@ -371,9 +372,9 @@ export function createImageFromSource(source) {
* @param {string} color - Kolor tła (CSS color) * @param {string} color - Kolor tła (CSS color)
* @returns {Promise<HTMLImageElement>} Pusty obraz * @returns {Promise<HTMLImageElement>} Pusty obraz
*/ */
export const createEmptyImage = withErrorHandling(function(width, height, color = 'transparent') { export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;

View File

@@ -43,7 +43,7 @@ export function createAutoLogger(level = LogLevel.DEBUG) {
* @returns {Function} Opakowana funkcja * @returns {Function} Opakowana funkcja
*/ */
export function withErrorLogging(operation, log, operationName) { export function withErrorLogging(operation, log, operationName) {
return async function(...args) { return async function (...args) {
try { try {
log.debug(`Starting ${operationName}`); log.debug(`Starting ${operationName}`);
const result = await operation.apply(this, args); const result = await operation.apply(this, args);
@@ -62,10 +62,10 @@ export function withErrorLogging(operation, log, operationName) {
* @param {string} methodName - Nazwa metody * @param {string} methodName - Nazwa metody
*/ */
export function logMethod(log, methodName) { export function logMethod(log, methodName) {
return function(target, propertyKey, descriptor) { return function (target, propertyKey, descriptor) {
const originalMethod = descriptor.value; const originalMethod = descriptor.value;
descriptor.value = async function(...args) { descriptor.value = async function (...args) {
try { try {
log.debug(`${methodName || propertyKey} started`); log.debug(`${methodName || propertyKey} started`);
const result = await originalMethod.apply(this, args); const result = await originalMethod.apply(this, args);

View File

@@ -130,7 +130,6 @@ class WebSocketManager {
log.warn("WebSocket not open. Queuing message."); log.warn("WebSocket not open. Queuing message.");
this.messageQueue.push(message); this.messageQueue.push(message);
if (!this.isConnecting) { if (!this.isConnecting) {
this.connect(); this.connect();
@@ -147,7 +146,6 @@ class WebSocketManager {
log.debug(`Flushing ${this.messageQueue.length} queued messages.`); log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
while (this.messageQueue.length > 0) { while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift(); const message = this.messageQueue.shift();
this.socket.send(message); this.socket.send(message);

View File

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

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "layerforge" name = "layerforge"
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing." description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
version = "1.2.4" version = "1.3.0"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"] dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]