152 Commits
v1.4.0 ... main

Author SHA1 Message Date
Dariusz L
835d94a11d Merge pull request #24 from diodiogod/fix/keyboard-shortcuts-focus-check
Fix keyboard shortcuts capturing events when node is unfocused
2026-02-20 16:28:04 +01:00
Dariusz L
061e2b7a9a Cleanup: Remove accidentally committed python\__pycache__folder 2026-02-05 16:23:10 +01:00
diodiogod
b1f29eefdb Cleanup: Remove accidentally committed __pycache__ and .vscode folders 2026-02-03 15:12:51 -03:00
diodiogod
b8fbcee67a Fix focus/modifier issues and improve multi-layer selection UX 2026-02-03 02:00:24 -03:00
diodiogod
d44d944f2d Fix: Restore drag-and-drop in layers panel 2026-02-02 23:15:08 -03:00
diodiogod
ab5d71597a Fix: Allow paste event to bubble for system clipboard access 2026-02-02 20:56:56 -03:00
diodiogod
ce4d332987 Fix Ctrl+V to paste from system clipboard when internal clipboard is empty 2026-02-02 18:22:54 -03:00
diodiogod
9b04729561 Enable cross-workflow node duplication with layers
Store canvas state in IndexedDB clipboard on copy, allowing nodes to be
duplicated with their layers preserved across different workflows.

When copying a node, the canvas state is stored in a special
'__clipboard__' entry that persists across workflow switches. On paste,
if the source node doesn't exist (indicating cross-workflow paste), the
system falls back to loading from the clipboard entry.
2026-01-19 18:24:42 -03:00
diodiogod
27ad139cd5 Add layer copy/paste and node duplication with layers
Implements two new features:
- Layer copy/paste within canvas using Ctrl+C/V
- Node duplication that preserves all layers

Layer Copy/Paste:
- Added Ctrl+V keyboard shortcut handler for pasting layers
- Intercept keydown events during capture phase to handle before ComfyUI
- Focus canvas when layer is clicked to ensure shortcuts work
- Prevent layers panel from stealing focus on mousedown

Node Duplication:
- Store source node ID during serialize for copy operations
- Track pending copy sources across node ID changes (-1 to real ID)
- Copy canvas state from source to destination in onAdded hook
- Use Map to persist copy metadata through node lifecycle
2026-01-19 17:57:14 -03:00
diodiogod
66cbcb641b Add retry logic for image loading validation
Increase robustness of image loading check before sending canvas data.
Now retries up to 5 times with 200ms delays (1 second total) instead
of a single 100ms wait.

This fixes the 'Failed to get confirmation from server' error that
appeared when executing workflows immediately after ComfyUI restart,
before images finished loading from IndexedDB.

Prevents workflow execution failures due to timing issues during
canvas initialization.
2026-01-17 20:30:36 -03:00
diodiogod
986e0a23a2 Fix canvas sizing bug by separating display and output dimensions
The canvas was getting corrupted to a small strip because of confusion
between two different dimension types:
- Output area dimensions (logical working area, e.g. 512x512)
- Display canvas dimensions (actual pixels shown on screen)

Root cause: Setting canvas.width/height attributes to match output area
while also using CSS width:100%/height:100% created conflicts. When
zooming or reloading, wrong dimensions would be read and saved.

Fix: Remove canvas element width/height attribute assignments. Let the
render loop control display size based on clientWidth/clientHeight.
Keep output area dimensions separate.

This prevents the canvas from being saved with corrupted tiny dimensions
and fixes the issue where canvas would only show in a small strip after
zooming or reloading workflows.
2026-01-17 15:03:00 -03:00
diodiogod
068ed9ee59 Skip sending canvas data for bypassed nodes
Fix critical issue where LayerForge was trying to send canvas data
even when the node was bypassed (mode === 4). This caused unnecessary
errors and blocked workflow execution.

Now properly checks node.mode before attempting to send data via
WebSocket, skipping bypassed nodes entirely.
2026-01-15 09:40:33 -03:00
diodiogod
4e5ef18d93 Fix canvas initialization and sizing bugs
- Add image loading validation before sending canvas data to server
  Prevents 'Failed to get confirmation' error when images haven't
  finished loading after workflow reload. Waits 100ms and checks
  if all layer images are complete before rendering output.

- Improve layer loading error handling in CanvasState
  Better logging when layers fail to load from IndexedDB.
  Allows empty canvas as valid state instead of failing.

- Add ResizeObserver for canvas container
  Fixes bug where canvas only shows in top half of node.
  Watches container size changes and triggers re-render to ensure
  canvas dimensions are correctly calculated after DOM layout.
2026-01-15 09:38:59 -03:00
diodiogod
be37966b45 Add DOM connection check to prevent capturing events in subgraphs
Ensure the canvas element is actually connected to the DOM before
handling paste events. This prevents LayerForge from capturing paste
events when navigating in subgraphs where the canvas is not visible.

Adds check for canvas.isConnected and document.body.contains() to
verify the canvas is part of the active DOM tree.
2026-01-14 16:11:22 -03:00
diodiogod
dd5fc5470f Fix keyboard shortcuts capturing events when node is unfocused
Prevent LayerForge from intercepting Ctrl+C, Ctrl+V, and other keyboard
shortcuts when the canvas is not focused. This was causing unwanted
popups and interfering with other nodes in ComfyUI.

Changes:
- Remove document.body focus check from handlePasteEvent
- Add focus validation to handleKeyDown before processing shortcuts
- Modifier keys (Ctrl, Shift, Alt, Meta) are still tracked globally
- All other shortcuts only trigger when canvas is focused

Fixes issue where paste events were captured globally regardless of focus.
2026-01-10 11:12:31 -03:00
Dariusz L
1f1d0aeb7d Update README.md 2025-11-13 17:10:29 +01:00
Dariusz L
da55d741d6 Update README.md 2025-11-13 16:37:25 +01:00
Dariusz L
959c47c29b Update README with quick links and compatibility info
Added quick start and workflow example links for easier navigation. Improved installation instructions and clarified manual install steps. Documented known incompatibility with Vue Nodes and provided guidance for reverting settings. Enhanced support section with actionable items.
2025-11-13 16:21:47 +01:00
Dariusz L
ab7ab9d1a8 Update README.md 2025-10-27 18:52:33 +01:00
Dariusz L
d8d33089d2 Update pyproject.toml 2025-10-27 17:21:34 +01:00
Dariusz L
de67252a87 Add grab icon for layer movement
Implemented grab icon feature in transform mode to move selected layers without changing selection, even when behind other layers. Added hover detection, cursor updates, and visual rendering in CanvasInteractions.ts and CanvasRenderer.ts.
2025-10-27 17:20:53 +01:00
Dariusz L
4acece1602 Update bug_report.yml 2025-09-11 19:08:52 +02:00
Dariusz L
ffa5b136bf Update pyproject.toml 2025-09-04 23:14:15 +02:00
Dariusz L
7a5ecb3919 Fix matting model check and frontend flow
Added proper backend validation for both config.json and model.safetensors to confirm model availability. Updated frontend logic to use /matting/check-model response, preventing unnecessary download notifications.
2025-09-04 23:10:22 +02:00
Dariusz L
20ab861315 Update feature-request.yml 2025-08-27 15:20:33 +02:00
Dariusz L
6750141bcc Update bug_report.yml 2025-08-27 15:04:03 +02:00
Dariusz L
5ea2562b32 added // @ts-ignore to compile to ts 2025-08-22 19:11:15 +02:00
Dariusz L
079fb7b362 Update bug_report.yml 2025-08-22 16:44:36 +02:00
Dariusz L
e05e2d8d8a Update feature-request.yml 2025-08-22 16:40:46 +02:00
Dariusz L
ae55c8a827 Update ComfyUIdownloads.yml 2025-08-21 18:51:38 +02:00
Dariusz L
e21fab0061 Update README.md 2025-08-20 23:29:00 +02:00
Dariusz L
36a80bbb7e Update README.md 2025-08-20 23:26:22 +02:00
Dariusz L
492e06068a Update README.md 2025-08-19 03:07:50 +02:00
Dariusz L
9af1491c68 Update pyproject.toml 2025-08-14 15:04:32 +02:00
Dariusz L
b04795d6e8 Fix CORS for images loaded from IndexedDB
Add crossOrigin='anonymous' to image elements in CanvasState._createLayerFromSrc() method. This prevents canvas tainting when images are restored from IndexedDB after page refresh, ensuring export functions work correctly.
2025-08-14 15:04:08 +02:00
Dariusz L
8d1545bb7e Fix context menu canvas access issues
ix context menu canvas access paths to properly reference canvasWidget.canvas methods instead of canvasWidget directly.
2025-08-14 14:59:28 +02:00
Dariusz L
f6a240c535 Fix CORS issue for Send to Clipspace function
Add crossOrigin='anonymous' attribute to image elements in CanvasLayers.ts to prevent canvas tainting. This resolves the "Tainted canvases may not be exported" error when using the Send to Clipspace feature.
2025-08-14 14:49:18 +02:00
Dariusz L
d1ceb6291b feat: add base64 image paste
Implemented data URI (base64) support for paste operations.
2025-08-14 14:39:01 +02:00
Dariusz L
868221b285 feat: add notification system with deduplication
Implemented a comprehensive notification system with smart deduplication for LayerForge's "Paste Image" operations. The system prevents duplicate error/warning notifications while providing clear feedback for all clipboard operations including success, failure, and edge cases.
2025-08-14 14:30:51 +02:00
Dariusz L
0f4f2cb1b0 feat: add interactive output area transform handles
Implemented drag-to-resize functionality for the output area with visual transform handles on corners and edges. Users can now interactively resize the output area by dragging handles instead of using dialogs, with support for grid snapping and aspect ratio preservation.
2025-08-14 13:54:10 +02:00
Dariusz L
7ce7194cbf feat: add auto adjust output area for selected layers
Implements one-click auto adjustment of output area to fit selected layers with intelligent bounding box calculation. Supports rotation, crop mode, flips, and includes automatic padding with complete canvas state updates.
2025-08-14 12:23:29 +02:00
Dariusz L
990853f8c7 Update Issue_template 2025-08-11 18:16:50 +02:00
Dariusz L
5fb163cd59 Update pyproject.toml 2025-08-09 17:07:24 +02:00
Dariusz L
19d3238680 Fix mismatch between preview and actual mask
Corrected the overlay alignment issue on the canvas so that the preview mask now matches the actual mask positioning. This ensures consistent visual accuracy during editing.
2025-08-09 17:07:13 +02:00
Dariusz L
c9860cac9e Add Master Visibility Toggle to Layers Panel
Introduce a three-state checkbox in CanvasLayersPanel header to control visibility of all layers at once. Supports automatic state updates and integrates with renderLayers() for seamless layer management.
2025-08-09 16:15:11 +02:00
Dariusz L
00cf74a3c2 Update pyproject.toml 2025-08-09 15:06:41 +02:00
Dariusz L
00a39d756d fix: increase z-index for LayerForge fullscreen mode
Fixed LayerForge fullscreen mode displaying behind ComfyUI interface elements by increasing z-index from 111 to 999999. Fullscreen mode now properly overlays all UI components as intended.
2025-08-09 15:06:16 +02:00
Dariusz L
d0e6bf8b3d Update pyproject.toml 2025-08-09 03:24:49 +02:00
Dariusz L
da37900b33 Refactor: unify image handling in CanvasIO via helpers
Removed duplicate code from CanvasIO.ts and replaced it with unified helpers from ImageUtils.ts. All tensor-to-image conversions and image creation now use centralized utility functions for consistency and maintainability.
2025-08-09 03:07:18 +02:00
Dariusz L
64c5e49707 Unify mask scaling logic with scaleImageToFit util
Refactored mask scaling and drawing into the scaleImageToFit method in ImageUtils.ts. Updated CanvasIO.ts to use this utility, reducing code duplication and improving maintainability.
2025-08-09 02:43:36 +02:00
Dariusz L
06d94f6a63 Improve mask loading logic on node connection
Updated mask loading to immediately use available data from connected nodes and preserve existing masks if none is provided. Backend mask data is only fetched after workflow execution, ensuring no stale data is loaded during connection.
2025-08-09 02:33:28 +02:00
Dariusz L
b21d6e3502 implement strict image/mask input separation
Enhanced LayerForge input handling to strictly separate image and mask loading based on connection type. Images now only load when allowImage=true and masks only when allowMask=true, preventing unintended cross-loading between input types.
2025-08-09 01:44:31 +02:00
Dariusz L
285ad035b2 Improve batch images and mask handling
Fixed batch image processing to prevent duplicates and layer deletion while ensuring proper mask loading from input_mask. Images are now added as new layers without removing existing ones, and masks are always checked from backend regardless of image state.
2025-08-09 00:49:58 +02:00
Dariusz L
949ffa0143 Repair Undo/Redo in Masking Mode 2025-08-08 22:41:19 +02:00
Dariusz L
afdac52144 Added mask and image input 2025-08-08 22:23:15 +02:00
Dariusz L
bf55d13f67 Update pyproject.toml 2025-08-08 17:14:05 +02:00
Dariusz L
de83a884c2 Switch mask preview from chunked to canvas rendering
Replaced chunked rendering approach with direct canvas drawing for mask preview, then applying to main canvas. Added "Mask Opacity" slider.
2025-08-08 17:13:44 +02:00
Dariusz L
dd2a81b6f2 add advanced brush cursor visualization
Implemented dynamic brush cursor with visual feedback for size (circle radius), strength (opacity), and hardness (solid/dashed border with gradient). Added overlay canvas system for smooth cursor updates without affecting main rendering performance.
2025-08-08 14:20:55 +02:00
Dariusz L
176b9d03ac unify modifier key handling in CanvasInteractions
Implemented centralized modifier state management with ModifierState interface and getModifierState() method. This eliminates inconsistencies between event-based and state-based modifier checking across mouse, wheel, and keyboard interactions.
2025-08-08 13:50:13 +02:00
Dariusz L
e4f44c10e8 resolve TypeScript errors and memory leaks
Fixed all TypeScript compilation errors by defining a dedicated TransformOrigin type and adding proper null checks. Implemented comprehensive event handler cleanup to prevent memory leaks and improved cross-platform support with Meta key handling for macOS users.
2025-08-08 13:15:21 +02:00
Dariusz L
11dd554204 Update pyproject.toml 2025-08-06 23:09:19 +02:00
Dariusz L
9f21ff13ae Add clipspace utils with full backward support
Refactored clipspace handling into ClipspaceUtils with validateAndFixClipspace() and safeClipspacePaste() for consistent, defensive logic. Ensures full backward compatibility with all ComfyUI versions and eliminates duplicated code.
2025-08-06 23:08:02 +02:00
Dariusz L
1a1d8748cb Update pyproject.toml 2025-08-04 01:50:29 +02:00
Dariusz L
38973b4698 Rename CanvasNode to LayerForgeNode
Replaced all instances of CanvasNode with LayerForgeNode to prevent naming conflicts with the ComfyUI-YCanvas node.
2025-08-04 01:49:37 +02:00
Dariusz L
1bd261bee0 Adjust Style disabled buttons 2025-08-04 01:00:35 +02:00
Dariusz L
df6979a59b Fix selection border points for vertical/horizontal flip 2025-08-04 00:46:14 +02:00
Dariusz L
2427f0bc5f Add fallback instructions to error for node confirmation failure 2025-08-03 23:08:52 +02:00
Dariusz L
3356c631bb Fix toggle mask switch UI sync with auto refresh
Ensured the toggle mask switch UI stays in sync with mask visibility when auto_refresh_after_generation hides or shows the mask. The checkbox and switch now correctly reflect the current mask state, preventing UI desynchronization and improving user experience.
2025-08-03 22:25:25 +02:00
Dariusz L
3d34bfafd5 Fix matting: refresh image after background removal
Fixed an issue where images were not immediately refreshed after background removal (matting). Now, the canvas updates instantly when the background is removed, ensuring correct display without requiring manual scaling or other actions.
2025-08-03 22:14:39 +02:00
Dariusz L
3c3e6934d7 Refactor CanvasLayers.ts: unify & deduplicate logic
Refactored CanvasLayers.ts to eliminate code duplication by unifying five main areas into reusable functions, following the DRY principle. Improved code readability, maintainability, and flexibility with better naming, documentation, and parameterization.
2025-08-03 21:57:47 +02:00
Dariusz L
84e1e4820c Improve cache selection for scaling with blend & crop
Enhanced the system to always select the best available cache based on both blend area and crop, prioritizing exact matches. Prevented costly operations and live rendering during scaling for optimal performance and smooth user experience.
2025-08-03 21:01:46 +02:00
Dariusz L
012368c52b Revert Cached Blend Area 2025-08-03 18:20:41 +02:00
Dariusz L
82c42f99fe Fix clipboard switch tooltip to update on toggle
Refactored tooltip logic for the clipboard switch so it now updates immediately when toggled, showing the correct template without requiring mouse movement. Added helper functions and improved event handling for better UX.
2025-08-03 14:56:18 +02:00
Dariusz L
5da0855a52 Added tooltip to mask visibility switch 2025-08-03 14:38:40 +02:00
Dariusz L
ed9fdf5d60 disable delete button when no layers selected
Added updateButtonStates() to enable/disable delete button based on selection
Updated control setup and selection handlers to call this method
Added CSS for disabled button state and tooltip
Delete button now disables when no layers are selected; all other panel features unchanged
2025-08-03 14:33:20 +02:00
Dariusz L
d84b9385ad Refactor: Move CanvasLayersPanel inline styles to external CSS
Moved all inline styles from CanvasLayersPanel.ts to layers_panel.css
Updated TypeScript to load external CSS and removed injectStyles()
Replaced inline styles with CSS classes in UI methods
Ensured all panel features and interactions work as before
Improved code maintainability and consistency with project structure
2025-08-03 14:27:31 +02:00
Dariusz L
c4318d4923 Refactor: Move blend mode menu styles to CSS file
Moved all blend mode menu styles from CanvasLayers.ts to a dedicated CSS file. Replaced inline styles with CSS classes and preserved all functionality.
2025-08-03 14:18:21 +02:00
Dariusz L
5b54ab28cb Update pyproject.toml 2025-08-03 02:45:20 +02:00
Dariusz L
503ec126a5 Fix DataCloneError by excluding non-serializable cache from state
Excluded blendedImageCache and blendedImageDirty properties from layer serialization in CanvasState.ts to prevent DataCloneError when saving state. This ensures that only serializable data is sent to Web Workers, while runtime caches are regenerated as needed. Blend area performance optimization remains functional without serialization issues.
2025-08-03 02:43:30 +02:00
Dariusz L
3d6e3901d0 Fix button crop icon display and update functionality 2025-08-03 02:19:52 +02:00
Dariusz L
4df89a793e Fix layer selection bug by sorting hit-test by z-index 2025-08-02 19:52:08 +02:00
Dariusz L
e42e08e35d Crop mode button to switch 2025-08-02 19:43:03 +02:00
Dariusz L
7ed6f7ee93 Implement crop mode for cropping selected layer 2025-08-02 19:05:11 +02:00
Dariusz L
9b0d4b3149 Update pyproject.toml 2025-08-01 15:56:25 +02:00
Dariusz L
f0f3d419f8 Update README.md 2025-08-01 15:51:38 +02:00
Dariusz L
26e2036388 Repair cnr_id 2025-08-01 15:23:18 +02:00
Dariusz L
22f5d028a2 Update README.md 2025-07-30 16:39:49 +02:00
Dariusz L
0b817411b7 Update pyproject.toml 2025-07-30 15:48:55 +02:00
Dariusz L
f755507974 Update README.md 2025-07-30 15:00:19 +02:00
Dariusz L
d65dc4349a Update README.md 2025-07-30 14:47:33 +02:00
Dariusz L
04033a48cb Update README.md 2025-07-30 14:44:28 +02:00
Dariusz L
05c0b91ecc Example_workflows 2025-07-30 14:41:05 +02:00
Dariusz L
4de1812370 Update config.js 2025-07-30 13:35:53 +02:00
Dariusz L
e3cef041c9 Implement robust world-based positioning for Blend Mode menu
Redesigned the positioning system for the Blend Mode menu, inspired by the "Custom Output Area" logic. The menu now anchors precisely to the top-right corner of the viewport and stays in place during panning and zooming.
2025-07-30 13:06:01 +02:00
Dariusz L
03950b1787 Implement adaptive selection frame based on layer coverage
Added a smart selection frame for layers that dynamically switches between solid and dashed lines depending on visibility:

Functionality:

Solid line: visible edge segments, not covered by other layers

Dashed line: covered edge segments, obscured by layers with higher zIndex and opacity > 0.1
2025-07-30 11:43:29 +02:00
Dariusz L
3d60c6aafa Move setOutputAreaSize 2025-07-30 11:37:26 +02:00
Dariusz L
fcb5565a28 Fix "Output Area Size" button behavior using new sizing method
Refactored the "Output Area Size" button to use the new setOutputAreaSize method:

Correctly sets output area size and position based on current system logic (custom shape, extensions, outputAreaBounds)

Fully functional across all editor modes

New resizing logic:

Operates relative to the current center of the output area

Output area expands or contracts around its center without repositioning

Ensures center remains unchanged as expected

This fix provides precise control over layout dimensions and aligns with user expectations across workflows.
2025-07-30 11:17:48 +02:00
Dariusz L
fb5bbdd187 Unify styling for "Run GC" and "Clear Cache" buttons
Updated the "Run GC" and "Clear Cache" buttons to match the styling of standard UI buttons
2025-07-30 11:10:13 +02:00
Dariusz L
7662a501a4 Add right-click deselection for layers
Implemented a new feature: right-clicking a layer in the layer panel now deselects it (if it was selected).
2025-07-30 11:06:37 +02:00
Dariusz L
fc4c343418 Improve minimized "Custom Output Area Active" styling
Unified the appearance of the minimized "Custom Output Area Active" bar with the full menu styling:
2025-07-30 11:05:04 +02:00
Dariusz L
b09f9789de Last Point yellow
Added a clear first point distinction when drawing a custom shape: if the mouse cursor is near the beginning of a line (the shape can be closed), the first point is drawn in yellow and larger. This allows the user to see when they can close the shape with a single click. The code has been compiled and is ready to use. The functionality works as expected.
2025-07-30 10:37:12 +02:00
Dariusz L
0de9aabb72 Hide pending generation areas 2025-07-30 09:52:07 +02:00
Dariusz L
3941104bd5 Tooltip Menu change to modern layout 2025-07-28 19:42:10 +02:00
Dariusz L
6b04e3891b Changed to switch button mask on/off 2025-07-28 19:02:29 +02:00
Dariusz L
257bada28d Nice Icon Comfy Clipspace
Nice Icon Comfy Clipspace
2025-07-28 17:54:50 +02:00
Dariusz L
7f39cfc8ed modernized the "Custom Shape Menu"
I updated src/css/custom_shape_menu.css with new styles for a modern look and feel, and I refactored src/CustomShapeMenu.ts to use a new HTML structure for the checkboxes that works with the updated CSS. The menu should now be fully functional with the new design.
2025-07-28 17:05:57 +02:00
Dariusz L
d2b7b396aa moved all CSS from src/CustomShapeMenu.ts to a new file
moved all CSS from src/CustomShapeMenu.ts to a new file src/css/custom_shape_menu.css and updated the TypeScript file to use the external stylesheet. This improves code organization and maintainability.
2025-07-28 16:43:59 +02:00
Dariusz L
0bae8c9c9d fixed the persistent shape preview bug
fixed the persistent shape preview bug where blue dashed lines would remain visible even when the user was no longer interacting with the slider. The issue was caused by race conditions and timing problems in the preview system.
2025-07-28 16:38:45 +02:00
Dariusz L
f57b9f6b58 Layout change 2025-07-28 15:57:28 +02:00
Dariusz L
bb687a768b Refactor text rendering and adjust viewport defaults
Introduced a drawTextWithBackground helper to centralize and simplify text rendering with backgrounds in CanvasRenderer (JS/TS). Updated all relevant usages to use this helper. Adjusted initial viewport x/y offsets in Canvas and Canvas.ts for improved default positioning.
2025-07-28 02:50:01 +02:00
Dariusz L
5e9869f827 Refactor notifications and improve matting UX
Refactored notification utilities for a more modern, reusable notification system and added info/success/error notifications to the background removal (matting) workflow. Removed the custom error dialog in favor of notifications, and exposed all notification types for debugging. Updated imports and cleaned up notification-related code.
2025-07-28 00:58:04 +02:00
Dariusz L
bfea0cdbab Enhance notification system and auto-correct node_id
Adds a modern, type-based notification UI with support for success, error, info, warning, and alert styles, including a new showAlertNotification function. CanvasState now auto-corrects the node_id widget before saving state and notifies the user if a correction occurs. CanvasView centering logic now uses the actual canvas container for more accurate viewport adjustments.
2025-07-28 00:28:15 +02:00
Dariusz L
7701ceda56 Center canvas viewport on fullscreen toggle
Adds logic to adjust the canvas viewport to keep content centered when entering or exiting fullscreen mode. The adjustment calculates the difference in container sizes and updates the viewport position accordingly, improving user experience during fullscreen transitions.
2025-07-27 22:51:37 +02:00
Dariusz L
9e4e618955 Add ESC key support to close fullscreen editor
Implemented an ESC key handler to allow users to close the fullscreen editor mode using the Escape key. Updated the editor button tooltip and the shortcuts documentation to reflect this new shortcut.
2025-07-27 21:39:18 +02:00
Dariusz L
19d1f9aa52 Move mask rendering before preview outlines
The mask is now drawn after layers but before all preview outlines, ensuring correct visual stacking. The redundant mask rendering code after the preview outlines has been removed.
2025-07-27 21:23:05 +02:00
Dariusz L
8d6cd783ec Restore and save outputAreaBounds in CanvasState
Adds logic to restore outputAreaBounds from saved state if available, or fallback to a default for legacy saves. Also ensures outputAreaBounds is included when saving state, improving consistency across sessions.
2025-07-27 21:17:52 +02:00
Dariusz L
7fc49d72f5 Update MaskEditorIntegration.js 2025-07-27 21:11:06 +02:00
Dariusz L
e68fc7e2cb Add dynamic wait time based on image size
Introduced a calculateDynamicWaitTime method to determine wait time before applying a mask, scaling the delay according to the image's pixel count. This improves responsiveness and reliability for different image sizes in the mask editor integration.
2025-07-27 21:10:58 +02:00
Dariusz L
058a1c4d67 Standart Error in Utils 2025-07-27 20:30:06 +02:00
Dariusz L
64ee2c6abb createElement to createCanvas 2025-07-27 20:02:45 +02:00
Dariusz L
f36f91487f Replace alert with notification utilities and refactor canvas creation
Replaces all uses of alert() with showErrorNotification or showSuccessNotification for improved user experience and consistency. Refactors canvas creation to use the createCanvas utility function across multiple files, reducing code duplication and improving maintainability. Also updates layer ID generation to use generateUUID.
2025-07-27 19:12:25 +02:00
Dariusz L
207bacc1f8 Refactor image and mask utility functions
Moved convertToImage, createMaskFromImageSrc, and canvasToMaskImage from MaskProcessingUtils and mask_utils to ImageUtils for better modularity and reuse. Updated imports in dependent modules to use the new locations. Removed duplicate implementations from mask_utils and MaskProcessingUtils.
2025-07-27 18:47:41 +02:00
Dariusz L
9d0c946e22 Refactor canvas creation to use createCanvas utility
Replaces direct usage of document.createElement('canvas') and manual context setup with the createCanvas utility across multiple utility modules. This change improves code consistency, reduces duplication, and centralizes canvas/context creation logic. Also updates notification usage in ClipboardManager to use showNotification and showInfoNotification utilities.
2025-07-27 18:34:46 +02:00
Dariusz L
7e71d3ec3e Refactor image, mask, and notification logic into utils
Extracted image upload, mask processing, notification, and preview update logic into dedicated utility modules. Updated MaskEditorIntegration and SAMDetectorIntegration to use these new utilities, reducing code duplication and improving maintainability.
2025-07-27 18:19:35 +02:00
Dariusz L
25d07767f1 Rename canvasMask to maskEditorIntegration in Canvas
Refactors the Canvas class to rename the 'canvasMask' property to 'maskEditorIntegration' for consistency. Updates all references and import to use the new name with the .js extension.
2025-07-27 18:00:55 +02:00
Dariusz L
35f8a85c8b Delete CanvasMask.js 2025-07-27 17:39:40 +02:00
Dariusz L
4019a8a88f Rename CanvasMask to MaskEditorIntegration
Renamed the CanvasMask class and file to MaskEditorIntegration for improved clarity and consistency. Updated all references in Canvas and SAMDetectorIntegration to use the new name.
2025-07-27 17:39:13 +02:00
Dariusz L
0d6bfb01d6 Optimize mask handling and shape mask UX for output area
Replaces full-canvas mask operations with getMaskForOutputArea() for significant performance improvements when processing masks for the output area. Refines shape mask removal and application logic to ensure correct mask state when changing expansion values or custom shapes, including auto-removal and re-application of masks. Adds throttling to shape preview rendering for better UI responsiveness. Improves mask removal to eliminate antialiasing artifacts and updates SAM mask integration to use correct output area positioning.
2025-07-27 17:23:08 +02:00
Dariusz L
6491d80225 Refactor MaskTool with utility methods and deduplication
Introduces utility methods to eliminate code duplication in chunk operations, mask creation, and shape processing. Adds universal chunk processing, chunk operation, and canvas helper methods. Refactors shape mask application and removal to use unified logic, and consolidates morphological and feathering operations for masks. Improves maintainability and readability by centralizing repeated logic.
2025-07-27 14:26:26 +02:00
Dariusz L
6121403460 Refactor MaskTool chunk operations and shape offset handling
Introduces utility methods for chunk bounds calculation, intersection, and activation for better code reuse and clarity. Refactors shape mask application and removal to consistently account for output area extensions, and centralizes chunk empty status updates. Improves chunk activation logic for mask and shape operations to enhance visibility and maintainability.
2025-07-27 13:35:30 +02:00
Dariusz L
03c841380e Optimize mask chunk activation and canvas updates
Introduces active chunk management for mask drawing, activating only nearby chunks during drawing for performance. Updates the active mask canvas to show all chunks but optimizes updates to redraw only active chunks during drawing, reducing lag. Adds LRU-style tracking and safety limits for active chunks, and improves chunk activation logic for both drawing and mask application.
2025-07-27 01:11:31 +02:00
Dariusz L
46e92f30e8 Refactor CanvasInteractions for code reuse and clarity
Introduces helper methods to reduce code duplication and improve readability in CanvasInteractions. Mouse coordinate extraction, event prevention, zoom operations, drag-and-drop styling, and layer wheel transformations are now handled by dedicated methods. This refactor centralizes logic, making the codebase easier to maintain and extend.
2025-07-27 00:10:56 +02:00
Dariusz L
796a65d251 Remove unused and redundant methods from canvas modules
Cleaned up the codebase by removing unused or redundant methods from Canvas, CanvasIO, CanvasLayers, CanvasLayersPanel, CanvasRenderer, and MaskTool. This reduces code complexity and improves maintainability without affecting core functionality.
2025-07-26 23:39:29 +02:00
Dariusz L
f28783348e Fix custom shape output area extension and mask logic
Refactors how custom output area shapes interact with extensions, ensuring the shape's position and mask application remain consistent when extensions are toggled. Moves output area shape logic to CanvasInteractions, tracks original shape position, and updates all rendering, IO, and mask operations to use the correct coordinates. Improves mask chunk clearing and adds chunked mask application/removal for shape masks, ensuring correct behavior with expansion and feathering.
2025-07-26 22:39:03 +02:00
Dariusz L
f329a6ded5 Refactor output area bounds handling for custom shapes
Output area bounds are now positioned relative to the world, not always at (0,0), and are updated to match custom shape placement. Rendering and extension logic have been updated to respect the new bounds, and the mask tool now adjusts to the output area position. Also sets log level to DEBUG for development.
2025-07-26 21:20:18 +02:00
Dariusz L
ca9e1890c4 Refactor mask system to use chunked canvas storage
Replaces the single large mask canvas with a chunked system, where mask data is stored in 512x512 pixel chunks. Updates all mask drawing, compositing, and manipulation logic to operate on these chunks, improving performance and scalability for large or sparse masks. The active mask canvas is now a composite of all non-empty chunks, and all mask operations (drawing, setting, clearing) are adapted to the new chunked architecture.
2025-07-26 19:19:23 +02:00
Dariusz L
14c5f291a6 Refactor output area and mask handling for flexible canvas bounds
This update introduces a unified output area bounds system, allowing the output area to be extended in all directions independently of the custom shape. All mask and layer operations now reference outputAreaBounds, ensuring correct alignment and rendering. The mask tool, mask editor, and export logic have been refactored to use these bounds, and a new UI for output area extension with live preview and tooltips has been added. The code also improves logging and visualization of mask and output area boundaries.
2025-07-26 18:27:14 +02:00
Dariusz L
1fc06f65a2 Refine shape mask UI and ensure hard-edged mask removal
Consolidates shape mask controls into a styled container, improves UI logic for showing/hiding sub-options, and updates slider initialization to reflect current values. Mask removal now always uses a hard-edged shape, even if feathering was previously applied, ensuring complete erasure of feathered areas. The mask application logic also clears the maximum possible mask area before applying the new mask to prevent artifacts from previous slider values. Checkbox and slider labels are updated for clarity.
2025-07-26 01:50:44 +02:00
Dariusz L
ccfa2b6cfb Add shape mask preview system for expansion and feather sliders
Implements a real-time blue outline preview when adjusting the expansion and feather sliders in the custom shape mask UI. The preview updates dynamically while dragging, and applies the mask only on mouse release. Adds a viewport change listener to keep the preview in sync during zooming or panning. Refactors mask expansion/contraction to use fast morphological operations for sharp edges and updates mask drawing to handle holes correctly.
2025-07-26 00:16:11 +02:00
Dariusz L
4c4856f9e7 Add shape mask preview system for expansion and feather sliders
Implements a real-time blue outline preview for shape mask expansion and feather adjustments in CustomShapeMenu, providing immediate visual feedback while dragging sliders. Adds a dedicated shape preview canvas and efficient morphological operations in MaskTool for sharp, fast previews. Also synchronizes the preview with viewport changes and refines mask expansion/contraction logic for improved accuracy and performance.
2025-07-26 00:15:44 +02:00
Dariusz L
24ef702f16 Add custom shape mask menu with expansion and feathering
Introduces a CustomShapeMenu UI component for managing custom output area shape masks, including options for auto-applying the mask, expansion/contraction, and feathering. Updates Canvas and MaskTool to support these new mask operations, and ensures the menu is shown or hidden based on shape presence. Adds distance transform-based algorithms for accurate mask expansion and feathering.
2025-07-25 18:40:21 +02:00
Dariusz L
764e802311 Add blend area effect for layers with distance field mask
Introduces a 'blendArea' property to layers and UI controls for adjusting it. Implements distance field mask generation in ImageAnalysis.ts and applies the mask during layer rendering for smooth edge blending. Refactors CanvasRenderer to delegate layer drawing to CanvasLayers for proper blend area support.
2025-07-24 23:34:27 +02:00
Dariusz L
58720a8eca Prevent browser context menu on custom right-click menus
Always prevent the default browser context menu and stop event propagation on right-click interactions and custom menus. This ensures that only the application's custom context menus are shown and avoids interference from the browser's native context menu.
2025-07-24 20:17:04 +02:00
Dariusz L
3b1a69041c Add layer visibility toggle and icon support
Introduces a 'visible' property to layers and updates all relevant logic to support toggling layer visibility. Adds visibility toggle icons to the layers panel using a new IconLoader utility, with SVG and fallback canvas icons. Updates rendering, selection, and batch preview logic to respect layer visibility. Also improves blend mode menu UI and ensures new/pasted layers are always added on top with correct z-index.
2025-07-24 19:10:17 +02:00
Dariusz L
2778b8df9f Add ShapeTool for custom output area selection
Introduces ShapeTool to allow users to define custom polygonal output areas by holding Shift+S and clicking to add points. The selected shape is used to crop and mask images and layers, and is visualized on the canvas. Updates Canvas, CanvasIO, CanvasInteractions, CanvasLayers, CanvasRenderer, and types to support shape-based output areas, including shape-aware import, export, and rendering logic.
2025-07-24 15:12:53 +02:00
Dariusz L
b655b68412 Update release.yml 2025-07-23 23:22:40 +02:00
Dariusz L
d7838d565f Merge branch 'main' of https://github.com/Azornes/Comfyui-LayerForge 2025-07-23 23:20:38 +02:00
Dariusz L
3990ed1605 Update release.yml 2025-07-23 23:20:27 +02:00
Dariusz L
ea35f2a405 Update LICENSE 2025-07-23 23:14:49 +02:00
Dariusz L
d25ec6b25c Update feature request template and release workflow
Removed the unused 'type' field from the feature request issue template. Improved the release workflow to fetch commit history since the last version tag instead of the last tag, ensuring more accurate changelog generation.
2025-07-23 22:22:17 +02:00
Dariusz L
2997be536d Rename and update feature request issue template
Renamed the feature request issue template from .yaml to .yml and updated the name field to include an emoji for better visibility.
2025-07-23 22:16:18 +02:00
Dariusz L
3309cae337 Create feature-request.yaml 2025-07-23 22:13:31 +02:00
83 changed files with 23679 additions and 4878 deletions

View File

@@ -1,20 +1,75 @@
name: 🐞 Bug Report name: 🐞 Bug Report
description: Report an error or unexpected behavior description: 'Report something that is not working correctly'
title: "[BUG] " title: "[BUG] "
labels: [bug] labels: [bug]
body: body:
- type: checkboxes
attributes:
label: Prerequisites
options:
- label: I am running the latest version of [ComfyUI](https://github.com/comfyanonymous/ComfyUI/releases)
required: true
- label: I am running the latest version of [ComfyUI_frontend](https://github.com/Comfy-Org/ComfyUI_frontend/releases)
required: true
- label: I am running the latest version of LayerForge [Github](https://github.com/Azornes/Comfyui-LayerForge/releases) | [Manager](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
required: true
- label: I have searched existing(open/closed) issues to make sure this isn't a duplicate
required: true
- type: textarea
id: description
attributes:
label: What happened?
description: A clear and concise description of the bug. Include screenshots or videos if helpful.
placeholder: |
Example: "When I connect a image to an Input, the connection line appears but the workflow fails to execute with an error message..."
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to Reproduce
description: How can I reproduce this issue? Please attach your workflow (JSON or PNG) if needed.
placeholder: |
1. Connect Image to Input
2. Click Queue Prompt
3. See error
validations:
required: true
- type: dropdown
id: severity
attributes:
label: How is this affecting you?
options:
- Crashes ComfyUI completely
- Workflow won't execute
- Feature doesn't work as expected
- Visual/UI issue only
- Minor inconvenience
validations:
required: true
- type: dropdown
id: browser
attributes:
label: Browser
description: Which browser are you using?
options:
- Chrome/Chromium
- Firefox
- Safari
- Edge
- Other
validations:
required: true
- type: markdown - type: markdown
attributes: attributes:
value: | value: |
**Thank you for reporting a bug!** ## Additional Information (Optional)
Please follow these steps to capture all necessary information: *The following fields help me debug complex issues but are not required for most bug reports.*
### ✅ Before You Report:
1. Make sure you have the **latest versions**:
- [ComfyUI Github](https://github.com/comfyanonymous/ComfyUI/releases)
- [LayerForge Github](https://github.com/Azornes/Comfyui-LayerForge/releases) or via [ComfyUI Node Manager](https://registry.comfy.org/publishers/azornes/nodes/layerforge)
2. Gather the required logs:
### 🔍 Enable Debug Logs (for **full** logs): ### 🔍 Enable Debug Logs (for **full** logs):
#### 1. Edit `config.js` (Frontend Logs): #### 1. Edit `config.js` (Frontend Logs):
@@ -46,75 +101,13 @@ body:
``` ```
➡️ **Restart ComfyUI** after applying these changes to activate full logging. ➡️ **Restart ComfyUI** after applying these changes to activate full logging.
- type: input
id: environment
attributes:
label: Environment (OS, ComfyUI version, LayerForge version)
placeholder: e.g. Windows 11, ComfyUI v0.3.43, LayerForge v1.2.4
validations:
required: true
- type: input
id: browser
attributes:
label: Browser & Version
placeholder: e.g. Chrome 115.0.0, Firefox 120.1.0
validations:
required: true
- type: textarea - type: textarea
id: what_happened id: console-errors
attributes: attributes:
label: What Happened? label: Console Errors
placeholder: Describe the issue you encountered
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
placeholder: |
1. …
2. …
3. …
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
placeholder: Describe what you expected to happen
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
placeholder: Describe what happened instead
validations:
required: true
- type: textarea
id: backend_logs
attributes:
label: ComfyUI (Backend) Logs
description: |
After enabling DEBUG logs, please:
1. Restart ComfyUI.
2. Reproduce the issue.
3. Copy-paste the newest **TEXT** logs from the terminal/console here.
validations:
required: true
- type: textarea
id: console_logs
attributes:
label: Browser Console Logs
description: | description: |
If you see red error messages in the browser console (F12), paste them here
More info:
After enabling DEBUG logs: After enabling DEBUG logs:
1. Open Developer Tools → Console. 1. Open Developer Tools → Console.
- Chrome/Edge (Win/Linux): `Ctrl+Shift+J` - Chrome/Edge (Win/Linux): `Ctrl+Shift+J`
@@ -128,11 +121,25 @@ body:
- Safari: 🗑 icon or `Cmd+K`. - Safari: 🗑 icon or `Cmd+K`.
3. Reproduce the issue. 3. Reproduce the issue.
4. Copy-paste the **TEXT** logs here (no screenshots). 4. Copy-paste the **TEXT** logs here (no screenshots).
validations: render: javascript
required: true
- type: markdown - type: textarea
id: logs
attributes: attributes:
value: | label: Logs
**Optional:** You can also **attach a screenshot or video** to demonstrate the issue visually. description: |
Simply drag & drop or paste image/video files into this issue form. GitHub supports common image formats and MP4/GIF files. If relevant, paste any terminal/server logs here
More info:
After enabling DEBUG logs, please:
1. Restart ComfyUI.
2. Reproduce the issue.
3. Copy-paste the newest **TEXT** logs from the terminal/console here.
render: shell
- type: textarea
id: additional
attributes:
label: Additional Context, Environment (OS, ComfyUI versions, Comfyui-LayerForge version)
description: Any other information that might help (OS, GPU, specific nodes involved, etc.)

View File

@@ -3,11 +3,17 @@ description: Suggest improvements or additions to documentation
title: "[Docs] " title: "[Docs] "
labels: [documentation] labels: [documentation]
body: body:
- type: markdown
attributes:
value: |
> This template is only for suggesting improvements or additions **to existing documentation**.
> If you want to suggest a new feature, functionality, or enhancement for the project itself, please use the **Feature Request** template instead.
> Thank you!
- type: input - type: input
id: doc_area id: doc_area
attributes: attributes:
label: Area of documentation label: Area of documentation
placeholder: e.g. Getting started, Node API, Deployment guide placeholder: e.g. Key Features, Installation, Controls & Shortcuts
validations: validations:
required: true required: true
- type: textarea - type: textarea

View File

@@ -0,0 +1,48 @@
name: ✨ Feature Request
description: Suggest an idea for this project
title: '[Feature Request]: '
labels: ['enhancement']
body:
- type: markdown
attributes:
value: |
## Before suggesting a new feature...
Please make sure of the following:
1. You are using the latest version of the project
2. The functionality you want to propose does not already exist
I also recommend using an AI assistant to check whether the feature is already included.
To do this, simply:
- Copy and paste the entire **README.md** file
- Ask if your desired feature is already covered
This helps to avoid duplicate requests for features that are already available.
- type: markdown
attributes:
value: |
*Please fill this form with as much information as possible, provide screenshots and/or illustrations of the feature if possible*
- type: textarea
id: feature
attributes:
label: What would your feature do ?
description: Tell me about your feature in a very clear and simple way, and what problem it would solve
validations:
required: true
- type: textarea
id: workflow
attributes:
label: Proposed workflow
description: Please provide me with step by step information on how you'd like the feature to be accessed and used
value: |
1. Go to ....
2. Press ....
3. ...
validations:
required: true
- type: textarea
id: misc
attributes:
label: Additional information
description: Add any other context or screenshots about the feature request here.

View File

@@ -20,7 +20,7 @@ jobs:
max_downloads=0 max_downloads=0
top_node_json="{}" top_node_json="{}"
for i in {1..20}; do for i in {1..3}; do
echo "Pobieranie danych z próby $i..." echo "Pobieranie danych z próby $i..."
curl -s https://api.comfy.org/nodes/layerforge > tmp_$i.json curl -s https://api.comfy.org/nodes/layerforge > tmp_$i.json

View File

@@ -36,18 +36,18 @@ jobs:
echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT echo "final_tag=v${{ steps.version.outputs.base_version }}" >> $GITHUB_OUTPUT
# ZMIANA: Poprawione obsługa multi-line output (z delimiterem EOF, bez zastępowania \n) # ZMIANA: Poprawione obsługa multi-line output (z delimiterem EOF, bez zastępowania \n)
- name: Get commit history since last tag - name: Get commit history since last version tag
id: commit_history id: commit_history
run: | run: |
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") VERSION_TAG="v${{ steps.version.outputs.base_version }}"
git fetch --tags
if [ -z "$LAST_TAG" ]; then
RANGE="HEAD" if git rev-parse "$VERSION_TAG" >/dev/null 2>&1; then
RANGE="$VERSION_TAG..HEAD"
else else
RANGE="$LAST_TAG..HEAD" RANGE="HEAD"
fi fi
# Pobierz listę commitów i przefiltruj tylko te znaczące
HISTORY=$(git log --pretty=format:"%s" $RANGE | \ HISTORY=$(git log --pretty=format:"%s" $RANGE | \
grep -vE '^\s*(add|update|fix|change|edit|mod|modify|cleanup|misc|typo|readme|temp|test|debug)\b' | \ grep -vE '^\s*(add|update|fix|change|edit|mod|modify|cleanup|misc|typo|readme|temp|test|debug)\b' | \
grep -vE '^(\s*Update|Add|Fix|Change|Edit|Refactor|Bump|Minor|Misc|Readme|Test)[^a-zA-Z0-9]*$' | \ grep -vE '^(\s*Update|Add|Fix|Change|Edit|Refactor|Bump|Minor|Misc|Readme|Test)[^a-zA-Z0-9]*$' | \
@@ -65,13 +65,7 @@ jobs:
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
tag_name: ${{ steps.unique_tag.outputs.final_tag }} tag_name: ${{ steps.unique_tag.outputs.final_tag }}
name: Release ${{ steps.unique_tag.outputs.final_tag }} name: Release ${{ steps.unique_tag.outputs.final_tag }}
body: | generate_release_notes: true
📦 Release based on pyproject.toml version `${{ steps.version.outputs.base_version }}`
📝 Changes since last release:
```
${{ steps.commit_history.outputs.commit_history }}
```
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2024 tanglup Copyright (c) 2025 Azornes
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

143
README.md
View File

@@ -19,6 +19,15 @@
<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>
<p align="center">
<strong>🔹 <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#-installation">Quick Start</a></strong>
&nbsp; | &nbsp;
<strong>🧩 <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#-workflow-example">Workflow Example</a></strong>
&nbsp; | &nbsp;
<strong>⚠️ <a href="https://github.com/Azornes/Comfyui-LayerForge?tab=readme-ov-file#%EF%B8%8F-known-issues--compatibility">Known Issues</a></strong>
</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
@@ -28,10 +37,13 @@
--- ---
https://github.com/user-attachments/assets/0f557d87-fd5e-422b-ab7e-dbdd4cab156c https://github.com/user-attachments/assets/90fffb9a-dae2-4d19-aca2-5d47600f0a01
https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
## ✨ Key Features ## ✨ Key Features
- **Freeform Inpainting Area:** Draw any custom (like a polygonal lasso tool) area directly inside the image for inpainting. The tool generates content that is coherent with the rest of the image, without requiring a brush.
- **Persistent & Stateful:** Your work is automatically saved to the browser's IndexedDB, preserving your full canvas - **Persistent & Stateful:** Your work is automatically saved to the browser's IndexedDB, preserving your full canvas
state (layers, positions, etc.) even after a page reload. state (layers, positions, etc.) even after a page reload.
- **Multi-Layer Editing:** Add, arrange, and manage multiple image layers with z-ordering. - **Multi-Layer Editing:** Add, arrange, and manage multiple image layers with z-ordering.
@@ -48,19 +60,27 @@ https://github.com/user-attachments/assets/0f557d87-fd5e-422b-ab7e-dbdd4cab156c
- **AI-Powered Matting:** Optional background removal for any layer using the `BiRefNet` model. - **AI-Powered Matting:** Optional background removal for any layer using the `BiRefNet` model.
- **Efficient Memory Management:** An automatic garbage collection system cleans up unused image data to keep the - **Efficient Memory Management:** An automatic garbage collection system cleans up unused image data to keep the
browser's storage footprint low. browser's storage footprint low.
- **Workflow Integration:** Outputs a final composite **image** and a combined alpha **mask**, ready for any other - **Inputs**
ComfyUI node. - **Image Input (optional):** Accepts a single image.
- **Multiple Images:** If you need to feed in more than one image, use the **core ComfyUI Batch Image node**.
- This lets you route multiple images into LayerForge.
- You can then stack, arrange, and edit them as separate layers inside the canvas.
- **Mask Input (optional):** Accepts a single external mask.
- When provided, the mask is applied directly to the **output area** of the LayerForge canvas when `Run` is triggered in ComfyUI.
- **Outputs**
- **Composite Image:** The final flattened result of your layer stack.
- **Combined Alpha Mask:** A merged mask representing all visible layers, ready for downstream nodes.
--- ---
## 🚀 Installation ## 🚀 Installation
### Install via ComfyUI-Manager ### Install via ComfyUI-Manager
* Search `Comfyui-LayerForge` in ComfyUI-Manager and click `Install` button. 1. Search `Comfyui-LayerForge` in ComfyUI-Manager and click `Install` button.
2. Restart ComfyUI.
### Manual Install ### Manual Install
1. Install [ComfyUi](https://github.com/comfyanonymous/ComfyUI). 1. Install [ComfyUi](https://github.com/comfyanonymous/ComfyUI). I use [portable](https://docs.comfy.org/installation/comfyui_portable_windows) version.
2. Clone this repo into `custom_modules`: 2. Clone this repo into `custom_nodes`:
```bash ```bash
cd ComfyUI/custom_nodes/ cd ComfyUI/custom_nodes/
git clone https://github.com/Azornes/Comfyui-LayerForge.git git clone https://github.com/Azornes/Comfyui-LayerForge.git
@@ -68,19 +88,75 @@ https://github.com/user-attachments/assets/0f557d87-fd5e-422b-ab7e-dbdd4cab156c
3. Start up ComfyUI. 3. Start up ComfyUI.
--- ---
## 🎯 Polygonal Lasso Inpainting Workflow
LayerForge's newest feature allows you to draw custom polygonal selection areas and run inpainting directly within ComfyUI. This brings Photoshop-like lasso tool functionality to your AI workflows.
### Setup Requirements
1. **Enable Auto-Refresh:** In LayerForge's settings, enable `auto_refresh_after_generation`. Without this setting, the new generation output won't update automatically in the canvas.
2. **Configure Auto-Apply (Optional):** If you want the mask to be automatically applied after drawing the shape, enable the `auto-apply shape mask` option in the Custom Output Area menu (appears on the left when a custom shape is active).
### How to Use Polygonal Selection
1. **Start Drawing:** Hold `Shift + S` and left-click to place the first point of your polygonal selection.
2. **Add Points:** Continue left-clicking to place additional points. Each click adds a new vertex to your polygon.
3. **Close Selection:** Click back on the first point (or close to it) to complete and close the polygonal selection.
4. **Run Inpainting:** Once your selection is complete, run your inpainting workflow as usual. The generated content will seamlessly integrate with the existing image.
### Advanced Shape Mask Options
When using custom shapes, LayerForge provides several options to fine-tune the mask quality:
- **Mask Expansion/Contraction:** Adjust the mask boundary by -300 to +300 pixels to ensure better blending
- **Edge Feathering:** Apply 0-300px feathering to create smooth transitions and reduce visible seams
- **Output Area Extension:** Extend the output area in all directions for more context during generation
- **Manual Blend Menu:** Right-click to access manual color adjustment tools for perfect edge blending
### Tips for Best Results
* Use **feathering (1050px)** depending on the **size of the image** to create smooth transitions between the inpainted area and existing content. Larger images generally benefit from more feathering.
* Experiment with **mask expansion** (e.g., 1020px) if you notice hard edges or visible seams.
* Use **Output Area Extension** based on image size:
* **Extend the output area in all directions** to give the model more **context during generation**, especially for larger or more complex images.
* If **visible seams** still appear in the inpainting results:
* Use the **Manual Blend Menu** (right-click on the mask area) to access **color and edge adjustment tools** for precise fine-tuning and seamless integration.
* **Image placement behavior:**
* The generated or pasted image is automatically inserted into the area defined by the **blue shape** you draw.
* The model uses the area within the **dashed white preview outline** as the **full context** during generation.
* Make sure the dashed region covers enough surrounding content to preserve lighting, texture, and scene coherence.
---
## 🧪 Workflow Example ## 🧪 Workflow Example
For a quick test of **LayerForge**, you can try the example workflow provided below. It demonstrates a basic compositing setup using the node. For a quick test of **LayerForge**, you can try the example workflow provided below. It demonstrates a basic compositing setup using the node.
**🔗 Download Example Workflow** **🔗 Download Example Workflow**
![📥 LayerForge\_Example](https://github.com/user-attachments/assets/7572149a-bd5e-4f3b-8379-18bcc9ea3874)
### 🔹 Simple Test Workflow
This workflow allows **quick testing** of node behavior and output structures **without requiring additional models or complex dependencies**. Useful for inspecting how basic outputs are generated and connected.
![📥 LayerForge_Example1](example_workflows/LayerForge_test_simple_workflow.png)
### 🔹 Flux Inpainting Workflow
This example shows a typical **inpainting setup using the Flux model**. It demonstrates how to integrate model-based fill with contextual generation for seamless content restoration.
![📥 LayerForge_Example1](example_workflows/LayerForge_flux_fill_inpaint_example.png)
**How to load the workflow:** **How to load the workflow:**
Click on the image above, then drag and drop it into your ComfyUI workflow window in your browser. The workflow should load automatically. Click on the image above, then drag and drop it into your ComfyUI workflow window in your browser. The workflow should load automatically.
--- ---
## 🎮 Controls & Shortcuts ## 🎮 Controls & Shortcuts
### Canvas Control ### Canvas Control
@@ -91,6 +167,9 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
| `Mouse Wheel` | Zoom view in/out | | `Mouse Wheel` | Zoom view in/out |
| `Shift + Click (background)` | Start resizing canvas area | | `Shift + Click (background)` | Start resizing canvas area |
| `Shift + Ctrl + Click` | Start moving entire canvas | | `Shift + Ctrl + Click` | Start moving entire canvas |
| `Shift + S + Left Click` | Draw custom polygonal shape for output area |
| `Single Click (background)` | Deselect all layers |
| `Esc` | Close fullscreen editor mode |
| `Double Click (background)` | Deselect all layers | | `Double Click (background)` | Deselect all layers |
### Clipboard & I/O ### Clipboard & I/O
@@ -108,10 +187,11 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
| `Click + Drag` | Move selected layer(s) | | `Click + Drag` | Move selected layer(s) |
| `Ctrl + Click` | Add/Remove layer from selection | | `Ctrl + Click` | Add/Remove layer from selection |
| `Alt + Drag` | Clone selected layer(s) | | `Alt + Drag` | Clone selected layer(s) |
| `Shift + Click` | Show blend mode & opacity menu | | `Right Click` | Show blend mode & opacity menu |
| `Mouse Wheel` | Scale layer (snaps to grid) | | `Mouse Wheel` | Scale layer (snaps to grid) |
| `Ctrl + Mouse Wheel` | Fine-scale layer | | `Ctrl + Mouse Wheel` | Fine-scale layer |
| `Shift + Mouse Wheel` | Rotate layer by 5° | | `Shift + Mouse Wheel` | Rotate layer by 5° |
| `Shift + Ctrl + Mouse Wheel` | Snap rotation to 5° increments |
| `Arrow Keys` | Nudge layer by 1px | | `Arrow Keys` | Nudge layer by 1px |
| `Shift + Arrow Keys` | Nudge layer by 10px | | `Shift + Arrow Keys` | Nudge layer by 10px |
| `[` or `]` | Rotate by 1° | | `[` or `]` | Rotate by 1° |
@@ -138,6 +218,14 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
| **Clear Mask** | Remove the entire mask | | **Clear Mask** | Remove the entire mask |
| **Exit Mode** | Click the "Draw Mask" button again | | **Exit Mode** | Click the "Draw Mask" button again |
---
## 🤖 Model Compatibility
LayerForge is designed to work with **any ComfyUI-compatible model**. The node outputs standard image and mask data that can be used with any model or workflow. LayerForge automatically inserts the generated image into the exact shape and position you draw with the blue polygon tool — but only if the generated image is saved properly, for example via a Save Image node.
---
## 🧠 Optional: Matting Model (for image cutout) ## 🧠 Optional: Matting Model (for image cutout)
The "Matting" feature allows you to automatically generate a cutout (alpha mask) for a selected layer. This is an The "Matting" feature allows you to automatically generate a cutout (alpha mask) for a selected layer. This is an
@@ -152,17 +240,24 @@ optional feature and requires a model.
--- ---
## 🐞 Known Issue: ## ⚠️ Known Issues / Compatibility
### `node_id` not auto-filled → black output
In some cases, **ComfyUI doesn't auto-fill the `node_id`** when adding a node. #### ○ Incompatibility with Modern Node Design (Vue Nodes)
As a result, the node may produce a **completely black image** or not work at all. > This node is **not compatible** with the new Vue Nodes display system.
>
> 🔧 **How to fix:**
> Go to **Settings → (search) "Vue Nodes" → Disable "Modern Node Design (Vue Nodes)"**.
**Workaround:** ---
* Search node ID in ComfyUI settings. #### ○ `node_id` not auto-filled → black output
* In NodesMap check "Enable node ID display" > In some cases, **ComfyUI doesnt auto-fill the `node_id`** when adding a node.
* Manually enter the correct `node_id` (match the ID Node "LayerForge" shown above the node, on the right side). > This may cause the node to output a **completely black image** or fail to work.
>
> 🛠️ **Workaround:**
> - Open **Settings → NodesMap → Enable "Show node IDs"**
> - Find the correct ID for your node *(match the ID Node "LayerForge" shown above the node, on the right side)*.
> - Manually enter the correct `node_id` in the LayerForge node
> [!WARNING] > [!WARNING]
> This is a known issue and not yet fixed. > This is a known issue and not yet fixed.
@@ -176,5 +271,17 @@ This project is licensed under the MIT License. Feel free to use, modify, and di
--- ---
## 💖 Support / Sponsorship
• ⭐ Give a star — it means a lot to me!
• 🐛 Report a bug or suggest a feature
• 💖 If youd like to support my work:
👉 [GitHub Sponsors](https://github.com/sponsors/Azornes)
---
## 🙏 Acknowledgments
Based on the original [**Comfyui-Ycanvas**](https://github.com/yichengup/Comfyui-Ycanvas) by yichengup. This fork Based on the original [**Comfyui-Ycanvas**](https://github.com/yichengup/Comfyui-Ycanvas) by yichengup. This fork
significantly enhances the editing capabilities for practical compositing workflows inside ComfyUI. significantly enhances the editing capabilities for practical compositing workflows inside ComfyUI.
Special thanks to the ComfyUI community for feedback, bug reports, and feature suggestions that help make LayerForge better.

View File

@@ -4,16 +4,16 @@ import os
# Add the custom node's directory to the Python path # Add the custom node's directory to the Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from .canvas_node import CanvasNode from .canvas_node import LayerForgeNode
CanvasNode.setup_routes() LayerForgeNode.setup_routes()
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"CanvasNode": CanvasNode "LayerForgeNode": LayerForgeNode
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"CanvasNode": "Layer Forge (Editor, outpaintintg, Canvas Node)" "LayerForgeNode": "Layer Forge (Editor, outpaintintg, Canvas Node)"
} }
WEB_DIRECTORY = "./js" WEB_DIRECTORY = "./js"

View File

@@ -64,6 +64,8 @@ class BiRefNetConfig(PretrainedConfig):
def __init__(self, bb_pretrained=False, **kwargs): def __init__(self, bb_pretrained=False, **kwargs):
self.bb_pretrained = bb_pretrained self.bb_pretrained = bb_pretrained
# Add the missing is_encoder_decoder attribute for compatibility with newer transformers
self.is_encoder_decoder = False
super().__init__(**kwargs) super().__init__(**kwargs)
@@ -90,7 +92,7 @@ class BiRefNet(torch.nn.Module):
return [output] return [output]
class CanvasNode: class LayerForgeNode:
_canvas_data_storage = {} _canvas_data_storage = {}
_storage_lock = threading.Lock() _storage_lock = threading.Lock()
@@ -179,6 +181,10 @@ class CanvasNode:
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}), "trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1}),
"node_id": ("STRING", {"default": "0"}), "node_id": ("STRING", {"default": "0"}),
}, },
"optional": {
"input_image": ("IMAGE",),
"input_mask": ("MASK",),
},
"hidden": { "hidden": {
"prompt": ("PROMPT",), "prompt": ("PROMPT",),
"unique_id": ("UNIQUE_ID",), "unique_id": ("UNIQUE_ID",),
@@ -239,7 +245,7 @@ class CanvasNode:
_processing_lock = threading.Lock() _processing_lock = threading.Lock()
def process_canvas_image(self, fit_on_add, show_preview, auto_refresh_after_generation, trigger, node_id, prompt=None, unique_id=None): def process_canvas_image(self, fit_on_add, show_preview, auto_refresh_after_generation, trigger, node_id, input_image=None, input_mask=None, prompt=None, unique_id=None):
try: try:
@@ -250,6 +256,81 @@ class CanvasNode:
log_info(f"Lock acquired. Starting process_canvas_image for node_id: {node_id} (fallback unique_id: {unique_id})") log_info(f"Lock acquired. Starting process_canvas_image for node_id: {node_id} (fallback unique_id: {unique_id})")
# Always store fresh input data, even if None, to clear stale data
log_info(f"Storing input data for node {node_id} - Image: {input_image is not None}, Mask: {input_mask is not None}")
with self.__class__._storage_lock:
input_data = {}
if input_image is not None:
# Convert image tensor(s) to base64 - handle batch
if isinstance(input_image, torch.Tensor):
# Ensure correct shape [B, H, W, C]
if input_image.dim() == 3:
input_image = input_image.unsqueeze(0)
batch_size = input_image.shape[0]
log_info(f"Processing batch of {batch_size} image(s)")
if batch_size == 1:
# Single image - keep backward compatibility
img_np = (input_image.squeeze(0).cpu().numpy() * 255).astype(np.uint8)
pil_img = Image.fromarray(img_np, 'RGB')
# Convert to base64
buffered = io.BytesIO()
pil_img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
input_data['input_image'] = f"data:image/png;base64,{img_str}"
input_data['input_image_width'] = pil_img.width
input_data['input_image_height'] = pil_img.height
log_debug(f"Stored single input image: {pil_img.width}x{pil_img.height}")
else:
# Multiple images - store as array
images_array = []
for i in range(batch_size):
img_np = (input_image[i].cpu().numpy() * 255).astype(np.uint8)
pil_img = Image.fromarray(img_np, 'RGB')
# Convert to base64
buffered = io.BytesIO()
pil_img.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
images_array.append({
'data': f"data:image/png;base64,{img_str}",
'width': pil_img.width,
'height': pil_img.height
})
log_debug(f"Stored batch image {i+1}/{batch_size}: {pil_img.width}x{pil_img.height}")
input_data['input_images_batch'] = images_array
log_info(f"Stored batch of {batch_size} images")
if input_mask is not None:
# Convert mask tensor to base64
if isinstance(input_mask, torch.Tensor):
# Ensure correct shape
if input_mask.dim() == 2:
input_mask = input_mask.unsqueeze(0)
if input_mask.dim() == 3 and input_mask.shape[0] == 1:
input_mask = input_mask.squeeze(0)
# Convert to numpy and then to PIL
mask_np = (input_mask.cpu().numpy() * 255).astype(np.uint8)
pil_mask = Image.fromarray(mask_np, 'L')
# Convert to base64
mask_buffered = io.BytesIO()
pil_mask.save(mask_buffered, format="PNG")
mask_str = base64.b64encode(mask_buffered.getvalue()).decode()
input_data['input_mask'] = f"data:image/png;base64,{mask_str}"
log_debug(f"Stored input mask: {pil_mask.width}x{pil_mask.height}")
input_data['fit_on_add'] = fit_on_add
# Store in a special key for input data (overwrites any previous data)
self.__class__._canvas_data_storage[f"{node_id}_input"] = input_data
storage_key = node_id storage_key = node_id
processed_image = None processed_image = None
@@ -433,6 +514,63 @@ class CanvasNode:
log_info("WebSocket connection closed") log_info("WebSocket connection closed")
return ws return ws
@PromptServer.instance.routes.get("/layerforge/get_input_data/{node_id}")
async def get_input_data(request):
try:
node_id = request.match_info["node_id"]
log_debug(f"Checking for input data for node: {node_id}")
with cls._storage_lock:
input_key = f"{node_id}_input"
input_data = cls._canvas_data_storage.get(input_key, None)
if input_data:
log_info(f"Input data found for node {node_id}, sending to frontend")
return web.json_response({
'success': True,
'has_input': True,
'data': input_data
})
else:
log_debug(f"No input data found for node {node_id}")
return web.json_response({
'success': True,
'has_input': False
})
except Exception as e:
log_error(f"Error in get_input_data: {str(e)}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@PromptServer.instance.routes.post("/layerforge/clear_input_data/{node_id}")
async def clear_input_data(request):
try:
node_id = request.match_info["node_id"]
log_info(f"Clearing input data for node: {node_id}")
with cls._storage_lock:
input_key = f"{node_id}_input"
if input_key in cls._canvas_data_storage:
del cls._canvas_data_storage[input_key]
log_info(f"Input data cleared for node {node_id}")
else:
log_debug(f"No input data to clear for node {node_id}")
return web.json_response({
'success': True,
'message': f'Input data cleared for node {node_id}'
})
except Exception as e:
log_error(f"Error in clear_input_data: {str(e)}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@PromptServer.instance.routes.get("/ycnode/get_canvas_data/{node_id}") @PromptServer.instance.routes.get("/ycnode/get_canvas_data/{node_id}")
async def get_canvas_data(request): async def get_canvas_data(request):
try: try:
@@ -619,16 +757,32 @@ class BiRefNetMatting:
full_model_path = os.path.join(self.base_path, "BiRefNet") full_model_path = os.path.join(self.base_path, "BiRefNet")
log_info(f"Loading BiRefNet model from {full_model_path}...") log_info(f"Loading BiRefNet model from {full_model_path}...")
try: try:
# Try loading with additional configuration to handle compatibility issues
self.model = AutoModelForImageSegmentation.from_pretrained( self.model = AutoModelForImageSegmentation.from_pretrained(
"ZhengPeng7/BiRefNet", "ZhengPeng7/BiRefNet",
trust_remote_code=True, trust_remote_code=True,
cache_dir=full_model_path cache_dir=full_model_path,
# Add force_download=False to use cached version if available
force_download=False,
# Add local_files_only=False to allow downloading if needed
local_files_only=False
) )
self.model.eval() self.model.eval()
if torch.cuda.is_available(): if torch.cuda.is_available():
self.model = self.model.cuda() self.model = self.model.cuda()
self.model_cache[model_path] = self.model self.model_cache[model_path] = self.model
log_info("Model loaded successfully from Hugging Face") log_info("Model loaded successfully from Hugging Face")
except AttributeError as e:
if "'Config' object has no attribute 'is_encoder_decoder'" in str(e):
log_error("Compatibility issue detected with transformers library. This has been fixed in the code.")
log_error("If you're still seeing this error, please clear the model cache and try again.")
raise RuntimeError(
"Model configuration compatibility issue detected. "
f"Please delete the model cache directory '{full_model_path}' and restart ComfyUI. "
"This will download a fresh copy of the model with the updated configuration."
) from e
else:
raise e
except JSONDecodeError as e: except JSONDecodeError as e:
log_error(f"JSONDecodeError: Failed to load model from {full_model_path}. The model's config.json may be corrupted.") log_error(f"JSONDecodeError: Failed to load model from {full_model_path}. The model's config.json may be corrupted.")
raise RuntimeError( raise RuntimeError(
@@ -758,6 +912,95 @@ class BiRefNetMatting:
_matting_lock = None _matting_lock = None
@PromptServer.instance.routes.get("/matting/check-model")
async def check_matting_model(request):
"""Check if the matting model is available and ready to use"""
try:
if not TRANSFORMERS_AVAILABLE:
return web.json_response({
"available": False,
"reason": "missing_dependency",
"message": "The 'transformers' library is required for the matting feature. Please install it by running: pip install transformers"
})
# Check if model exists in cache
base_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "models")
model_path = os.path.join(base_path, "BiRefNet")
# Look for the actual BiRefNet model structure
model_files_exist = False
if os.path.exists(model_path):
# BiRefNet model from Hugging Face has a specific structure
# Check for subdirectories that indicate the model is downloaded
existing_items = os.listdir(model_path) if os.path.isdir(model_path) else []
# Look for the model subdirectory (usually named with the model ID)
model_subdirs = [d for d in existing_items if os.path.isdir(os.path.join(model_path, d)) and
(d.startswith("models--") or d == "ZhengPeng7--BiRefNet")]
if model_subdirs:
# Found model subdirectory, check inside for actual model files
for subdir in model_subdirs:
subdir_path = os.path.join(model_path, subdir)
# Navigate through the cache structure
if os.path.exists(os.path.join(subdir_path, "snapshots")):
snapshots_path = os.path.join(subdir_path, "snapshots")
snapshot_dirs = os.listdir(snapshots_path) if os.path.isdir(snapshots_path) else []
for snapshot in snapshot_dirs:
snapshot_path = os.path.join(snapshots_path, snapshot)
snapshot_files = os.listdir(snapshot_path) if os.path.isdir(snapshot_path) else []
# Check for essential files - BiRefNet uses model.safetensors
has_config = "config.json" in snapshot_files
has_model = "model.safetensors" in snapshot_files or "pytorch_model.bin" in snapshot_files
has_backbone = "backbone_swin.pth" in snapshot_files or "swin_base_patch4_window12_384_22kto1k.pth" in snapshot_files
has_birefnet = "birefnet.pth" in snapshot_files or any(f.endswith(".pth") for f in snapshot_files)
# Model is valid if it has config and either model.safetensors or other model files
if has_config and (has_model or has_backbone or has_birefnet):
model_files_exist = True
log_info(f"Found model files in: {snapshot_path} (config: {has_config}, model: {has_model})")
break
if model_files_exist:
break
# Also check if there are .pth files directly in the model_path
if not model_files_exist:
direct_files = existing_items
has_config = "config.json" in direct_files
has_model_files = any(f.endswith((".pth", ".bin", ".safetensors")) for f in direct_files)
model_files_exist = has_config and has_model_files
if model_files_exist:
log_info(f"Found model files directly in: {model_path}")
if model_files_exist:
# Model files exist, assume it's ready
log_info("BiRefNet model files detected")
return web.json_response({
"available": True,
"reason": "ready",
"message": "Model is ready to use"
})
else:
log_info(f"BiRefNet model not found in {model_path}")
return web.json_response({
"available": False,
"reason": "not_downloaded",
"message": "The matting model needs to be downloaded. This will happen automatically when you first use the matting feature (requires internet connection).",
"model_path": model_path
})
except Exception as e:
log_error(f"Error checking matting model: {str(e)}")
return web.json_response({
"available": False,
"reason": "error",
"message": f"Error checking model status: {str(e)}"
}, status=500)
@PromptServer.instance.routes.post("/matting") @PromptServer.instance.routes.post("/matting")
async def matting(request): async def matting(request):
global _matting_lock global _matting_lock
@@ -911,13 +1154,3 @@ def convert_tensor_to_base64(tensor, alpha_mask=None, original_alpha=None):
log_error(f"Error in convert_tensor_to_base64: {str(e)}") log_error(f"Error in convert_tensor_to_base64: {str(e)}")
log_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}") log_debug(f"Tensor shape: {tensor.shape}, dtype: {tensor.dtype}")
raise raise
CanvasNode.setup_routes()
NODE_CLASS_MAPPINGS = {
"CanvasNode": CanvasNode
}
NODE_DISPLAY_NAME_MAPPINGS = {
"CanvasNode": "LayerForge"
}

542
css_test.html Normal file
View File

@@ -0,0 +1,542 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas View CSS Test - All Button Styles</title>
<link rel="stylesheet" href="src/css/canvas_view.css">
<style>
body {
font-family: Arial, sans-serif;
background: #2a2a2a;
color: #ffffff;
margin: 20px;
line-height: 1.6;
}
.test-section {
margin: 30px 0;
padding: 20px;
background: #353535;
border-radius: 8px;
border: 1px solid #444;
}
.test-section h2 {
color: #4a90e2;
margin-top: 0;
border-bottom: 1px solid #555;
padding-bottom: 10px;
}
.test-section h3 {
color: #ffffff;
margin: 20px 0 10px 0;
}
.demo-row {
margin: 15px 0;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.demo-label {
min-width: 150px;
color: #cccccc;
font-size: 14px;
}
.test-controls {
margin: 20px 0;
}
.test-controls button {
margin: 5px;
padding: 8px 16px;
background: #4a6cd4;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.test-controls button:hover {
background: #5a7ce4;
}
/* Custom Slider Styles to match ComfyUI aesthetic */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 80px; /* Default width from painter-slider-container */
height: 7px;
background: #222;
border-radius: 5px;
outline: none;
margin: 0;
padding: 0;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: #cccccc;
border-radius: 50%;
border: 2px solid #555;
cursor: pointer;
margin-top: -4.5px; /* Center thumb on track */
transition: background 0.2s;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
background: #cccccc;
border-radius: 50%;
border: 2px solid #555;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #e0e0e0;
}
input[type="range"]::-moz-range-thumb:hover {
background: #e0e0e0;
}
input[type="range"]::-webkit-slider-runnable-track {
width: 100%;
height: 7px;
background: #222;
border-radius: 5px;
}
input[type="range"]::-moz-range-track {
width: 100%;
height: 7px;
background: #222;
border-radius: 5px;
}
</style>
</head>
<body>
<h1>Canvas View CSS Test Page</h1>
<p>This page demonstrates all button styles from <code>src/css/canvas_view.css</code></p>
<div class="test-controls">
<button onclick="toggleLoadingState()">Toggle Loading State</button>
<button onclick="toggleDisabledState()">Toggle Disabled State</button>
<button onclick="toggleClipboardSwitch()">Toggle Clipboard Switch</button>
</div>
<!-- CanvasView Full Control Panel -->
<div class="test-section">
<h2>CanvasView Control Panel Layout</h2>
<div class="painter-controls" style="position: relative; flex-wrap: wrap; padding: 10px; background: #383838;">
<!-- Group 1: Main Controls -->
<div class="painter-button-group">
<button class="painter-button icon-button" title="Open in Editor"></button>
<button class="painter-button icon-button" title="Show shortcuts">?</button>
<button class="painter-button primary">Add Image</button>
<button class="painter-button primary">Import Input</button>
<div class="painter-clipboard-group">
<button class="painter-button primary">Paste Image</button>
<label class="clipboard-switch" id="test-clipboard-switch">
<input type="checkbox">
<span class="switch-track"></span>
<span class="switch-labels">
<span class="text-clipspace">Clipspace</span>
<span class="text-system">System</span>
</span>
<span class="switch-knob">
<span class="switch-icon">
<img src="" alt="clipboard icon" style="width: 20px; height: 20px;">
</span>
</span>
</label>
</div>
</div>
<div class="painter-separator"></div>
<!-- Group 2: Layer Management -->
<div class="painter-button-group">
<button class="painter-button">Output Area Size</button>
<button class="painter-button requires-selection">Remove Layer</button>
<button class="painter-button requires-selection">Layer Up</button>
<button class="painter-button requires-selection">Layer Down</button>
<button class="painter-button requires-selection">Fuse</button>
</div>
<div class="painter-separator"></div>
<!-- Group 3: Transformations -->
<div class="painter-button-group">
<button class="painter-button requires-selection">Rotate +90°</button>
<button class="painter-button requires-selection">Scale +5%</button>
<button class="painter-button requires-selection">Scale -5%</button>
<button class="painter-button requires-selection">Mirror H</button>
<button class="painter-button requires-selection">Mirror V</button>
</div>
<div class="painter-separator"></div>
<!-- Group 4: Tools -->
<div class="painter-button-group">
<button class="painter-button matting-button requires-selection">Matting<div class="matting-spinner"></div></button>
<button class="painter-button">Undo</button>
<button class="painter-button">Redo</button>
</div>
<div class="painter-separator"></div>
<!-- Group 5: Masking -->
<div class="painter-button-group" id="test-mask-controls">
<label class="clipboard-switch mask-switch" title="Toggle mask overlay visibility" style="min-width: 56px; max-width: 56px; width: 56px;">
<input type="checkbox" checked="">
<span class="switch-track"></span>
<span class="switch-labels" style="font-size: 11px;">
<span class="text-clipspace" style="padding-right: 22px;">On</span>
<span class="text-system" style="padding-left: 20px;">Off</span>
</span>
<span class="switch-knob">
<span class="switch-icon" style="display: flex; align-items: center; justify-content: center; width: 16px; height: 16px;">
<!-- Icon would be loaded here by JS -->
</span>
</span>
</label>
<button class="painter-button">Edit Mask</button>
<button class="painter-button" id="test-mask-mode-btn" onclick="toggleTestMaskControls()">Draw Mask</button>
<div class="painter-slider-container mask-control" style="display: none;">
<label>Size:</label>
<input type="range" min="1" max="200" value="20">
<div class="slider-value">20px</div>
</div>
<div class="painter-slider-container mask-control" style="display: none;">
<label>Strength:</label>
<input type="range" min="0" max="1" step="0.05" value="0.5">
<div class="slider-value">50%</div>
</div>
<div class="painter-slider-container mask-control" style="display: none;">
<label>Hardness:</label>
<input type="range" min="0" max="1" step="0.05" value="0.5">
<div class="slider-value">50%</div>
</div>
<button class="painter-button mask-control" style="display: none;">Clear Mask</button>
</div>
<div class="painter-separator"></div>
<!-- Group 6: Dev/Debug -->
<div class="painter-button-group">
<button class="painter-button success">Run GC</button>
<button class="painter-button danger">Clear Cache</button>
</div>
</div>
</div>
<!-- Basic Buttons -->
<div class="test-section">
<h2>Basic Painter Buttons</h2>
<div class="demo-row">
<span class="demo-label">Normal Button:</span>
<button class="painter-button">Normal Button</button>
<button class="painter-button">Another Button</button>
<button class="painter-button">Third Button</button>
</div>
<div class="demo-row">
<span class="demo-label">Primary Button:</span>
<button class="painter-button primary">Primary Button</button>
<button class="painter-button primary">Save</button>
<button class="painter-button primary">Apply</button>
</div>
<div class="demo-row">
<span class="demo-label">Disabled Buttons:</span>
<button class="painter-button" disabled>Disabled Normal</button>
<button class="painter-button primary" disabled>Disabled Primary</button>
</div>
<div class="demo-row">
<span class="demo-label">Matting Buttons:</span>
<button class="painter-button matting-button" id="mattingBtn1">
Matting Tool
<div class="matting-spinner"></div>
</button>
<button class="painter-button matting-button" id="mattingBtn2">
Process Image
<div class="matting-spinner"></div>
</button>
</div>
</div>
<!-- Button Groups -->
<div class="test-section">
<h2>Button Groups and Controls</h2>
<h3>Painter Controls Container</h3>
<div class="painter-controls">
<button class="painter-button">Tool 1</button>
<button class="painter-button">Tool 2</button>
<button class="painter-button primary">Active Tool</button>
<div class="painter-separator"></div>
<button class="painter-button">Option A</button>
<button class="painter-button">Option B</button>
</div>
<h3>Button Group</h3>
<div class="painter-button-group">
<button class="painter-button">Group 1</button>
<button class="painter-button">Group 2</button>
<button class="painter-button primary">Group 3</button>
</div>
<h3>Clipboard Group</h3>
<div class="painter-clipboard-group">
<button class="painter-button">Copy</button>
<button class="painter-button">Paste</button>
<button class="painter-button primary">Clear</button>
</div>
</div>
<!-- Clipboard Switch -->
<div class="test-section">
<h2>Clipboard Switch</h2>
<div class="demo-row">
<span class="demo-label">Clipboard Switch:</span>
<label class="clipboard-switch" id="clipboardSwitch">
<input type="checkbox">
<span class="switch-track"></span>
<span class="switch-knob">
<span class="switch-icon">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTkgMTZIMTVWMTBIOVYxNloiIGZpbGw9IiM0YTkwZTIiLz4KPHBhdGggZD0iTTcgMTZIMTdWMTBIN1YxNloiIHN0cm9rZT0iIzRhOTBlMiIgc3Ryb2tlLXdpZHRoPSIyIiBmaWxsPSJub25lIi8+Cjwvc3ZnPgo=" alt="clipboard">
</span>
</span>
<span class="switch-labels">
<span class="text-system">System</span>
<span class="text-clipspace">Clipspace</span>
</span>
</label>
</div>
</div>
<!-- Sliders -->
<div class="test-section">
<h2>Sliders and Controls</h2>
<div class="painter-controls">
<div class="painter-slider-container">
<label>Opacity:</label>
<input type="range" min="0" max="100" value="50">
<span>50%</span>
</div>
<div class="painter-separator"></div>
<div class="painter-slider-container">
<label>Size:</label>
<input type="range" min="1" max="100" value="25">
<span>25px</span>
</div>
</div>
</div>
<!-- Container Examples -->
<div class="test-section">
<h2>Container Examples</h2>
<h3>Normal Container</h3>
<div class="painter-container" style="padding: 20px; margin: 10px 0;">
<p>This is a normal painter container</p>
<button class="painter-button">Button inside container</button>
</div>
<h3>Container with Focus</h3>
<div class="painter-container has-focus" style="padding: 20px; margin: 10px 0;">
<p>This container has focus (white border)</p>
<button class="painter-button primary">Focused container button</button>
</div>
<h3>Drag Over Container</h3>
<div class="painter-container drag-over" style="padding: 20px; margin: 10px 0;">
<p>This container is in drag-over state (green dashed border)</p>
<button class="painter-button">Drag target button</button>
</div>
</div>
<!-- Modal Example -->
<div class="test-section">
<h2>Modal and Dialog</h2>
<button onclick="showModal()" class="painter-button primary">Show Modal Example</button>
<h3>Dialog Example</h3>
<div class="painter-dialog" style="display: inline-block; margin: 10px 0;">
<h4>Sample Dialog</h4>
<p>Enter values:</p>
<input type="text" placeholder="Width" value="100">
<input type="text" placeholder="Height" value="100">
<br>
<button>OK</button>
<button>Cancel</button>
</div>
</div>
<!-- Hidden Modal -->
<div class="painter-modal-backdrop" id="testModal" style="display: none;">
<div class="painter-modal-content">
<div class="painterMainContainer">
<div class="painter-controls">
<button class="painter-button" onclick="hideModal()">Close Modal</button>
<button class="painter-button primary">Save</button>
<div class="painter-separator"></div>
<button class="painter-button">Option 1</button>
<button class="painter-button">Option 2</button>
</div>
<div class="painterCanvasContainer" style="padding: 20px; display: flex; align-items: center; justify-content: center;">
<div>
<h3>Modal Content Area</h3>
<p>This is the main content area of the modal.</p>
<div class="painter-button-group">
<button class="painter-button">Action 1</button>
<button class="painter-button">Action 2</button>
<button class="painter-button primary">Primary Action</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- This is where the tooltip will be rendered -->
<div class="painter-tooltip" id="live-tooltip" style="display: none;"></div>
<script>
// Toggle loading state for matting buttons
function toggleLoadingState() {
const mattingBtns = document.querySelectorAll('.matting-button');
mattingBtns.forEach(btn => {
btn.classList.toggle('loading');
});
}
// Toggle disabled state for all buttons
function toggleDisabledState() {
const buttons = document.querySelectorAll('.painter-button:not(.matting-button)');
buttons.forEach(btn => {
btn.disabled = !btn.disabled;
});
}
// Toggle clipboard switch
function toggleClipboardSwitch() {
const checkbox = document.querySelector('#clipboardSwitch input[type="checkbox"]');
checkbox.checked = !checkbox.checked;
}
// Show modal
function showModal() {
document.getElementById('testModal').style.display = 'flex';
}
// Hide modal
function hideModal() {
document.getElementById('testModal').style.display = 'none';
}
// --- Live Tooltip Logic ---
const tooltipButton = document.querySelector('button[title="Show shortcuts"]');
const tooltipContainer = document.getElementById('live-tooltip');
let shortcutsHtml = '';
// Pre-fetch the shortcuts content
fetch('src/templates/standard_shortcuts.html')
.then(response => response.text())
.then(html => {
shortcutsHtml = html;
})
.catch(error => {
console.error('Error fetching shortcuts:', error);
shortcutsHtml = '<p>Error loading shortcuts.</p>';
});
tooltipButton.addEventListener('mouseenter', (e) => {
if (!shortcutsHtml || !tooltipContainer) return;
tooltipContainer.innerHTML = shortcutsHtml;
tooltipContainer.style.visibility = 'hidden';
tooltipContainer.style.display = 'block';
const buttonRect = e.target.getBoundingClientRect();
const tooltipRect = tooltipContainer.getBoundingClientRect();
let left = buttonRect.left;
let top = buttonRect.bottom + 5;
// Adjust position to stay within viewport
if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - 10;
}
if (top + tooltipRect.height > window.innerHeight) {
top = buttonRect.top - tooltipRect.height - 5;
}
if (left < 10) left = 10;
if (top < 10) top = 10;
tooltipContainer.style.left = `${left}px`;
tooltipContainer.style.top = `${top}px`;
tooltipContainer.style.visibility = 'visible';
});
tooltipButton.addEventListener('mouseleave', () => {
if (tooltipContainer) {
tooltipContainer.style.display = 'none';
}
});
// Close modal when clicking backdrop
document.getElementById('testModal').addEventListener('click', function(e) {
if (e.target === this) {
hideModal();
}
});
// --- Icon Loading and Interactive Logic ---
const icons = {
system: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>')}`,
clipspace: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 7H7c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2zm0 12H7V9h10v10z"/><path d="M19 3H9c-1.1 0-2 .9-2 2v2h2V5h10v10h-2v2h2c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/></svg>')}`
};
function updateClipboardIcon(switchElement) {
const isChecked = switchElement.querySelector('input').checked;
const iconImg = switchElement.querySelector('.switch-icon img');
iconImg.src = isChecked ? icons.clipspace : icons.system;
}
document.addEventListener('DOMContentLoaded', () => {
const clipboardSwitch = document.getElementById('test-clipboard-switch');
if (clipboardSwitch) {
updateClipboardIcon(clipboardSwitch);
clipboardSwitch.querySelector('input').addEventListener('change', () => {
updateClipboardIcon(clipboardSwitch);
});
}
});
// Function to toggle mask controls in the test layout
function toggleTestMaskControls() {
const maskBtn = document.getElementById('test-mask-mode-btn');
const maskControlsContainer = document.getElementById('test-mask-controls');
const controls = maskControlsContainer.querySelectorAll('.mask-control');
const isActive = maskBtn.classList.toggle('primary');
controls.forEach(control => {
control.style.display = isActive ? 'flex' : 'none';
});
}
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -0,0 +1,843 @@
{
"id": "d26732fd-91ea-4503-8d0d-383544823cec",
"revision": 0,
"last_node_id": 52,
"last_link_id": 114,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
307,
282
],
"size": [
425.2799987792969,
180.61000061035156
],
"flags": {
"collapsed": true
},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 63
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
81
]
}
],
"title": "CLIP Text Encode (Negative Prompt)",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.44",
"Node name for S&R": "CLIPTextEncode",
"widget_ue_connectable": {}
},
"widgets_values": [
""
],
"color": "#322",
"bgcolor": "#533"
},
{
"id": 26,
"type": "FluxGuidance",
"pos": [
593,
44
],
"size": [
317.3999938964844,
58
],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{
"name": "conditioning",
"type": "CONDITIONING",
"link": 41
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
80
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.44",
"Node name for S&R": "FluxGuidance",
"widget_ue_connectable": {}
},
"widgets_values": [
30
]
},
{
"id": 39,
"type": "DifferentialDiffusion",
"pos": [
1001,
-68
],
"size": [
277.20001220703125,
26
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 85
}
],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
86
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.44",
"Node name for S&R": "DifferentialDiffusion",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 45,
"type": "MarkdownNote",
"pos": [
-225,
255
],
"size": [
225,
88
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"widget_ue_connectable": {}
},
"widgets_values": [
"🛈 [Learn more about this workflow](https://comfyanonymous.github.io/ComfyUI_examples/flux/#fill-inpainting-model)"
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 34,
"type": "DualCLIPLoader",
"pos": [
-237,
79
],
"size": [
315,
130
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "CLIP",
"type": "CLIP",
"links": [
62,
63
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.44",
"Node name for S&R": "DualCLIPLoader",
"models": [
{
"name": "clip_l.safetensors",
"url": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/clip_l.safetensors?download=true",
"directory": "text_encoders"
},
{
"name": "t5xxl_fp16.safetensors",
"url": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp16.safetensors?download=true",
"directory": "text_encoders"
}
],
"widget_ue_connectable": {}
},
"widgets_values": [
"SDV3\\clip_l.safetensors",
"FLUX\\t5xxl_fp16.safetensors",
"flux",
"default"
]
},
{
"id": 31,
"type": "UNETLoader",
"pos": [
602,
-120
],
"size": [
315,
82
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [
85
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.44",
"Node name for S&R": "UNETLoader",
"models": [
{
"name": "flux1-fill-dev.safetensors",
"url": "https://huggingface.co/black-forest-labs/FLUX.1-Fill-dev/blob/main/flux1-fill-dev.safetensors",
"directory": "diffusion_models"
}
],
"widget_ue_connectable": {}
},
"widgets_values": [
"FLUX\\flux1-fill-dev.safetensors",
"default"
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1685.0001220703125,
69.49121856689453
],
"size": [
210,
46
],
"flags": {},
"order": 14,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 112
},
{
"name": "vae",
"type": "VAE",
"link": 60
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [
95
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.44",
"Node name for S&R": "VAEDecode",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 49,
"type": "CR Latent Batch Size",
"pos": [
959.2691040039062,
354.2141418457031
],
"size": [
270,
58
],
"flags": {},
"order": 12,
"mode": 0,
"inputs": [
{
"name": "latent",
"type": "LATENT",
"link": 110
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
111
]
}
],
"properties": {
"cnr_id": "ComfyUI_Comfyroll_CustomNodes",
"ver": "d78b780ae43fcf8c6b7c6505e6ffb4584281ceca",
"Node name for S&R": "CR Latent Batch Size",
"widget_ue_connectable": {}
},
"widgets_values": [
2
]
},
{
"id": 38,
"type": "InpaintModelConditioning",
"pos": [
916.876953125,
124.41231536865234
],
"size": [
302.3999938964844,
138
],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": 80
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 81
},
{
"name": "vae",
"type": "VAE",
"link": 82
},
{
"name": "pixels",
"type": "IMAGE",
"link": 113
},
{
"name": "mask",
"type": "MASK",
"link": 114
}
],
"outputs": [
{
"name": "positive",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
77
]
},
{
"name": "negative",
"type": "CONDITIONING",
"slot_index": 1,
"links": [
78
]
},
{
"name": "latent",
"type": "LATENT",
"slot_index": 2,
"links": [
110
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.44",
"Node name for S&R": "InpaintModelConditioning",
"widget_ue_connectable": {}
},
"widgets_values": [
false
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
1280,
100
],
"size": [
315,
262
],
"flags": {},
"order": 13,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 86
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 77
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 78
},
{
"name": "latent_image",
"type": "LATENT",
"link": 111
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [
112
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.44",
"Node name for S&R": "KSampler",
"widget_ue_connectable": {}
},
"widgets_values": [
1006953529460557,
"randomize",
20,
1,
"euler",
"normal",
1
]
},
{
"id": 32,
"type": "VAELoader",
"pos": [
1329.6622314453125,
479.7701416015625
],
"size": [
315,
58
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VAE",
"type": "VAE",
"slot_index": 0,
"links": [
60,
82
]
}
],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.44",
"Node name for S&R": "VAELoader",
"models": [
{
"name": "ae.safetensors",
"url": "https://huggingface.co/black-forest-labs/FLUX.1-schnell/resolve/main/ae.safetensors?download=true",
"directory": "vae"
}
],
"widget_ue_connectable": {}
},
"widgets_values": [
"FLUX\\ae.safetensors"
]
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1936.2982177734375,
82.75439453125
],
"size": [
828.9500122070312,
893.8499755859375
],
"flags": {},
"order": 15,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 95
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.44",
"Node name for S&R": "SaveImage",
"widget_ue_connectable": {}
},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 23,
"type": "CLIPTextEncode",
"pos": [
-905.195556640625,
924.5140991210938
],
"size": [
311.0955810546875,
108.43277740478516
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 62
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [
41
]
}
],
"title": "CLIP Text Encode (Positive Prompt)",
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.44",
"Node name for S&R": "CLIPTextEncode",
"widget_ue_connectable": {}
},
"widgets_values": [
"grass"
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 51,
"type": "Note",
"pos": [
-916.8970947265625,
476.72564697265625
],
"size": [
350.92510986328125,
250.50831604003906
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"How to Use Polygonal Selection\n- Start Drawing: Hold Shift + S and left-click to place the first point of your polygonal selection.\n\n- Add Points: Continue left-clicking to place additional points. Each click adds a new vertex to your polygon.\n\n- Close Selection: Click back on the first point (or close to it) to complete and close the polygonal selection.\n\n- Run Inpainting: Once your selection is complete, run your inpainting workflow as usual. The generated content will seamlessly integrate with the existing image."
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 52,
"type": "Note",
"pos": [
-911.10205078125,
769.1378173828125
],
"size": [
350.28143310546875,
99.23915100097656
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": [
"Add a description at the bottom to tell the model what to generate."
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 50,
"type": "LayerForgeNode",
"pos": [
-553.9073486328125,
478.2644348144531
],
"size": [
1879.927490234375,
1259.4072265625
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
113
]
},
{
"name": "mask",
"type": "MASK",
"links": [
114
]
}
],
"properties": {
"cnr_id": "layerforge",
"ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
"Node name for S&R": "LayerForgeNode",
"widget_ue_connectable": {}
},
"widgets_values": [
false,
false,
true,
18,
"50",
""
]
}
],
"links": [
[
41,
23,
0,
26,
0,
"CONDITIONING"
],
[
60,
32,
0,
8,
1,
"VAE"
],
[
62,
34,
0,
23,
0,
"CLIP"
],
[
63,
34,
0,
7,
0,
"CLIP"
],
[
77,
38,
0,
3,
1,
"CONDITIONING"
],
[
78,
38,
1,
3,
2,
"CONDITIONING"
],
[
80,
26,
0,
38,
0,
"CONDITIONING"
],
[
81,
7,
0,
38,
1,
"CONDITIONING"
],
[
82,
32,
0,
38,
2,
"VAE"
],
[
85,
31,
0,
39,
0,
"MODEL"
],
[
86,
39,
0,
3,
0,
"MODEL"
],
[
95,
8,
0,
9,
0,
"IMAGE"
],
[
110,
38,
2,
49,
0,
"LATENT"
],
[
111,
49,
0,
3,
3,
"LATENT"
],
[
112,
3,
0,
8,
0,
"LATENT"
],
[
113,
50,
0,
38,
3,
"IMAGE"
],
[
114,
50,
1,
38,
4,
"MASK"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 0.6588450000000008,
"offset": [
1117.7398801488407,
-110.40634975151642
]
},
"ue_links": [],
"links_added_by_ue": [],
"frontendVersion": "1.23.4",
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -1,75 +1,211 @@
{ {
"id": "c7ba7096-c52c-4978-8843-e87ce219b6a8", "id": "c7ba7096-c52c-4978-8843-e87ce219b6a8",
"revision": 0, "revision": 0,
"last_node_id": 705, "last_node_id": 710,
"last_link_id": 1497, "last_link_id": 1505,
"nodes": [ "nodes": [
{ {
"id": 368, "id": 708,
"type": "Mask To Image (mtb)", "type": "LayerForgeNode",
"pos": [ "pos": [
-1913.9735107421875, -3077.55615234375,
-3351.5126953125 -3358.0537109375
], ],
"size": [ "size": [
210, 1150,
130 1000
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "image",
"type": "IMAGE",
"links": [
1500
]
},
{
"name": "mask",
"type": "MASK",
"links": [
1501
]
}
],
"properties": {
"cnr_id": "layerforge",
"ver": "1bd261bee0c96c03cfac992ccabdea9544616a57",
"widget_ue_connectable": {},
"Node name for S&R": "LayerForgeNode"
},
"widgets_values": [
false,
false,
false,
11,
"708",
""
]
},
{
"id": 709,
"type": "Reroute",
"pos": [
-1920.4510498046875,
-3559.688232421875
],
"size": [
75,
26
],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 1500
}
],
"outputs": [
{
"name": "",
"type": "IMAGE",
"links": [
1502,
1503
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 710,
"type": "Reroute",
"pos": [
-1917.6273193359375,
-3524.312744140625
],
"size": [
75,
26
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "",
"type": "*",
"link": 1501
}
],
"outputs": [
{
"name": "",
"type": "MASK",
"links": [
1504,
1505
]
}
],
"properties": {
"showOutputText": false,
"horizontal": false
}
},
{
"id": 369,
"type": "PreviewImage",
"pos": [
-1914.3177490234375,
-2807.92724609375
],
"size": [
710,
450
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 1499
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.3.34",
"Node name for S&R": "PreviewImage",
"widget_ue_connectable": {}
},
"widgets_values": []
},
{
"id": 606,
"type": "PreviewImage",
"pos": [
-1913.4202880859375,
-3428.773193359375
],
"size": [
700,
510
], ],
"flags": {}, "flags": {},
"order": 3, "order": 3,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
"name": "mask", "name": "images",
"type": "MASK",
"link": 1496
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE", "type": "IMAGE",
"links": [ "link": 1503
612
]
} }
], ],
"outputs": [],
"properties": { "properties": {
"cnr_id": "comfy-mtb", "cnr_id": "comfy-core",
"ver": "7e36007933f42c29cca270ae55e0e6866e323633", "ver": "0.3.34",
"Node name for S&R": "Mask To Image (mtb)", "Node name for S&R": "PreviewImage",
"widget_ue_connectable": {} "widget_ue_connectable": {}
}, },
"widgets_values": [ "widgets_values": []
"#ff0000",
"#000000",
false
]
}, },
{ {
"id": 442, "id": 442,
"type": "JoinImageWithAlpha", "type": "JoinImageWithAlpha",
"pos": [ "pos": [
-1907.2977294921875, -1190.1787109375,
-3180.562744140625 -3237.75732421875
], ],
"size": [ "size": [
176.86483764648438, 176.86483764648438,
46 46
], ],
"flags": {}, "flags": {},
"order": 4, "order": 5,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
"name": "image", "name": "image",
"type": "IMAGE", "type": "IMAGE",
"link": 1494 "link": 1502
}, },
{ {
"name": "alpha", "name": "alpha",
"type": "MASK", "type": "MASK",
"link": 1497 "link": 1505
} }
], ],
"outputs": [ "outputs": [
@@ -90,79 +226,19 @@
}, },
"widgets_values": [] "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, "id": 603,
"type": "PreviewImage", "type": "PreviewImage",
"pos": [ "pos": [
-1344.1650390625, -1188.5968017578125,
-2915.117919921875 -3143.6875
], ],
"size": [ "size": [
601.4136962890625, 640,
527.1531372070312 510
], ],
"flags": {}, "flags": {},
"order": 6, "order": 7,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
@@ -184,15 +260,15 @@
"id": 680, "id": 680,
"type": "SaveImage", "type": "SaveImage",
"pos": [ "pos": [
-1025.9984130859375, -536.2315673828125,
-3357.975341796875 -3135.49755859375
], ],
"size": [ "size": [
278.8309020996094, 279.97137451171875,
395.84002685546875 282
], ],
"flags": {}, "flags": {},
"order": 7, "order": 8,
"mode": 0, "mode": 0,
"inputs": [ "inputs": [
{ {
@@ -213,87 +289,45 @@
] ]
}, },
{ {
"id": 701, "id": 706,
"type": "MarkdownNote", "type": "MaskToImage",
"pos": [ "pos": [
-3330.08984375, -1911.38525390625,
-3347.998291015625 -2875.74658203125
], ],
"size": [ "size": [
347.055419921875, 184.62362670898438,
217.8630828857422 26
], ],
"flags": {}, "flags": {},
"order": 0, "order": 4,
"mode": 0, "mode": 0,
"inputs": [], "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", "name": "mask",
"type": "MASK", "type": "MASK",
"link": 1504
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [ "links": [
1496, 1499
1497
] ]
} }
], ],
"properties": { "properties": {
"cnr_id": "Comfyui-Ycanvas", "cnr_id": "comfy-core",
"ver": "f6a491e83bab9481a2cac3367541a3b7803df9ab", "ver": "0.3.44",
"Node name for S&R": "CanvasNode", "Node name for S&R": "MaskToImage",
"widget_ue_connectable": {} "widget_ue_connectable": {}
}, },
"widgets_values": [ "widgets_values": []
true,
17,
"697",
""
]
} }
], ],
"links": [ "links": [
[
612,
368,
0,
369,
0,
"IMAGE"
],
[ [
1236, 1236,
442, 442,
@@ -311,33 +345,57 @@
"IMAGE" "IMAGE"
], ],
[ [
1494, 1499,
697, 706,
0,
369,
0,
"IMAGE"
],
[
1500,
708,
0,
709,
0,
"*"
],
[
1501,
708,
1,
710,
0,
"*"
],
[
1502,
709,
0, 0,
442, 442,
0, 0,
"IMAGE" "IMAGE"
], ],
[ [
1495, 1503,
697, 709,
0, 0,
606, 606,
0, 0,
"IMAGE" "IMAGE"
], ],
[ [
1496, 1504,
697, 710,
1, 0,
368, 706,
0, 0,
"MASK" "MASK"
], ],
[ [
1497, 1505,
697, 710,
1, 0,
442, 442,
1, 1,
"MASK" "MASK"
@@ -349,8 +407,8 @@
"ds": { "ds": {
"scale": 0.7972024500000005, "scale": 0.7972024500000005,
"offset": [ "offset": [
3957.401300495613, 3208.3419155969927,
3455.1487103849176 3617.011371212156
] ]
}, },
"ue_links": [], "ue_links": [],

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

View File

@@ -123,10 +123,17 @@ export class BatchPreviewManager {
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible; this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
if (this.maskWasVisible) { if (this.maskWasVisible) {
this.canvas.maskTool.toggleOverlayVisibility(); this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`); const toggleSwitch = document.getElementById(`toggle-mask-switch-${this.canvas.node.id}`);
if (toggleBtn) { if (toggleSwitch) {
toggleBtn.classList.remove('primary'); const checkbox = toggleSwitch.querySelector('input[type="checkbox"]');
toggleBtn.textContent = "Hide Mask"; if (checkbox) {
checkbox.checked = false;
}
toggleSwitch.classList.remove('primary');
const iconContainer = toggleSwitch.querySelector('.switch-icon');
if (iconContainer) {
iconContainer.style.opacity = '0.5';
}
} }
this.canvas.render(); this.canvas.render();
} }
@@ -143,6 +150,10 @@ export class BatchPreviewManager {
this.worldX -= menuWidthInWorld / 2; this.worldX -= menuWidthInWorld / 2;
this.worldY += paddingInWorld; this.worldY += paddingInWorld;
} }
// Hide all batch layers initially, then show only the first one
this.layers.forEach((layer) => {
layer.visible = false;
});
this._update(); this._update();
} }
hide() { hide() {
@@ -158,14 +169,28 @@ export class BatchPreviewManager {
this.canvas.render(); this.canvas.render();
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) { if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
this.canvas.maskTool.toggleOverlayVisibility(); this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`); const toggleSwitch = document.getElementById(`toggle-mask-switch-${String(this.canvas.node.id)}`);
if (toggleBtn) { if (toggleSwitch) {
toggleBtn.classList.add('primary'); const checkbox = toggleSwitch.querySelector('input[type="checkbox"]');
toggleBtn.textContent = "Show Mask"; if (checkbox) {
checkbox.checked = true;
}
toggleSwitch.classList.add('primary');
const iconContainer = toggleSwitch.querySelector('.switch-icon');
if (iconContainer) {
iconContainer.style.opacity = '1';
}
} }
} }
this.maskWasVisible = false; this.maskWasVisible = false;
this.canvas.layers.forEach((l) => l.visible = true); // Only make visible the layers that were part of the batch preview
this.layers.forEach((layer) => {
layer.visible = true;
});
// Update the layers panel to reflect visibility changes
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
this.canvas.render(); this.canvas.render();
} }
navigate(direction) { navigate(direction) {
@@ -203,11 +228,22 @@ export class BatchPreviewManager {
_focusOnLayer(layer) { _focusOnLayer(layer) {
if (!layer) if (!layer)
return; return;
log.debug(`Focusing on layer ${layer.id}`); log.debug(`Focusing on layer ${layer.id} using visibility toggle`);
// Move the selected layer to the top of the layer stack // Hide all batch layers first
this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 }); this.layers.forEach((l) => {
this.canvas.updateSelection([layer]); l.visible = false;
// Render is called by moveLayers, but we call it again to be safe });
// Show only the current layer
layer.visible = true;
// Deselect only this layer if it is selected
const selected = this.canvas.canvasSelection.selectedLayers;
if (selected && selected.includes(layer)) {
this.canvas.updateSelection(selected.filter((l) => l !== layer));
}
// Update the layers panel to reflect visibility changes
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
this.canvas.render(); this.canvas.render();
} }
} }

View File

@@ -1,6 +1,8 @@
// @ts-ignore // @ts-ignore
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import { MaskTool } from "./MaskTool.js"; import { MaskTool } from "./MaskTool.js";
import { ShapeTool } from "./ShapeTool.js";
import { CustomShapeMenu } from "./CustomShapeMenu.js";
import { CanvasState } from "./CanvasState.js"; import { CanvasState } from "./CanvasState.js";
import { CanvasInteractions } from "./CanvasInteractions.js"; import { CanvasInteractions } from "./CanvasInteractions.js";
import { CanvasLayers } from "./CanvasLayers.js"; import { CanvasLayers } from "./CanvasLayers.js";
@@ -10,8 +12,8 @@ import { CanvasIO } from "./CanvasIO.js";
import { ImageReferenceManager } from "./ImageReferenceManager.js"; import { ImageReferenceManager } from "./ImageReferenceManager.js";
import { BatchPreviewManager } from "./BatchPreviewManager.js"; import { BatchPreviewManager } from "./BatchPreviewManager.js";
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { debounce } from "./utils/CommonUtils.js"; import { debounce, createCanvas } from "./utils/CommonUtils.js";
import { CanvasMask } from "./CanvasMask.js"; import { MaskEditorIntegration } from "./MaskEditorIntegration.js";
import { CanvasSelection } from "./CanvasSelection.js"; import { CanvasSelection } from "./CanvasSelection.js";
const useChainCallback = (original, next) => { const useChainCallback = (original, next) => {
if (original === undefined || original === null) { if (original === undefined || original === null) {
@@ -36,33 +38,68 @@ export class Canvas {
constructor(node, widget, callbacks = {}) { constructor(node, widget, callbacks = {}) {
this.node = node; this.node = node;
this.widget = widget; this.widget = widget;
this.canvas = document.createElement('canvas'); const { canvas, ctx } = createCanvas(0, 0, '2d', { willReadFrequently: true });
const ctx = this.canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) if (!ctx)
throw new Error("Could not create canvas context"); throw new Error("Could not create canvas context");
this.canvas = canvas;
this.ctx = ctx; this.ctx = ctx;
this.width = 512; this.width = 512;
this.height = 512; this.height = 512;
this.layers = []; this.layers = [];
this.onStateChange = callbacks.onStateChange; this.onStateChange = callbacks.onStateChange;
this.onHistoryChange = callbacks.onHistoryChange; this.onHistoryChange = callbacks.onHistoryChange;
this.onViewportChange = null;
this.lastMousePosition = { x: 0, y: 0 }; this.lastMousePosition = { x: 0, y: 0 };
this.viewport = { this.viewport = {
x: -(this.width / 4), x: -(this.width / 1.5),
y: -(this.height / 4), y: -(this.height / 2),
zoom: 0.8, zoom: 0.8,
}; };
this.offscreenCanvas = document.createElement('canvas'); const { canvas: offscreenCanvas, ctx: offscreenCtx } = createCanvas(0, 0, '2d', {
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
alpha: false, alpha: false,
willReadFrequently: true willReadFrequently: true
}); });
this.offscreenCanvas = offscreenCanvas;
this.offscreenCtx = offscreenCtx;
// Create overlay canvas for brush cursor and other lightweight overlays
const { canvas: overlayCanvas, ctx: overlayCtx } = createCanvas(0, 0, '2d', {
alpha: true,
willReadFrequently: false
});
if (!overlayCtx)
throw new Error("Could not create overlay canvas context");
this.overlayCanvas = overlayCanvas;
this.overlayCtx = overlayCtx;
this.canvasContainer = null;
this.dataInitialized = false; this.dataInitialized = false;
this.pendingDataCheck = null; this.pendingDataCheck = null;
this.pendingInputDataCheck = null;
this.inputDataLoaded = false;
this.imageCache = new Map(); this.imageCache = new Map();
this.requestSaveState = () => { }; this.requestSaveState = () => { };
this.outputAreaShape = null;
this.autoApplyShapeMask = false;
this.shapeMaskExpansion = false;
this.shapeMaskExpansionValue = 0;
this.shapeMaskFeather = false;
this.shapeMaskFeatherValue = 0;
this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
this.outputAreaExtensionEnabled = false;
this.outputAreaExtensionPreview = null;
this.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
this.originalCanvasSize = { width: this.width, height: this.height };
this.originalOutputAreaPosition = { x: -(this.width / 4), y: -(this.height / 4) };
// Initialize outputAreaBounds centered in viewport, similar to how canvas resize/move work
this.outputAreaBounds = {
x: -(this.width / 4),
y: -(this.height / 4),
width: this.width,
height: this.height
};
this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange }); this.maskTool = new MaskTool(this, { onStateChange: this.onStateChange });
this.canvasMask = new CanvasMask(this); this.shapeTool = new ShapeTool(this);
this.customShapeMenu = new CustomShapeMenu(this);
this.maskEditorIntegration = new MaskEditorIntegration(this);
this.canvasState = new CanvasState(this); this.canvasState = new CanvasState(this);
this.canvasSelection = new CanvasSelection(this); this.canvasSelection = new CanvasSelection(this);
this.canvasInteractions = new CanvasInteractions(this); this.canvasInteractions = new CanvasInteractions(this);
@@ -275,12 +312,6 @@ export class Canvas {
removeSelectedLayers() { removeSelectedLayers() {
return this.canvasSelection.removeSelectedLayers(); return this.canvasSelection.removeSelectedLayers();
} }
/**
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
*/
duplicateSelectedLayers() {
return this.canvasSelection.duplicateSelectedLayers();
}
/** /**
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia. * To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
@@ -295,6 +326,9 @@ export class Canvas {
updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) { updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index) {
return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
} }
defineOutputAreaWithShape(shape) {
this.canvasInteractions.defineOutputAreaWithShape(shape);
}
/** /**
* Zmienia rozmiar obszaru wyjściowego * Zmienia rozmiar obszaru wyjściowego
* @param {number} width - Nowa szerokość * @param {number} width - Nowa szerokość
@@ -302,7 +336,17 @@ export class Canvas {
* @param {boolean} saveHistory - Czy zapisać w historii * @param {boolean} saveHistory - Czy zapisać w historii
*/ */
updateOutputAreaSize(width, height, saveHistory = true) { updateOutputAreaSize(width, height, saveHistory = true) {
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory); const result = this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
// Update mask canvas to ensure it covers the new output area
this.maskTool.updateMaskCanvasForOutputArea();
return result;
}
/**
* Ustawia nowy rozmiar output area zgodnie z nowym systemem (resetuje rozszerzenia, pozycję, rozmiar)
* (Fasada: deleguje do CanvasLayers)
*/
setOutputAreaSize(width, height) {
this.canvasLayers.setOutputAreaSize(width, height);
} }
/** /**
* Eksportuje spłaszczony canvas jako blob * Eksportuje spłaszczony canvas jako blob
@@ -330,21 +374,25 @@ export class Canvas {
return widget ? widget.value : false; return widget ? widget.value : false;
}; };
const handleExecutionStart = () => { const handleExecutionStart = () => {
// Check for input data when execution starts, but don't reset the flag
log.debug('Execution started, checking for input data...');
// On start, only allow images; mask should load on mask-connect or after execution completes
this.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: 'execution_start' });
if (getAutoRefreshValue()) { if (getAutoRefreshValue()) {
lastExecutionStartTime = Date.now(); lastExecutionStartTime = Date.now();
// Store a snapshot of the context for the upcoming batch // Store a snapshot of the context for the upcoming batch
this.pendingBatchContext = { this.pendingBatchContext = {
// For the menu position // For the menu position - position relative to outputAreaBounds, not canvas center
spawnPosition: { spawnPosition: {
x: this.width / 2, x: this.outputAreaBounds.x + this.outputAreaBounds.width / 2,
y: this.height y: this.outputAreaBounds.y + this.outputAreaBounds.height
}, },
// For the image placement // For the image placement - use actual outputAreaBounds instead of hardcoded (0,0)
outputArea: { outputArea: {
x: 0, x: this.outputAreaBounds.x,
y: 0, y: this.outputAreaBounds.y,
width: this.width, width: this.outputAreaBounds.width,
height: this.height height: this.outputAreaBounds.height
} }
}; };
log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext); log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext);
@@ -352,6 +400,9 @@ export class Canvas {
} }
}; };
const handleExecutionSuccess = async () => { const handleExecutionSuccess = async () => {
// Always check for input data after execution completes
log.debug('Execution success, checking for input data...');
await this.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: 'execution_success' });
if (getAutoRefreshValue()) { if (getAutoRefreshValue()) {
log.info('Auto-refresh triggered, importing latest images.'); log.info('Auto-refresh triggered, importing latest images.');
if (!this.pendingBatchContext) { if (!this.pendingBatchContext) {
@@ -386,14 +437,14 @@ export class Canvas {
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora * @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
*/ */
async startMaskEditor(predefinedMask = null, sendCleanImage = true) { async startMaskEditor(predefinedMask = null, sendCleanImage = true) {
return this.canvasMask.startMaskEditor(predefinedMask, sendCleanImage); return this.maskEditorIntegration.startMaskEditor(predefinedMask, sendCleanImage);
} }
/** /**
* Inicjalizuje podstawowe właściwości canvas * Inicjalizuje podstawowe właściwości canvas
*/ */
initCanvas() { initCanvas() {
this.canvas.width = this.width; // Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
this.canvas.height = this.height; // this.width and this.height are for the OUTPUT AREA, not the display canvas
this.canvas.style.border = '1px solid black'; this.canvas.style.border = '1px solid black';
this.canvas.style.maxWidth = '100%'; this.canvas.style.maxWidth = '100%';
this.canvas.style.backgroundColor = '#606060'; this.canvas.style.backgroundColor = '#606060';

View File

@@ -1,6 +1,8 @@
import { createCanvas } from "./utils/CommonUtils.js"; import { createCanvas } from "./utils/CommonUtils.js";
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { showErrorNotification } from "./utils/NotificationUtils.js";
import { webSocketManager } from "./utils/WebSocketManager.js"; import { webSocketManager } from "./utils/WebSocketManager.js";
import { scaleImageToFit, createImageFromSource, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js";
const log = createModuleLogger('CanvasIO'); const log = createModuleLogger('CanvasIO');
export class CanvasIO { export class CanvasIO {
constructor(canvas) { constructor(canvas) {
@@ -49,10 +51,9 @@ export class CanvasIO {
return new Promise((resolve) => { return new Promise((resolve) => {
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height); const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height);
const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height); const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height);
const visibilityCanvas = document.createElement('canvas'); const originalShape = this.canvas.outputAreaShape;
visibilityCanvas.width = this.canvas.width; this.canvas.outputAreaShape = null;
visibilityCanvas.height = this.canvas.height; const { canvas: visibilityCanvas, ctx: visibilityCtx } = createCanvas(this.canvas.width, this.canvas.height, '2d', { alpha: true });
const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true });
if (!visibilityCtx) if (!visibilityCtx)
throw new Error("Could not create visibility context"); throw new Error("Could not create visibility context");
if (!maskCtx) if (!maskCtx)
@@ -74,43 +75,37 @@ export class CanvasIO {
maskData.data[i + 3] = 255; maskData.data[i + 3] = 255;
} }
maskCtx.putImageData(maskData, 0, 0); maskCtx.putImageData(maskData, 0, 0);
const toolMaskCanvas = this.canvas.maskTool.getMask(); this.canvas.outputAreaShape = originalShape;
// Use optimized getMaskForOutputArea() instead of getMask() for better performance
// This only processes chunks that overlap with the output area
const toolMaskCanvas = this.canvas.maskTool.getMaskForOutputArea();
if (toolMaskCanvas) { if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas'); log.debug(`Using optimized output area mask (${toolMaskCanvas.width}x${toolMaskCanvas.height}) instead of full mask`);
tempMaskCanvas.width = this.canvas.width; // The optimized mask is already sized and positioned for the output area
tempMaskCanvas.height = this.canvas.height; // So we can draw it directly without complex positioning calculations
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true }); const tempMaskData = toolMaskCanvas.getContext('2d', { willReadFrequently: true })?.getImageData(0, 0, toolMaskCanvas.width, toolMaskCanvas.height);
if (!tempMaskCtx) if (tempMaskData) {
throw new Error("Could not create temp mask context"); // Ensure the mask data is in the correct format (white with alpha)
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height); for (let i = 0; i < tempMaskData.data.length; i += 4) {
const maskX = this.canvas.maskTool.x; const alpha = tempMaskData.data[i + 3];
const maskY = this.canvas.maskTool.y; tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
log.debug(`Extracting mask from world position (${maskX}, ${maskY}) for output area (0,0) to (${this.canvas.width}, ${this.canvas.height})`); tempMaskData.data[i + 3] = alpha;
const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading }
const sourceY = Math.max(0, -maskY); // Create a temporary canvas to hold the processed mask
const destX = Math.max(0, maskX); // Where in the output canvas to start writing const { canvas: tempMaskCanvas, ctx: tempMaskCtx } = createCanvas(this.canvas.width, this.canvas.height, '2d', { willReadFrequently: true });
const destY = Math.max(0, maskY); if (!tempMaskCtx)
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, // Available width in source throw new Error("Could not create temp mask context");
this.canvas.width - destX // Available width in destination // Put the processed mask data into a canvas that matches the output area size
); const { canvas: outputMaskCanvas, ctx: outputMaskCtx } = createCanvas(toolMaskCanvas.width, toolMaskCanvas.height, '2d', { willReadFrequently: true });
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, // Available height in source if (!outputMaskCtx)
this.canvas.height - destY // Available height in destination throw new Error("Could not create output mask context");
); outputMaskCtx.putImageData(tempMaskData, 0, 0);
if (copyWidth > 0 && copyHeight > 0) { // Draw the optimized mask at the correct position (output area bounds)
log.debug(`Copying mask region: source(${sourceX}, ${sourceY}) to dest(${destX}, ${destY}) size(${copyWidth}, ${copyHeight})`); const bounds = this.canvas.outputAreaBounds;
tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, // Source rectangle tempMaskCtx.drawImage(outputMaskCanvas, bounds.x, bounds.y);
destX, destY, copyWidth, copyHeight // Destination rectangle maskCtx.globalCompositeOperation = 'source-over';
); maskCtx.drawImage(tempMaskCanvas, 0, 0);
} }
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
tempMaskData.data[i + 3] = alpha;
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
maskCtx.globalCompositeOperation = 'source-over';
maskCtx.drawImage(tempMaskCanvas, 0, 0);
} }
if (outputMode === 'ram') { if (outputMode === 'ram') {
const imageData = tempCanvas.toDataURL('image/png'); const imageData = tempCanvas.toDataURL('image/png');
@@ -201,67 +196,51 @@ export class CanvasIO {
}); });
} }
async _renderOutputData() { async _renderOutputData() {
return new Promise((resolve) => { log.info("=== RENDERING OUTPUT DATA FOR COMFYUI ===");
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(this.canvas.width, this.canvas.height); // Check if layers have valid images loaded, with retry logic
const { canvas: maskCanvas, ctx: maskCtx } = createCanvas(this.canvas.width, this.canvas.height); const maxRetries = 5;
const visibilityCanvas = document.createElement('canvas'); const retryDelay = 200;
visibilityCanvas.width = this.canvas.width; for (let attempt = 0; attempt < maxRetries; attempt++) {
visibilityCanvas.height = this.canvas.height; const layersWithoutImages = this.canvas.layers.filter(layer => !layer.image || !layer.image.complete);
const visibilityCtx = visibilityCanvas.getContext('2d', { alpha: true }); if (layersWithoutImages.length === 0) {
if (!visibilityCtx) break; // All images loaded
throw new Error("Could not create visibility context");
if (!maskCtx)
throw new Error("Could not create mask context");
if (!tempCtx)
throw new Error("Could not create temp context");
maskCtx.fillStyle = '#ffffff'; // Start with a white mask (nothing masked)
maskCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.canvas.canvasLayers.drawLayersToContext(tempCtx, this.canvas.layers);
this.canvas.canvasLayers.drawLayersToContext(visibilityCtx, this.canvas.layers);
const visibilityData = visibilityCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const maskData = maskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < visibilityData.data.length; i += 4) {
const alpha = visibilityData.data[i + 3];
const maskValue = 255 - alpha; // Invert alpha to create the mask
maskData.data[i] = maskData.data[i + 1] = maskData.data[i + 2] = maskValue;
maskData.data[i + 3] = 255; // Solid mask
} }
maskCtx.putImageData(maskData, 0, 0); if (attempt === 0) {
const toolMaskCanvas = this.canvas.maskTool.getMask(); log.warn(`${layersWithoutImages.length} layer(s) have incomplete image data. Waiting for images to load...`);
if (toolMaskCanvas) {
const tempMaskCanvas = document.createElement('canvas');
tempMaskCanvas.width = this.canvas.width;
tempMaskCanvas.height = this.canvas.height;
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
if (!tempMaskCtx)
throw new Error("Could not create temp mask context");
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
const maskX = this.canvas.maskTool.x;
const maskY = this.canvas.maskTool.y;
log.debug(`[renderOutputData] Extracting mask from world position (${maskX}, ${maskY})`);
const sourceX = Math.max(0, -maskX);
const sourceY = Math.max(0, -maskY);
const destX = Math.max(0, maskX);
const destY = Math.max(0, maskY);
const copyWidth = Math.min(toolMaskCanvas.width - sourceX, this.canvas.width - destX);
const copyHeight = Math.min(toolMaskCanvas.height - sourceY, this.canvas.height - destY);
if (copyWidth > 0 && copyHeight > 0) {
tempMaskCtx.drawImage(toolMaskCanvas, sourceX, sourceY, copyWidth, copyHeight, destX, destY, copyWidth, copyHeight);
}
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
for (let i = 0; i < tempMaskData.data.length; i += 4) {
const alpha = tempMaskData.data[i + 3];
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = alpha;
tempMaskData.data[i + 3] = 255; // Solid alpha
}
tempMaskCtx.putImageData(tempMaskData, 0, 0);
maskCtx.globalCompositeOperation = 'screen';
maskCtx.drawImage(tempMaskCanvas, 0, 0);
} }
const imageDataUrl = tempCanvas.toDataURL('image/png'); if (attempt < maxRetries - 1) {
const maskDataUrl = maskCanvas.toDataURL('image/png'); await new Promise(resolve => setTimeout(resolve, retryDelay));
resolve({ image: imageDataUrl, mask: maskDataUrl }); }
else {
// Last attempt failed
throw new Error(`Canvas not ready after ${maxRetries} attempts: ${layersWithoutImages.length} layer(s) still have incomplete image data. Try waiting a moment and running again.`);
}
}
// Użyj zunifikowanych funkcji z CanvasLayers
const imageBlob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
const maskBlob = await this.canvas.canvasLayers.getFlattenedMaskAsBlob();
if (!imageBlob || !maskBlob) {
throw new Error("Failed to generate canvas or mask blobs");
}
// Konwertuj blob na data URL
const imageDataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(imageBlob);
}); });
const maskDataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(maskBlob);
});
const bounds = this.canvas.outputAreaBounds;
log.info(`=== OUTPUT DATA GENERATED ===`);
log.info(`Image size: ${bounds.width}x${bounds.height}`);
log.info(`Image data URL length: ${imageDataUrl.length}`);
log.info(`Mask data URL length: ${maskDataUrl.length}`);
return { image: imageDataUrl, mask: maskDataUrl };
} }
async sendDataViaWebSocket(nodeId) { async sendDataViaWebSocket(nodeId) {
log.info(`Preparing to send data for node ${nodeId} via WebSocket.`); log.info(`Preparing to send data for node ${nodeId} via WebSocket.`);
@@ -279,27 +258,26 @@ export class CanvasIO {
} }
catch (error) { catch (error) {
log.error(`Failed to send data for node ${nodeId}:`, error); log.error(`Failed to send data for node ${nodeId}:`, error);
throw new Error(`Failed to get confirmation from server for node ${nodeId}. The workflow might not have the latest canvas data.`); throw new Error(`Failed to get confirmation from server for node ${nodeId}. ` +
`Make sure that the nodeId: (${nodeId}) matches the "node_id" value in the node options. If they don't match, you may need to manually set the node_id to ${nodeId}.` +
`If the issue persists, try using a different browser. Some issues have been observed specifically with portable versions of Chrome, ` +
`which may have limitations related to memory or WebSocket handling. Consider testing in a standard Chrome installation, Firefox, or another browser.`);
} }
} }
async addInputToCanvas(inputImage, inputMask) { async addInputToCanvas(inputImage, inputMask) {
try { try {
log.debug("Adding input to canvas:", { inputImage }); log.debug("Adding input to canvas:", { inputImage });
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(inputImage.width, inputImage.height); // Use unified tensorToImageData for RGB image
if (!tempCtx) const imageData = tensorToImageData(inputImage, 'rgb');
throw new Error("Could not create temp context"); if (!imageData)
const imgData = new ImageData(new Uint8ClampedArray(inputImage.data), inputImage.width, inputImage.height); throw new Error("Failed to convert input image tensor");
tempCtx.putImageData(imgData, 0, 0); // Create HTMLImageElement from ImageData
const image = new Image(); const image = await createImageFromImageData(imageData);
await new Promise((resolve, reject) => { const bounds = this.canvas.outputAreaBounds;
image.onload = resolve; const scale = Math.min(bounds.width / inputImage.width * 0.8, bounds.height / inputImage.height * 0.8);
image.onerror = reject;
image.src = tempCanvas.toDataURL();
});
const scale = Math.min(this.canvas.width / inputImage.width * 0.8, this.canvas.height / inputImage.height * 0.8);
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, { const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
x: (this.canvas.width - inputImage.width * scale) / 2, x: bounds.x + (bounds.width - inputImage.width * scale) / 2,
y: (this.canvas.height - inputImage.height * scale) / 2, y: bounds.y + (bounds.height - inputImage.height * scale) / 2,
width: inputImage.width * scale, width: inputImage.width * scale,
height: inputImage.height * scale, height: inputImage.height * scale,
}); });
@@ -320,20 +298,10 @@ export class CanvasIO {
if (!tensor || !tensor.data || !tensor.width || !tensor.height) { if (!tensor || !tensor.data || !tensor.width || !tensor.height) {
throw new Error("Invalid tensor data"); throw new Error("Invalid tensor data");
} }
const canvas = document.createElement('canvas'); const imageData = tensorToImageData(tensor, 'rgb');
const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!imageData)
if (!ctx) throw new Error("Failed to convert tensor to image data");
throw new Error("Could not create canvas context"); return await createImageFromImageData(imageData);
canvas.width = tensor.width;
canvas.height = tensor.height;
const imageData = new ImageData(new Uint8ClampedArray(tensor.data), tensor.width, tensor.height);
ctx.putImageData(imageData, 0, 0);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(new Error("Failed to load image: " + e));
img.src = canvas.toDataURL();
});
} }
catch (error) { catch (error) {
log.error("Error converting tensor to image:", error); log.error("Error converting tensor to image:", error);
@@ -354,12 +322,26 @@ export class CanvasIO {
async initNodeData() { async initNodeData() {
try { try {
log.info("Starting node data initialization..."); log.info("Starting node data initialization...");
// First check for input data from the backend (new feature)
await this.checkForInputData();
// If we've already loaded input data, don't continue with old initialization
if (this.canvas.inputDataLoaded) {
log.debug("Input data already loaded, skipping old initialization");
this.canvas.dataInitialized = true;
return;
}
if (!this.canvas.node || !this.canvas.node.inputs) { if (!this.canvas.node || !this.canvas.node.inputs) {
log.debug("Node or inputs not ready"); log.debug("Node or inputs not ready");
return this.scheduleDataCheck(); return this.scheduleDataCheck();
} }
if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) { if (this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const imageLinkId = this.canvas.node.inputs[0].link; const imageLinkId = this.canvas.node.inputs[0].link;
// Check if we already loaded this link
if (this.canvas.lastLoadedLinkId === imageLinkId) {
log.debug(`Link ${imageLinkId} already loaded via new system, marking as initialized`);
this.canvas.dataInitialized = true;
return;
}
const imageData = window.app.nodeOutputs[imageLinkId]; const imageData = window.app.nodeOutputs[imageLinkId];
if (imageData) { if (imageData) {
log.debug("Found image data:", imageData); log.debug("Found image data:", imageData);
@@ -371,6 +353,10 @@ export class CanvasIO {
return this.scheduleDataCheck(); return this.scheduleDataCheck();
} }
} }
else {
// No input connected, mark as initialized to stop repeated checks
this.canvas.dataInitialized = true;
}
if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) { if (this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
const maskLinkId = this.canvas.node.inputs[1].link; const maskLinkId = this.canvas.node.inputs[1].link;
const maskData = window.app.nodeOutputs[maskLinkId]; const maskData = window.app.nodeOutputs[maskLinkId];
@@ -385,6 +371,390 @@ export class CanvasIO {
return this.scheduleDataCheck(); return this.scheduleDataCheck();
} }
} }
async checkForInputData(options) {
try {
const nodeId = this.canvas.node.id;
const allowImage = options?.allowImage ?? true;
const allowMask = options?.allowMask ?? true;
const reason = options?.reason ?? 'unspecified';
log.info(`Checking for input data for node ${nodeId}... opts: image=${allowImage}, mask=${allowMask}, reason=${reason}`);
// Track loaded links separately for image and mask
let imageLoaded = false;
let maskLoaded = false;
let imageChanged = false;
// First, try to get data from connected node's output if available (IMAGES)
if (allowImage && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const linkId = this.canvas.node.inputs[0].link;
const graph = this.canvas.node.graph;
// Always check if images have changed first
if (graph) {
const link = graph.links[linkId];
if (link) {
const sourceNode = graph.getNodeById(link.origin_id);
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
// Create current batch identifier (all image sources combined)
const currentBatchImageSrcs = sourceNode.imgs.map((img) => img.src).join('|');
// Check if this is the same link we loaded before
if (this.canvas.lastLoadedLinkId === linkId) {
// Same link, check if images actually changed
if (this.canvas.lastLoadedImageSrc !== currentBatchImageSrcs) {
log.info(`Batch images changed for link ${linkId} (${sourceNode.imgs.length} images), will reload...`);
log.debug(`Previous batch hash: ${this.canvas.lastLoadedImageSrc?.substring(0, 100)}...`);
log.debug(`Current batch hash: ${currentBatchImageSrcs.substring(0, 100)}...`);
imageChanged = true;
// Clear the inputDataLoaded flag to force reload from backend
this.canvas.inputDataLoaded = false;
// Clear the lastLoadedImageSrc to force reload
this.canvas.lastLoadedImageSrc = undefined;
// Clear backend data to force fresh load
fetch(`/layerforge/clear_input_data/${nodeId}`, { method: 'POST' })
.then(() => log.debug("Backend input data cleared due to image change"))
.catch(err => log.error("Failed to clear backend data:", err));
}
else {
log.debug(`Batch images for link ${linkId} unchanged (${sourceNode.imgs.length} images)`);
imageLoaded = true;
}
}
else {
// Different link or first load
log.info(`New link ${linkId} detected, will load ${sourceNode.imgs.length} images`);
imageChanged = false; // It's not a change, it's a new link
imageLoaded = false; // Need to load
// Reset the inputDataLoaded flag for new link
this.canvas.inputDataLoaded = false;
}
}
}
}
if (!imageLoaded || imageChanged) {
// Reset the inputDataLoaded flag when images change
if (imageChanged) {
this.canvas.inputDataLoaded = false;
log.info("Resetting inputDataLoaded flag due to image change");
}
if (this.canvas.node.graph) {
const graph2 = this.canvas.node.graph;
const link2 = graph2.links[linkId];
if (link2) {
const sourceNode = graph2.getNodeById(link2.origin_id);
if (sourceNode && sourceNode.imgs && sourceNode.imgs.length > 0) {
// The connected node has images in its output - handle multiple images (batch)
log.info(`Found ${sourceNode.imgs.length} image(s) in connected node's output, loading all`);
// Create a combined source identifier for batch detection
const batchImageSrcs = sourceNode.imgs.map((img) => img.src).join('|');
// Mark this link and batch sources as loaded
this.canvas.lastLoadedLinkId = linkId;
this.canvas.lastLoadedImageSrc = batchImageSrcs;
// Don't clear layers - just add new ones
if (imageChanged) {
log.info("Image change detected, will add new layers");
}
// Determine add mode
const fitOnAddWidget = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center';
// Add all images from the batch as separate layers
for (let i = 0; i < sourceNode.imgs.length; i++) {
const img = sourceNode.imgs[i];
await this.canvas.canvasLayers.addLayerWithImage(img, { name: `Batch Image ${i + 1}` }, // Give each layer a unique name
addMode, this.canvas.outputAreaBounds);
log.debug(`Added batch image ${i + 1}/${sourceNode.imgs.length} to canvas`);
}
this.canvas.inputDataLoaded = true;
imageLoaded = true;
log.info(`All ${sourceNode.imgs.length} input images from batch added as separate layers`);
this.canvas.render();
this.canvas.saveState();
}
}
}
}
}
// Check for mask input separately (from nodeOutputs) ONLY when allowed
if (allowMask && this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link) {
const maskLinkId = this.canvas.node.inputs[1].link;
// Check if we already loaded this mask link
if (this.canvas.lastLoadedMaskLinkId === maskLinkId) {
log.debug(`Mask link ${maskLinkId} already loaded`);
maskLoaded = true;
}
else {
// Try to get mask tensor from nodeOutputs using origin_id (not link id)
const graph = this.canvas.node.graph;
let maskOutput = null;
if (graph) {
const link = graph.links[maskLinkId];
if (link && link.origin_id) {
// Use origin_id to get the actual node output
const nodeOutput = window.app?.nodeOutputs?.[link.origin_id];
log.debug(`Looking for mask output from origin node ${link.origin_id}, found:`, !!nodeOutput);
if (nodeOutput) {
log.debug(`Node ${link.origin_id} output structure:`, {
hasData: !!nodeOutput.data,
hasShape: !!nodeOutput.shape,
dataType: typeof nodeOutput.data,
shapeType: typeof nodeOutput.shape,
keys: Object.keys(nodeOutput)
});
// Only use if it has actual tensor data
if (nodeOutput.data && nodeOutput.shape) {
maskOutput = nodeOutput;
}
}
}
}
if (maskOutput && maskOutput.data && maskOutput.shape) {
try {
// Derive dimensions from shape or explicit width/height
let width = maskOutput.width || 0;
let height = maskOutput.height || 0;
const shape = maskOutput.shape; // e.g. [1,H,W] or [1,H,W,1]
if ((!width || !height) && Array.isArray(shape)) {
if (shape.length >= 3) {
height = shape[1];
width = shape[2];
}
else if (shape.length === 2) {
height = shape[0];
width = shape[1];
}
}
if (!width || !height) {
throw new Error("Cannot determine mask dimensions from nodeOutputs");
}
// Determine channels count
let channels = 1;
if (Array.isArray(shape) && shape.length >= 4) {
channels = shape[3];
}
else if (maskOutput.channels) {
channels = maskOutput.channels;
}
else {
const len = maskOutput.data.length;
channels = Math.max(1, Math.floor(len / (width * height)));
}
// Use unified tensorToImageData for masks
const maskImageData = tensorToImageData(maskOutput, 'grayscale');
if (!maskImageData)
throw new Error("Failed to convert mask tensor to image data");
// Create canvas and put image data
const { canvas: maskCanvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create mask context");
ctx.putImageData(maskImageData, 0, 0);
// Convert to HTMLImageElement
const maskImg = await createImageFromSource(maskCanvas.toDataURL());
// Respect fit_on_add (scale to output area)
const widgets = this.canvas.node.widgets;
const fitOnAddWidget = widgets ? widgets.find((w) => w.name === "fit_on_add") : null;
const shouldFit = fitOnAddWidget && fitOnAddWidget.value;
let finalMaskImg = maskImg;
if (shouldFit) {
const bounds = this.canvas.outputAreaBounds;
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
}
// Apply to MaskTool (centers internally)
if (this.canvas.maskTool) {
this.canvas.maskTool.setMask(finalMaskImg, true);
this.canvas.maskAppliedFromInput = true;
this.canvas.canvasState.saveMaskState();
this.canvas.render();
// Mark this mask link as loaded to avoid re-applying
this.canvas.lastLoadedMaskLinkId = maskLinkId;
maskLoaded = true;
log.info("Applied input mask from nodeOutputs immediately on connection" + (shouldFit ? " (fitted to output area)" : ""));
}
}
catch (err) {
log.warn("Failed to apply mask from nodeOutputs immediately; will wait for backend input_mask after execution", err);
}
}
else {
// nodeOutputs exist but don't have tensor data yet (need workflow execution)
log.info(`Mask node ${this.canvas.node.graph?.links[maskLinkId]?.origin_id} found but has no tensor data yet. Mask will be applied automatically after workflow execution.`);
// Don't retry - data won't be available until workflow runs
}
}
}
// Only check backend if we have actual inputs connected
const hasImageInput = this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link;
const hasMaskInput = this.canvas.node.inputs && this.canvas.node.inputs[1] && this.canvas.node.inputs[1].link;
// If mask input is disconnected, clear any currently applied mask to ensure full separation
if (!hasMaskInput) {
this.canvas.maskAppliedFromInput = false;
this.canvas.lastLoadedMaskLinkId = undefined;
log.info("Mask input disconnected - cleared mask to enforce separation from input_image");
}
if (!hasImageInput && !hasMaskInput) {
log.debug("No inputs connected, skipping backend check");
this.canvas.inputDataLoaded = true;
return;
}
// Skip backend check during mask connection if we didn't get immediate data
if (reason === "mask_connect" && !maskLoaded) {
log.info("No immediate mask data available during connection, skipping backend check to avoid stale data. Will check after execution.");
return;
}
// Check backend for input data only if we have connected inputs
const response = await fetch(`/layerforge/get_input_data/${nodeId}`);
const result = await response.json();
if (result.success && result.has_input) {
// Dedupe: skip only if backend payload matches last loaded batch hash
let backendBatchHash;
if (result.data?.input_images_batch && Array.isArray(result.data.input_images_batch)) {
backendBatchHash = result.data.input_images_batch.map((i) => i.data).join('|');
}
else if (result.data?.input_image) {
backendBatchHash = result.data.input_image;
}
// Check mask separately - don't skip if only images are unchanged AND mask is actually connected AND allowed
const shouldCheckMask = hasMaskInput && allowMask;
if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && !shouldCheckMask) {
log.debug("Backend input data unchanged and no mask to check, skipping reload");
this.canvas.inputDataLoaded = true;
return;
}
else if (backendBatchHash && this.canvas.lastLoadedImageSrc === backendBatchHash && shouldCheckMask) {
log.debug("Images unchanged but need to check mask, continuing...");
imageLoaded = true; // Mark images as already loaded to skip reloading them
}
// Check if we already loaded image data (by checking the current link)
if (allowImage && !imageLoaded && this.canvas.node.inputs && this.canvas.node.inputs[0] && this.canvas.node.inputs[0].link) {
const currentLinkId = this.canvas.node.inputs[0].link;
if (this.canvas.lastLoadedLinkId !== currentLinkId) {
// Mark this link as loaded
this.canvas.lastLoadedLinkId = currentLinkId;
imageLoaded = false; // Will load from backend
}
}
// Check for mask data from backend ONLY when mask input is actually connected AND allowed
// Only reset if the mask link actually changed
if (allowMask && hasMaskInput && this.canvas.node.inputs && this.canvas.node.inputs[1]) {
const currentMaskLinkId = this.canvas.node.inputs[1].link;
// Only reset if this is a different mask link than what we loaded before
if (this.canvas.lastLoadedMaskLinkId !== currentMaskLinkId) {
maskLoaded = false;
log.debug(`New mask input detected (${currentMaskLinkId}), will check backend for mask data`);
}
else {
log.debug(`Same mask input (${currentMaskLinkId}), mask already loaded`);
maskLoaded = true;
}
}
else {
// No mask input connected, or mask loading not allowed right now
maskLoaded = true; // Mark as loaded to skip mask processing
if (!allowMask) {
log.debug("Mask loading is currently disabled by caller, skipping mask check");
}
else {
log.debug("No mask input connected, skipping mask check");
}
}
log.info("Input data found from backend, adding to canvas");
const inputData = result.data;
// Compute backend batch hash for dedupe and state
let backendHashNow;
if (inputData?.input_images_batch && Array.isArray(inputData.input_images_batch)) {
backendHashNow = inputData.input_images_batch.map((i) => i.data).join('|');
}
else if (inputData?.input_image) {
backendHashNow = inputData.input_image;
}
// Just update the hash without removing any layers
if (backendHashNow) {
log.info("New backend input data detected, adding new layers");
this.canvas.lastLoadedImageSrc = backendHashNow;
}
// Mark that we've loaded input data for this execution
this.canvas.inputDataLoaded = true;
// Determine add mode based on fit_on_add setting
const widgets = this.canvas.node.widgets;
const fitOnAddWidget = widgets ? widgets.find((w) => w.name === "fit_on_add") : null;
const addMode = (fitOnAddWidget && fitOnAddWidget.value) ? 'fit' : 'center';
// Load input image(s) only if image input is actually connected, not already loaded, and allowed
if (allowImage && !imageLoaded && hasImageInput) {
if (inputData.input_images_batch) {
// Handle batch of images
const batch = inputData.input_images_batch;
log.info(`Processing batch of ${batch.length} images from backend`);
for (let i = 0; i < batch.length; i++) {
const imgData = batch[i];
const img = await createImageFromSource(imgData.data);
// Add image to canvas with unique name
await this.canvas.canvasLayers.addLayerWithImage(img, { name: `Batch Image ${i + 1}` }, addMode, this.canvas.outputAreaBounds);
log.debug(`Added batch image ${i + 1}/${batch.length} from backend`);
}
log.info(`All ${batch.length} batch images added from backend`);
this.canvas.render();
this.canvas.saveState();
}
else if (inputData.input_image) {
// Handle single image (backward compatibility)
const img = await createImageFromSource(inputData.input_image);
// Add image to canvas at output area position
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode, this.canvas.outputAreaBounds);
log.info("Single input image added as new layer to canvas");
this.canvas.render();
this.canvas.saveState();
}
else {
log.debug("No input image data from backend");
}
}
else if (!hasImageInput && (inputData.input_images_batch || inputData.input_image)) {
log.debug("Backend has image data but no image input connected, skipping image load");
}
// Handle mask separately only if mask input is actually connected, allowed, and not already loaded
if (allowMask && !maskLoaded && hasMaskInput && inputData.input_mask) {
log.info("Processing input mask");
// Load mask image
const maskImg = await createImageFromSource(inputData.input_mask);
// Determine if we should fit the mask or use it at original size
const fitOnAddWidget2 = this.canvas.node.widgets.find((w) => w.name === "fit_on_add");
const shouldFit = fitOnAddWidget2 && fitOnAddWidget2.value;
let finalMaskImg = maskImg;
if (shouldFit && this.canvas.maskTool) {
const bounds = this.canvas.outputAreaBounds;
finalMaskImg = await scaleImageToFit(maskImg, bounds.width, bounds.height);
}
// Apply to MaskTool (centers internally)
if (this.canvas.maskTool) {
this.canvas.maskTool.setMask(finalMaskImg, true);
}
this.canvas.maskAppliedFromInput = true;
// Save the mask state
this.canvas.canvasState.saveMaskState();
log.info("Applied input mask to mask tool" + (shouldFit ? " (fitted to output area)" : " (original size)"));
}
else if (!hasMaskInput && inputData.input_mask) {
log.debug("Backend has mask data but no mask input connected, skipping mask load");
}
else if (!allowMask && inputData.input_mask) {
log.debug("Mask input data present in backend but mask loading is disabled by caller; skipping");
}
}
else {
log.debug("No input data from backend");
// Don't schedule another check - we'll only check when explicitly triggered
}
}
catch (error) {
log.error("Error checking for input data:", error);
// Don't schedule another check on error
}
}
scheduleInputDataCheck() {
// Schedule a retry for mask data check when nodeOutputs are not ready yet
if (this.canvas.pendingInputDataCheck) {
clearTimeout(this.canvas.pendingInputDataCheck);
}
this.canvas.pendingInputDataCheck = window.setTimeout(() => {
this.canvas.pendingInputDataCheck = null;
log.debug("Retrying input data check for mask...");
}, 500); // Shorter delay for mask data retry
}
scheduleDataCheck() { scheduleDataCheck() {
if (this.canvas.pendingDataCheck) { if (this.canvas.pendingDataCheck) {
clearTimeout(this.canvas.pendingDataCheck); clearTimeout(this.canvas.pendingDataCheck);
@@ -445,7 +815,8 @@ export class CanvasIO {
originalWidth: image.width, originalWidth: image.width,
originalHeight: image.height, originalHeight: image.height,
blendMode: 'normal', blendMode: 'normal',
opacity: 1 opacity: 1,
visible: true
}; };
this.canvas.layers.push(layer); this.canvas.layers.push(layer);
this.canvas.updateSelection([layer]); this.canvas.updateSelection([layer]);
@@ -462,69 +833,10 @@ export class CanvasIO {
} }
} }
convertTensorToImageData(tensor) { convertTensorToImageData(tensor) {
try { return tensorToImageData(tensor, 'rgb');
const shape = tensor.shape;
const height = shape[1];
const width = shape[2];
const channels = shape[3];
log.debug("Converting tensor:", {
shape: shape,
dataRange: {
min: tensor.min_val,
max: tensor.max_val
}
});
const imageData = new ImageData(width, height);
const data = new Uint8ClampedArray(width * height * 4);
const flatData = tensor.data;
const pixelCount = width * height;
for (let i = 0; i < pixelCount; i++) {
const pixelIndex = i * 4;
const tensorIndex = i * channels;
for (let c = 0; c < channels; c++) {
const value = flatData[tensorIndex + c];
const normalizedValue = (value - tensor.min_val) / (tensor.max_val - tensor.min_val);
data[pixelIndex + c] = Math.round(normalizedValue * 255);
}
data[pixelIndex + 3] = 255;
}
imageData.data.set(data);
return imageData;
}
catch (error) {
log.error("Error converting tensor:", error);
return null;
}
} }
async createImageFromData(imageData) { async createImageFromData(imageData) {
return new Promise((resolve, reject) => { return createImageFromImageData(imageData);
const canvas = document.createElement('canvas');
canvas.width = imageData.width;
canvas.height = imageData.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create canvas context");
ctx.putImageData(imageData, 0, 0);
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = canvas.toDataURL();
});
}
async retryDataLoad(maxRetries = 3, delay = 1000) {
for (let i = 0; i < maxRetries; i++) {
try {
await this.initNodeData();
return;
}
catch (error) {
log.warn(`Retry ${i + 1}/${maxRetries} failed:`, error);
if (i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
log.error("Failed to load data after", maxRetries, "retries");
} }
async processMaskData(maskData) { async processMaskData(maskData) {
try { try {
@@ -548,72 +860,6 @@ export class CanvasIO {
log.error("Error processing mask data:", error); log.error("Error processing mask data:", error);
} }
} }
async loadImageFromCache(base64Data) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = base64Data;
});
}
async importImage(cacheData) {
try {
log.info("Starting image import with cache data");
const img = await this.loadImageFromCache(cacheData.image);
const mask = cacheData.mask ? await this.loadImageFromCache(cacheData.mask) : null;
const scale = Math.min(this.canvas.width / img.width * 0.8, this.canvas.height / img.height * 0.8);
const tempCanvas = document.createElement('canvas');
tempCanvas.width = img.width;
tempCanvas.height = img.height;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
if (!tempCtx)
throw new Error("Could not create temp context");
tempCtx.drawImage(img, 0, 0);
if (mask) {
const imageData = tempCtx.getImageData(0, 0, img.width, img.height);
const maskCanvas = document.createElement('canvas');
maskCanvas.width = img.width;
maskCanvas.height = img.height;
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
if (!maskCtx)
throw new Error("Could not create mask context");
maskCtx.drawImage(mask, 0, 0);
const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i + 3] = maskData.data[i];
}
tempCtx.putImageData(imageData, 0, 0);
}
const finalImage = new Image();
await new Promise((resolve) => {
finalImage.onload = resolve;
finalImage.src = tempCanvas.toDataURL();
});
const layer = {
id: '', // This will be set in addLayerWithImage
imageId: '', // This will be set in addLayerWithImage
name: 'Layer',
image: finalImage,
x: (this.canvas.width - img.width * scale) / 2,
y: (this.canvas.height - img.height * scale) / 2,
width: img.width * scale,
height: img.height * scale,
originalWidth: img.width,
originalHeight: img.height,
rotation: 0,
zIndex: this.canvas.layers.length,
blendMode: 'normal',
opacity: 1,
};
this.canvas.layers.push(layer);
this.canvas.updateSelection([layer]);
this.canvas.render();
this.canvas.saveState();
}
catch (error) {
log.error('Error importing image:', error);
}
}
async importLatestImage() { async importLatestImage() {
try { try {
log.info("Fetching latest image from server..."); log.info("Fetching latest image from server...");
@@ -637,7 +883,7 @@ export class CanvasIO {
} }
catch (error) { catch (error) {
log.error("Error importing latest image:", error); log.error("Error importing latest image:", error);
alert(`Failed to import latest image: ${error.message}`); showErrorNotification(`Failed to import latest image: ${error.message}`);
return false; return false;
} }
} }
@@ -650,13 +896,13 @@ export class CanvasIO {
log.info(`Received ${result.images.length} new images, adding to canvas.`); log.info(`Received ${result.images.length} new images, adding to canvas.`);
const newLayers = []; const newLayers = [];
for (const imageData of result.images) { for (const imageData of result.images) {
const img = new Image(); const img = await createImageFromSource(imageData);
await new Promise((resolve, reject) => { let processedImage = img;
img.onload = resolve; // If there's a custom shape, clip the image to that shape
img.onerror = reject; if (this.canvas.outputAreaShape && this.canvas.outputAreaShape.isClosed) {
img.src = imageData; processedImage = await this.clipImageToShape(img, this.canvas.outputAreaShape);
}); }
const newLayer = await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit', targetArea); const newLayer = await this.canvas.canvasLayers.addLayerWithImage(processedImage, {}, 'fit', targetArea);
newLayers.push(newLayer); newLayers.push(newLayer);
} }
log.info("All new images imported and placed on canvas successfully."); log.info("All new images imported and placed on canvas successfully.");
@@ -672,8 +918,32 @@ export class CanvasIO {
} }
catch (error) { catch (error) {
log.error("Error importing latest images:", error); log.error("Error importing latest images:", error);
alert(`Failed to import latest images: ${error.message}`); showErrorNotification(`Failed to import latest images: ${error.message}`);
return []; return [];
} }
} }
async clipImageToShape(image, shape) {
const { canvas, ctx } = createCanvas(image.width, image.height);
if (!ctx) {
throw new Error("Could not create canvas context for clipping");
}
// Draw the image first
ctx.drawImage(image, 0, 0);
// Calculate custom shape position accounting for extensions
// Custom shape should maintain its relative position within the original canvas area
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
const shapeOffsetX = ext.left; // Add left extension to maintain relative position
const shapeOffsetY = ext.top; // Add top extension to maintain relative position
// Create a clipping mask using the shape with extension offset
ctx.globalCompositeOperation = 'destination-in';
ctx.beginPath();
ctx.moveTo(shape.points[0].x + shapeOffsetX, shape.points[0].y + shapeOffsetY);
for (let i = 1; i < shape.points.length; i++) {
ctx.lineTo(shape.points[i].x + shapeOffsetX, shape.points[i].y + shapeOffsetY);
}
ctx.closePath();
ctx.fill();
// Create a new image from the clipped canvas
return await createImageFromSource(canvas.toDataURL());
}
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import { createCanvas } from "./utils/CommonUtils.js";
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
const log = createModuleLogger('CanvasLayersPanel'); const log = createModuleLogger('CanvasLayersPanel');
export class CanvasLayersPanel { export class CanvasLayersPanel {
constructor(canvas) { constructor(canvas) {
@@ -14,17 +17,96 @@ export class CanvasLayersPanel {
this.handleDragOver = this.handleDragOver.bind(this); this.handleDragOver = this.handleDragOver.bind(this);
this.handleDragEnd = this.handleDragEnd.bind(this); this.handleDragEnd = this.handleDragEnd.bind(this);
this.handleDrop = this.handleDrop.bind(this); this.handleDrop = this.handleDrop.bind(this);
// Preload icons
this.initializeIcons();
// Load CSS for layers panel
addStylesheet(getUrl('./css/layers_panel.css'));
log.info('CanvasLayersPanel initialized'); log.info('CanvasLayersPanel initialized');
} }
async initializeIcons() {
try {
await iconLoader.preloadToolIcons();
log.debug('Icons preloaded successfully');
}
catch (error) {
log.warn('Failed to preload icons, using fallbacks:', error);
}
}
createIconElement(toolName, size = 16) {
const iconContainer = document.createElement('div');
iconContainer.className = 'icon-container';
iconContainer.style.width = `${size}px`;
iconContainer.style.height = `${size}px`;
const icon = iconLoader.getIcon(toolName);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode();
img.style.width = `${size}px`;
img.style.height = `${size}px`;
iconContainer.appendChild(img);
}
else if (icon instanceof HTMLCanvasElement) {
const { canvas, ctx } = createCanvas(size, size);
if (ctx) {
ctx.drawImage(icon, 0, 0, size, size);
}
iconContainer.appendChild(canvas);
}
}
else {
// Fallback text
iconContainer.classList.add('fallback-text');
iconContainer.textContent = toolName.charAt(0).toUpperCase();
iconContainer.style.fontSize = `${size * 0.6}px`;
}
return iconContainer;
}
createVisibilityIcon(isVisible) {
if (isVisible) {
return this.createIconElement(LAYERFORGE_TOOLS.VISIBILITY, 16);
}
else {
// Create a "hidden" version of the visibility icon
const iconContainer = document.createElement('div');
iconContainer.className = 'icon-container visibility-hidden';
iconContainer.style.width = '16px';
iconContainer.style.height = '16px';
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode();
img.style.width = '16px';
img.style.height = '16px';
iconContainer.appendChild(img);
}
else if (icon instanceof HTMLCanvasElement) {
const { canvas, ctx } = createCanvas(16, 16);
if (ctx) {
ctx.globalAlpha = 0.3;
ctx.drawImage(icon, 0, 0, 16, 16);
}
iconContainer.appendChild(canvas);
}
}
else {
// Fallback
iconContainer.classList.add('fallback-text');
iconContainer.textContent = 'H';
iconContainer.style.fontSize = '10px';
}
return iconContainer;
}
}
createPanelStructure() { createPanelStructure() {
this.container = document.createElement('div'); this.container = document.createElement('div');
this.container.className = 'layers-panel'; this.container.className = 'layers-panel';
this.container.tabIndex = 0; // Umożliwia fokus na panelu this.container.tabIndex = 0; // Umożliwia fokus na panelu
this.container.innerHTML = ` this.container.innerHTML = `
<div class="layers-panel-header"> <div class="layers-panel-header">
<div class="master-visibility-toggle" title="Toggle all layers visibility"></div>
<span class="layers-panel-title">Layers</span> <span class="layers-panel-title">Layers</span>
<div class="layers-panel-controls"> <div class="layers-panel-controls">
<button class="layers-btn" id="delete-layer-btn" title="Delete layer">🗑</button> <button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
</div> </div>
</div> </div>
<div class="layers-container" id="layers-container"> <div class="layers-container" id="layers-container">
@@ -32,217 +114,116 @@ export class CanvasLayersPanel {
</div> </div>
`; `;
this.layersContainer = this.container.querySelector('#layers-container'); this.layersContainer = this.container.querySelector('#layers-container');
this.injectStyles();
// Setup event listeners dla przycisków // Setup event listeners dla przycisków
this.setupControlButtons(); this.setupControlButtons();
this.setupMasterVisibilityToggle();
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu // Dodaj listener dla klawiatury, aby usuwanie działało z panelu
this.container.addEventListener('keydown', (e) => { this.container.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.deleteSelectedLayers(); this.deleteSelectedLayers();
return;
}
// Handle Ctrl+C/V for layer copy/paste when panel has focus
if (e.ctrlKey || e.metaKey) {
if (e.key.toLowerCase() === 'c') {
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
e.preventDefault();
e.stopPropagation();
this.canvas.canvasLayers.copySelectedLayers();
log.info('Layers copied from panel');
}
}
else if (e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.pasteLayers();
log.info('Layers pasted from panel');
}
}
} }
}); });
log.debug('Panel structure created'); log.debug('Panel structure created');
return this.container; return this.container;
} }
injectStyles() {
const styleId = 'layers-panel-styles';
if (document.getElementById(styleId)) {
return; // Style już istnieją
}
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.layers-panel {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 8px;
height: 100%;
overflow: hidden;
font-family: Arial, sans-serif;
font-size: 12px;
color: #ffffff;
user-select: none;
display: flex;
flex-direction: column;
}
.layers-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid #3a3a3a;
margin-bottom: 8px;
}
.layers-panel-title {
font-weight: bold;
color: #ffffff;
}
.layers-panel-controls {
display: flex;
gap: 4px;
}
.layers-btn {
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #ffffff;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.layers-btn:hover {
background: #4a4a4a;
}
.layers-btn:active {
background: #5a5a5a;
}
.layers-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.layer-row {
display: flex;
align-items: center;
padding: 6px 4px;
margin-bottom: 2px;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.15s ease;
position: relative;
gap: 6px;
}
.layer-row:hover {
background: rgba(255, 255, 255, 0.05);
}
.layer-row.selected {
background: #2d5aa0 !important;
box-shadow: inset 0 0 0 1px #4a7bc8;
}
.layer-row.dragging {
opacity: 0.6;
}
.layer-thumbnail {
width: 48px;
height: 48px;
border: 1px solid #4a4a4a;
border-radius: 2px;
background: transparent;
position: relative;
flex-shrink: 0;
overflow: hidden;
}
.layer-thumbnail canvas {
width: 100%;
height: 100%;
display: block;
}
.layer-thumbnail::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(45deg, #555 25%, transparent 25%),
linear-gradient(-45deg, #555 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #555 75%),
linear-gradient(-45deg, transparent 75%, #555 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
z-index: 1;
}
.layer-thumbnail canvas {
position: relative;
z-index: 2;
}
.layer-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 4px;
border-radius: 2px;
color: #ffffff;
}
.layer-name.editing {
background: #4a4a4a;
border: 1px solid #6a6a6a;
outline: none;
color: #ffffff;
}
.layer-name input {
background: transparent;
border: none;
color: #ffffff;
font-size: 12px;
width: 100%;
outline: none;
}
.drag-insertion-line {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: #4a7bc8;
border-radius: 1px;
z-index: 1000;
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
}
.layers-container::-webkit-scrollbar {
width: 6px;
}
.layers-container::-webkit-scrollbar-track {
background: #2a2a2a;
}
.layers-container::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 3px;
}
.layers-container::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}
`;
document.head.appendChild(style);
log.debug('Styles injected');
}
setupControlButtons() { setupControlButtons() {
if (!this.container) if (!this.container)
return; return;
const deleteBtn = this.container.querySelector('#delete-layer-btn'); const deleteBtn = this.container.querySelector('#delete-layer-btn');
// Add delete icon to button
if (deleteBtn) {
const deleteIcon = this.createIconElement(LAYERFORGE_TOOLS.DELETE, 16);
deleteBtn.appendChild(deleteIcon);
}
deleteBtn?.addEventListener('click', () => { deleteBtn?.addEventListener('click', () => {
log.info('Delete layer button clicked'); log.info('Delete layer button clicked');
this.deleteSelectedLayers(); this.deleteSelectedLayers();
}); });
// Initial button state update
this.updateButtonStates();
}
setupMasterVisibilityToggle() {
if (!this.container)
return;
const toggleContainer = this.container.querySelector('.master-visibility-toggle');
if (!toggleContainer)
return;
const updateToggleState = () => {
const total = this.canvas.layers.length;
const visibleCount = this.canvas.layers.filter(l => l.visible).length;
toggleContainer.innerHTML = '';
const checkboxContainer = document.createElement('div');
checkboxContainer.className = 'checkbox-container';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'master-visibility-checkbox';
const customCheckbox = document.createElement('span');
customCheckbox.className = 'custom-checkbox';
checkboxContainer.appendChild(checkbox);
checkboxContainer.appendChild(customCheckbox);
if (visibleCount === 0) {
checkbox.checked = false;
checkbox.indeterminate = false;
customCheckbox.classList.remove('checked', 'indeterminate');
}
else if (visibleCount === total) {
checkbox.checked = true;
checkbox.indeterminate = false;
customCheckbox.classList.add('checked');
customCheckbox.classList.remove('indeterminate');
}
else {
checkbox.checked = false;
checkbox.indeterminate = true;
customCheckbox.classList.add('indeterminate');
customCheckbox.classList.remove('checked');
}
checkboxContainer.addEventListener('click', (e) => {
e.stopPropagation();
let newVisible;
if (checkbox.indeterminate) {
newVisible = false; // hide all when mixed
}
else if (checkbox.checked) {
newVisible = false; // toggle to hide all
}
else {
newVisible = true; // toggle to show all
}
this.canvas.layers.forEach(layer => {
layer.visible = newVisible;
});
this.canvas.render();
this.canvas.requestSaveState();
updateToggleState();
this.renderLayers();
});
toggleContainer.appendChild(checkboxContainer);
};
updateToggleState();
this._updateMasterVisibilityToggle = updateToggleState;
} }
renderLayers() { renderLayers() {
if (!this.layersContainer) { if (!this.layersContainer) {
@@ -260,6 +241,8 @@ export class CanvasLayersPanel {
if (this.layersContainer) if (this.layersContainer)
this.layersContainer.appendChild(layerElement); this.layersContainer.appendChild(layerElement);
}); });
if (this._updateMasterVisibilityToggle)
this._updateMasterVisibilityToggle();
log.debug(`Rendered ${sortedLayers.length} layers`); log.debug(`Rendered ${sortedLayers.length} layers`);
} }
createLayerElement(layer, index) { createLayerElement(layer, index) {
@@ -280,9 +263,16 @@ export class CanvasLayersPanel {
layer.name = this.ensureUniqueName(layer.name, layer); layer.name = this.ensureUniqueName(layer.name, layer);
} }
layerRow.innerHTML = ` layerRow.innerHTML = `
<div class="layer-visibility-toggle" data-layer-index="${index}" title="Toggle layer visibility"></div>
<div class="layer-thumbnail" data-layer-index="${index}"></div> <div class="layer-thumbnail" data-layer-index="${index}"></div>
<span class="layer-name" data-layer-index="${index}">${layer.name}</span> <span class="layer-name" data-layer-index="${index}">${layer.name}</span>
`; `;
// Add visibility icon
const visibilityToggle = layerRow.querySelector('.layer-visibility-toggle');
if (visibilityToggle) {
const visibilityIcon = this.createVisibilityIcon(layer.visible);
visibilityToggle.appendChild(visibilityIcon);
}
const thumbnailContainer = layerRow.querySelector('.layer-thumbnail'); const thumbnailContainer = layerRow.querySelector('.layer-thumbnail');
if (thumbnailContainer) { if (thumbnailContainer) {
this.generateThumbnail(layer, thumbnailContainer); this.generateThumbnail(layer, thumbnailContainer);
@@ -295,12 +285,9 @@ export class CanvasLayersPanel {
thumbnailContainer.style.background = '#4a4a4a'; thumbnailContainer.style.background = '#4a4a4a';
return; return;
} }
const canvas = document.createElement('canvas'); const { canvas, ctx } = createCanvas(48, 48, '2d', { willReadFrequently: true });
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) if (!ctx)
return; return;
canvas.width = 48;
canvas.height = 48;
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height); const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
const scaledWidth = layer.image.width * scale; const scaledWidth = layer.image.width * scale;
const scaledHeight = layer.image.height * scale; const scaledHeight = layer.image.height * scale;
@@ -320,6 +307,17 @@ export class CanvasLayersPanel {
} }
this.handleLayerClick(e, layer, index); this.handleLayerClick(e, layer, index);
}); });
// --- PRAWY PRZYCISK: ODJAZNACZ LAYER ---
layerRow.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
this.canvas.updateSelection(newSelection);
this.updateSelectionAppearance();
this.updateButtonStates();
}
});
layerRow.addEventListener('dblclick', (e) => { layerRow.addEventListener('dblclick', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -328,6 +326,15 @@ export class CanvasLayersPanel {
this.startEditingLayerName(nameElement, layer); this.startEditingLayerName(nameElement, layer);
} }
}); });
// Add visibility toggle event listener
const visibilityToggle = layerRow.querySelector('.layer-visibility-toggle');
if (visibilityToggle) {
visibilityToggle.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.toggleLayerVisibility(layer);
});
}
layerRow.addEventListener('dragstart', (e) => this.handleDragStart(e, layer, index)); layerRow.addEventListener('dragstart', (e) => this.handleDragStart(e, layer, index));
layerRow.addEventListener('dragover', this.handleDragOver.bind(this)); layerRow.addEventListener('dragover', this.handleDragOver.bind(this));
layerRow.addEventListener('dragend', this.handleDragEnd.bind(this)); layerRow.addEventListener('dragend', this.handleDragEnd.bind(this));
@@ -341,6 +348,9 @@ export class CanvasLayersPanel {
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM // Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates();
// Focus the canvas so keyboard shortcuts (like Ctrl+C/V) work for layer operations
this.canvas.canvas.focus();
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
} }
startEditingLayerName(nameElement, layer) { startEditingLayerName(nameElement, layer) {
@@ -401,6 +411,19 @@ export class CanvasLayersPanel {
} while (existingNames.includes(uniqueName)); } while (existingNames.includes(uniqueName));
return uniqueName; return uniqueName;
} }
toggleLayerVisibility(layer) {
layer.visible = !layer.visible;
// If layer became invisible and is selected, deselect it
if (!layer.visible && this.canvas.canvasSelection.selectedLayers.includes(layer)) {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l) => l !== layer);
this.canvas.updateSelection(newSelection);
}
this.canvas.render();
this.canvas.requestSaveState();
// Update the eye icon in the panel
this.renderLayers();
log.info(`Layer "${layer.name}" visibility toggled to: ${layer.visible}`);
}
deleteSelectedLayers() { deleteSelectedLayers() {
if (this.canvas.canvasSelection.selectedLayers.length === 0) { if (this.canvas.canvasSelection.selectedLayers.length === 0) {
log.debug('No layers selected for deletion'); log.debug('No layers selected for deletion');
@@ -508,12 +531,29 @@ export class CanvasLayersPanel {
} }
}); });
} }
/**
* Aktualizuje stan przycisków w zależności od zaznaczenia warstw
*/
updateButtonStates() {
if (!this.container)
return;
const deleteBtn = this.container.querySelector('#delete-layer-btn');
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
if (deleteBtn) {
deleteBtn.disabled = !hasSelectedLayers;
deleteBtn.title = hasSelectedLayers
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
: 'No layers selected';
}
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
}
/** /**
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz). * Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd. * Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
*/ */
onSelectionChanged() { onSelectionChanged() {
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates();
} }
destroy() { destroy() {
if (this.container && this.container.parentNode) { if (this.container && this.container.parentNode) {

View File

@@ -7,6 +7,60 @@ export class CanvasRenderer {
this.lastRenderTime = 0; this.lastRenderTime = 0;
this.renderInterval = 1000 / 60; this.renderInterval = 1000 / 60;
this.isDirty = false; this.isDirty = false;
// Initialize overlay canvases
this.initOverlay();
this.initStrokeOverlay();
}
/**
* Helper function to draw text with background at world coordinates
* @param ctx Canvas context
* @param text Text to display
* @param worldX World X coordinate
* @param worldY World Y coordinate
* @param options Optional styling options
*/
drawTextWithBackground(ctx, text, worldX, worldY, options = {}) {
const { font = "14px sans-serif", textColor = "white", backgroundColor = "rgba(0, 0, 0, 0.7)", padding = 10, lineHeight = 18 } = options;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
const screenX = (worldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (worldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
ctx.font = font;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const lines = text.split('\n');
const textMetrics = lines.map(line => ctx.measureText(line));
const bgWidth = Math.max(...textMetrics.map(m => m.width)) + padding;
const bgHeight = lines.length * lineHeight + 4;
ctx.fillStyle = backgroundColor;
ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight);
ctx.fillStyle = textColor;
lines.forEach((line, index) => {
const yPos = screenY - (bgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2;
ctx.fillText(line, screenX, yPos);
});
ctx.restore();
}
/**
* Helper function to draw rectangle with stroke style
* @param ctx Canvas context
* @param rect Rectangle bounds {x, y, width, height}
* @param options Styling options
*/
drawStyledRect(ctx, rect, options = {}) {
const { strokeStyle = "rgba(255, 255, 255, 0.8)", lineWidth = 2, dashPattern = null } = options;
ctx.save();
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = lineWidth / this.canvas.viewport.zoom;
if (dashPattern) {
const scaledDash = dashPattern.map((d) => d / this.canvas.viewport.zoom);
ctx.setLineDash(scaledDash);
}
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
if (dashPattern) {
ctx.setLineDash([]);
}
ctx.restore();
} }
render() { render() {
if (this.renderAnimationFrame) { if (this.renderAnimationFrame) {
@@ -44,54 +98,69 @@ export class CanvasRenderer {
ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom); ctx.scale(this.canvas.viewport.zoom, this.canvas.viewport.zoom);
ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y); ctx.translate(-this.canvas.viewport.x, -this.canvas.viewport.y);
this.drawGrid(ctx); this.drawGrid(ctx);
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex); // Use CanvasLayers to draw layers with proper blend area support
sortedLayers.forEach(layer => { this.canvas.canvasLayers.drawLayersToContext(ctx, this.canvas.layers);
if (!layer.image) // Draw mask AFTER layers but BEFORE all preview outlines
return;
ctx.save();
const currentTransform = ctx.getTransform();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = layer.blendMode || 'normal';
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
ctx.setTransform(currentTransform);
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
ctx.translate(centerX, centerY);
ctx.rotate(layer.rotation * Math.PI / 180);
const scaleH = layer.flipH ? -1 : 1;
const scaleV = layer.flipV ? -1 : 1;
if (layer.flipH || layer.flipV) {
ctx.scale(scaleH, scaleV);
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
if (layer.mask) {
}
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
this.drawSelectionFrame(ctx, layer);
}
ctx.restore();
});
this.drawCanvasOutline(ctx);
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
const maskImage = this.canvas.maskTool.getMask(); const maskImage = this.canvas.maskTool.getMask();
if (maskImage && this.canvas.maskTool.isOverlayVisible) { if (maskImage && this.canvas.maskTool.isOverlayVisible) {
ctx.save(); ctx.save();
if (this.canvas.maskTool.isActive) { if (this.canvas.maskTool.isActive) {
// In draw mask mode, use the previewOpacity value from the slider
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 0.5; ctx.globalAlpha = this.canvas.maskTool.previewOpacity;
} }
else { else {
// When not in draw mask mode, show mask at full opacity
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1.0; ctx.globalAlpha = 1.0;
} }
ctx.drawImage(maskImage, this.canvas.maskTool.x, this.canvas.maskTool.y); // Renderuj maskę w jej pozycji światowej (bez przesunięcia względem bounds)
const maskWorldX = this.canvas.maskTool.x;
const maskWorldY = this.canvas.maskTool.y;
ctx.drawImage(maskImage, maskWorldX, maskWorldY);
ctx.globalAlpha = 1.0; ctx.globalAlpha = 1.0;
ctx.restore(); ctx.restore();
} }
// Draw selection frames for selected layers
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
sortedLayers.forEach(layer => {
if (!layer.image || !layer.visible)
return;
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
ctx.save();
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
ctx.translate(centerX, centerY);
ctx.rotate(layer.rotation * Math.PI / 180);
const scaleH = layer.flipH ? -1 : 1;
const scaleV = layer.flipV ? -1 : 1;
if (layer.flipH || layer.flipV) {
ctx.scale(scaleH, scaleV);
}
this.drawSelectionFrame(ctx, layer);
ctx.restore();
}
});
// Draw grab icons for selected layers when hovering
if (this.canvas.canvasInteractions.interaction.hoveringGrabIcon) {
this.drawGrabIcons(ctx);
}
this.drawCanvasOutline(ctx);
this.drawOutputAreaExtensionPreview(ctx); // Draw extension preview
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
this.renderInteractionElements(ctx); this.renderInteractionElements(ctx);
this.canvas.shapeTool.render(ctx);
this.drawMaskAreaBounds(ctx); // Draw mask area bounds when mask tool is active
this.renderOutputAreaTransformHandles(ctx); // Draw output area transform handles
this.renderLayerInfo(ctx); this.renderLayerInfo(ctx);
// Update custom shape menu position and visibility
if (this.canvas.outputAreaShape) {
this.canvas.customShapeMenu.show();
this.canvas.customShapeMenu.updateScreenPosition();
}
else {
this.canvas.customShapeMenu.hide();
}
ctx.restore(); ctx.restore();
if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width || if (this.canvas.canvas.width !== this.canvas.offscreenCanvas.width ||
this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) { this.canvas.canvas.height !== this.canvas.offscreenCanvas.height) {
@@ -99,6 +168,11 @@ export class CanvasRenderer {
this.canvas.canvas.height = this.canvas.offscreenCanvas.height; this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
} }
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0); this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
// Ensure overlay canvases are in DOM and properly sized
this.addOverlayToDOM();
this.updateOverlaySize();
this.addStrokeOverlayToDOM();
this.updateStrokeOverlaySize();
// Update Batch Preview UI positions // Update Batch Preview UI positions
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) { if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach((manager) => { this.canvas.batchPreviewManagers.forEach((manager) => {
@@ -110,67 +184,39 @@ export class CanvasRenderer {
const interaction = this.canvas.interaction; const interaction = this.canvas.interaction;
if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) { if (interaction.mode === 'resizingCanvas' && interaction.canvasResizeRect) {
const rect = interaction.canvasResizeRect; const rect = interaction.canvasResizeRect;
ctx.save(); this.drawStyledRect(ctx, rect, {
ctx.strokeStyle = 'rgba(0, 255, 0, 0.8)'; strokeStyle: 'rgba(0, 255, 0, 0.8)',
ctx.lineWidth = 2 / this.canvas.viewport.zoom; lineWidth: 2,
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]); dashPattern: [8, 4]
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); });
ctx.setLineDash([]);
ctx.restore();
if (rect.width > 0 && rect.height > 0) { if (rect.width > 0 && rect.height > 0) {
const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`; const text = `${Math.round(rect.width)}x${Math.round(rect.height)}`;
const textWorldX = rect.x + rect.width / 2; const textWorldX = rect.x + rect.width / 2;
const textWorldY = rect.y + rect.height + (20 / this.canvas.viewport.zoom); const textWorldY = rect.y + rect.height + (20 / this.canvas.viewport.zoom);
ctx.save(); this.drawTextWithBackground(ctx, text, textWorldX, textWorldY, {
ctx.setTransform(1, 0, 0, 1, 0, 0); backgroundColor: "rgba(0, 128, 0, 0.7)"
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; });
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
ctx.font = "14px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 10;
const bgHeight = 22;
ctx.fillStyle = "rgba(0, 128, 0, 0.7)";
ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight);
ctx.fillStyle = "white";
ctx.fillText(text, screenX, screenY);
ctx.restore();
} }
} }
if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) { if (interaction.mode === 'movingCanvas' && interaction.canvasMoveRect) {
const rect = interaction.canvasMoveRect; const rect = interaction.canvasMoveRect;
ctx.save(); this.drawStyledRect(ctx, rect, {
ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)'; strokeStyle: 'rgba(0, 150, 255, 0.8)',
ctx.lineWidth = 2 / this.canvas.viewport.zoom; lineWidth: 2,
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]); dashPattern: [10, 5]
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); });
ctx.setLineDash([]);
ctx.restore();
const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`; const text = `(${Math.round(rect.x)}, ${Math.round(rect.y)})`;
const textWorldX = rect.x + rect.width / 2; const textWorldX = rect.x + rect.width / 2;
const textWorldY = rect.y - (20 / this.canvas.viewport.zoom); const textWorldY = rect.y - (20 / this.canvas.viewport.zoom);
ctx.save(); this.drawTextWithBackground(ctx, text, textWorldX, textWorldY, {
ctx.setTransform(1, 0, 0, 1, 0, 0); backgroundColor: "rgba(0, 100, 170, 0.7)"
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom; });
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
ctx.font = "14px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 10;
const bgHeight = 22;
ctx.fillStyle = "rgba(0, 100, 170, 0.7)";
ctx.fillRect(screenX - bgWidth / 2, screenY - bgHeight / 2, bgWidth, bgHeight);
ctx.fillStyle = "white";
ctx.fillText(text, screenX, screenY);
ctx.restore();
} }
} }
renderLayerInfo(ctx) { renderLayerInfo(ctx) {
if (this.canvas.canvasSelection.selectedLayer) { if (this.canvas.canvasSelection.selectedLayer) {
this.canvas.canvasSelection.selectedLayers.forEach((layer) => { this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
if (!layer.image) if (!layer.image || !layer.visible)
return; return;
const layerIndex = this.canvas.layers.indexOf(layer); const layerIndex = this.canvas.layers.indexOf(layer);
const currentWidth = Math.round(layer.width); const currentWidth = Math.round(layer.width);
@@ -206,26 +252,7 @@ export class CanvasRenderer {
const padding = 20 / this.canvas.viewport.zoom; const padding = 20 / this.canvas.viewport.zoom;
const textWorldX = (minX + maxX) / 2; const textWorldX = (minX + maxX) / 2;
const textWorldY = maxY + padding; const textWorldY = maxY + padding;
ctx.save(); this.drawTextWithBackground(ctx, text, textWorldX, textWorldY);
ctx.setTransform(1, 0, 0, 1, 0, 0);
const screenX = (textWorldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (textWorldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
ctx.font = "14px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
const lines = text.split('\n');
const textMetrics = lines.map(line => ctx.measureText(line));
const textBgWidth = Math.max(...textMetrics.map(m => m.width)) + 10;
const lineHeight = 18;
const textBgHeight = lines.length * lineHeight + 4;
ctx.fillStyle = "rgba(0, 0, 0, 0.7)";
ctx.fillRect(screenX - textBgWidth / 2, screenY - textBgHeight / 2, textBgWidth, textBgHeight);
ctx.fillStyle = "white";
lines.forEach((line, index) => {
const yPos = screenY - (textBgHeight / 2) + (lineHeight / 2) + (index * lineHeight) + 2;
ctx.fillText(line, screenX, yPos);
});
ctx.restore();
}); });
} }
} }
@@ -249,69 +276,650 @@ export class CanvasRenderer {
} }
ctx.stroke(); ctx.stroke();
} }
/**
* Check if custom shape overlaps with any active batch preview areas
*/
isCustomShapeOverlappingWithBatchAreas() {
if (!this.canvas.outputAreaShape || !this.canvas.batchPreviewManagers || this.canvas.batchPreviewManagers.length === 0) {
return false;
}
// Get custom shape bounds
const bounds = this.canvas.outputAreaBounds;
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
const shapeOffsetX = bounds.x + ext.left;
const shapeOffsetY = bounds.y + ext.top;
const shape = this.canvas.outputAreaShape;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
// Calculate shape bounding box
shape.points.forEach((point) => {
const worldX = shapeOffsetX + point.x;
const worldY = shapeOffsetY + point.y;
minX = Math.min(minX, worldX);
maxX = Math.max(maxX, worldX);
minY = Math.min(minY, worldY);
maxY = Math.max(maxY, worldY);
});
const shapeBounds = { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
// Check overlap with each active batch preview area
for (const manager of this.canvas.batchPreviewManagers) {
if (manager.generationArea) {
const area = manager.generationArea;
// Check if rectangles overlap
if (!(shapeBounds.x + shapeBounds.width < area.x ||
area.x + area.width < shapeBounds.x ||
shapeBounds.y + shapeBounds.height < area.y ||
area.y + area.height < shapeBounds.y)) {
return true; // Overlap detected
}
}
}
return false;
}
drawCanvasOutline(ctx) { drawCanvasOutline(ctx) {
ctx.beginPath(); ctx.beginPath();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)'; ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom; ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]); ctx.setLineDash([10 / this.canvas.viewport.zoom, 5 / this.canvas.viewport.zoom]);
ctx.rect(0, 0, this.canvas.width, this.canvas.height); // Rysuj outline w pozycji outputAreaBounds
const bounds = this.canvas.outputAreaBounds;
ctx.rect(bounds.x, bounds.y, bounds.width, bounds.height);
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
// Display dimensions under outputAreaBounds
const dimensionsText = `${Math.round(bounds.width)}x${Math.round(bounds.height)}`;
const textWorldX = bounds.x + bounds.width / 2;
const textWorldY = bounds.y + bounds.height + (20 / this.canvas.viewport.zoom);
this.drawTextWithBackground(ctx, dimensionsText, textWorldX, textWorldY);
// Only draw custom shape if it doesn't overlap with batch preview areas
if (this.canvas.outputAreaShape && !this.isCustomShapeOverlappingWithBatchAreas()) {
ctx.save();
ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([]);
const shape = this.canvas.outputAreaShape;
const bounds = this.canvas.outputAreaBounds;
// Calculate custom shape position accounting for extensions
// Custom shape should maintain its relative position within the original canvas area
const ext = this.canvas.outputAreaExtensionEnabled ? this.canvas.outputAreaExtensions : { top: 0, bottom: 0, left: 0, right: 0 };
const shapeOffsetX = bounds.x + ext.left; // Add left extension to maintain relative position
const shapeOffsetY = bounds.y + ext.top; // Add top extension to maintain relative position
ctx.beginPath();
// Render custom shape with extension offset to maintain relative position
ctx.moveTo(shapeOffsetX + shape.points[0].x, shapeOffsetY + shape.points[0].y);
for (let i = 1; i < shape.points.length; i++) {
ctx.lineTo(shapeOffsetX + shape.points[i].x, shapeOffsetY + shape.points[i].y);
}
ctx.closePath();
ctx.stroke();
ctx.restore();
}
}
/**
* Sprawdza czy punkt w świecie jest przykryty przez warstwy o wyższym zIndex
*/
isPointCoveredByHigherLayers(worldX, worldY, currentLayer) {
// Znajdź warstwy o wyższym zIndex niż aktualny layer
const higherLayers = this.canvas.layers.filter((l) => l.zIndex > currentLayer.zIndex && l.visible && l !== currentLayer);
for (const higherLayer of higherLayers) {
// Sprawdź czy punkt jest wewnątrz tego layera
const centerX = higherLayer.x + higherLayer.width / 2;
const centerY = higherLayer.y + higherLayer.height / 2;
// Przekształć punkt do lokalnego układu współrzędnych layera
const dx = worldX - centerX;
const dy = worldY - centerY;
const rad = -higherLayer.rotation * Math.PI / 180;
const rotatedX = dx * Math.cos(rad) - dy * Math.sin(rad);
const rotatedY = dx * Math.sin(rad) + dy * Math.cos(rad);
// Sprawdź czy punkt jest wewnątrz prostokąta layera
if (Math.abs(rotatedX) <= higherLayer.width / 2 &&
Math.abs(rotatedY) <= higherLayer.height / 2) {
// Sprawdź przezroczystość layera - jeśli ma znaczącą nieprzezroczystość, uznaj za przykryty
if (higherLayer.opacity > 0.1) {
return true;
}
}
}
return false;
}
/**
* Rysuje linię z automatycznym przełączaniem między ciągłą a przerywaną w zależności od przykrycia
*/
drawAdaptiveLine(ctx, startX, startY, endX, endY, layer) {
const segmentLength = 8 / this.canvas.viewport.zoom; // Długość segmentu do sprawdzania
const dashLength = 6 / this.canvas.viewport.zoom;
const gapLength = 4 / this.canvas.viewport.zoom;
const totalLength = Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
const segments = Math.max(1, Math.floor(totalLength / segmentLength));
let currentX = startX;
let currentY = startY;
let lastCovered = null;
let segmentStart = { x: startX, y: startY };
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const x = startX + (endX - startX) * t;
const y = startY + (endY - startY) * t;
// Przekształć współrzędne lokalne na światowe
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
const rad = layer.rotation * Math.PI / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const worldX = centerX + (x * cos - y * sin);
const worldY = centerY + (x * sin + y * cos);
const isCovered = this.isPointCoveredByHigherLayers(worldX, worldY, layer);
// Jeśli stan się zmienił lub to ostatni segment, narysuj poprzedni odcinek
if (lastCovered !== null && (lastCovered !== isCovered || i === segments)) {
ctx.beginPath();
ctx.moveTo(segmentStart.x, segmentStart.y);
ctx.lineTo(currentX, currentY);
if (lastCovered) {
// Przykryty - linia przerywana
ctx.setLineDash([dashLength, gapLength]);
}
else {
// Nie przykryty - linia ciągła
ctx.setLineDash([]);
}
ctx.stroke();
segmentStart = { x: currentX, y: currentY };
}
lastCovered = isCovered;
currentX = x;
currentY = y;
}
// Narysuj ostatni segment jeśli potrzeba
if (lastCovered !== null) {
ctx.beginPath();
ctx.moveTo(segmentStart.x, segmentStart.y);
ctx.lineTo(endX, endY);
if (lastCovered) {
ctx.setLineDash([dashLength, gapLength]);
}
else {
ctx.setLineDash([]);
}
ctx.stroke();
}
// Resetuj dash pattern
ctx.setLineDash([]);
} }
drawSelectionFrame(ctx, layer) { drawSelectionFrame(ctx, layer) {
const lineWidth = 2 / this.canvas.viewport.zoom; const lineWidth = 2 / this.canvas.viewport.zoom;
const handleRadius = 5 / this.canvas.viewport.zoom; const handleRadius = 5 / this.canvas.viewport.zoom;
ctx.strokeStyle = '#00ff00'; if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
ctx.lineWidth = lineWidth; // --- CROP MODE ---
ctx.beginPath(); ctx.lineWidth = lineWidth;
ctx.rect(-layer.width / 2, -layer.height / 2, layer.width, layer.height); // 1. Draw dashed blue line for the full transform frame (the "original size" container)
ctx.stroke(); ctx.strokeStyle = '#007bff';
ctx.beginPath(); ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
ctx.moveTo(0, -layer.height / 2); ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom); ctx.setLineDash([]);
ctx.stroke(); // 2. Draw solid blue line for the crop bounds
const layerScaleX = layer.width / layer.originalWidth;
const layerScaleY = layer.height / layer.originalHeight;
const s = layer.cropBounds;
const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
const cropRectW = s.width * layerScaleX;
const cropRectH = s.height * layerScaleY;
ctx.strokeStyle = '#007bff'; // Solid blue
this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY + cropRectH, cropRectX, cropRectY + cropRectH, layer); // Bottom
this.drawAdaptiveLine(ctx, cropRectX, cropRectY + cropRectH, cropRectX, cropRectY, layer); // Left
}
else {
// --- TRANSFORM MODE ---
ctx.strokeStyle = '#00ff00'; // Green
ctx.lineWidth = lineWidth;
const halfW = layer.width / 2;
const halfH = layer.height / 2;
// Draw adaptive solid green line for transform frame
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
// Draw line to rotation handle
ctx.setLineDash([]);
ctx.beginPath();
const startY = layer.flipV ? halfH : -halfH;
const endY = startY + (layer.flipV ? 1 : -1) * (20 / this.canvas.viewport.zoom);
ctx.moveTo(0, startY);
ctx.lineTo(0, endY);
ctx.stroke();
}
// --- DRAW HANDLES (Unified Logic) ---
const handles = this.canvas.canvasLayers.getHandles(layer); 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;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
for (const key in handles) { for (const key in handles) {
// Skip rotation handle in crop mode
if (layer.cropMode && key === 'rot')
continue;
const point = handles[key]; const point = handles[key];
ctx.beginPath(); // The handle position is already in world space.
const localX = point.x - (layer.x + layer.width / 2); // We need to convert it to the layer's local, un-rotated space.
const localY = point.y - (layer.y + layer.height / 2); const dx = point.x - centerX;
const dy = point.y - centerY;
// "Un-rotate" the position to get it in the layer's local, un-rotated space
const rad = -layer.rotation * Math.PI / 180; const rad = -layer.rotation * Math.PI / 180;
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad); const cos = Math.cos(rad);
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad); const sin = Math.sin(rad);
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2); const localX = dx * cos - dy * sin;
const localY = dx * sin + dy * cos;
// The context is already flipped. We need to flip the coordinates
// to match the visual transformation, so the arc is drawn in the correct place.
const finalX = localX * (layer.flipH ? -1 : 1);
const finalY = localY * (layer.flipV ? -1 : 1);
ctx.beginPath();
ctx.arc(finalX, finalY, handleRadius, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
ctx.stroke(); ctx.stroke();
} }
} }
drawPendingGenerationAreas(ctx) { drawOutputAreaExtensionPreview(ctx) {
const areasToDraw = []; if (!this.canvas.outputAreaExtensionPreview) {
// 1. Get areas from active managers
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach((manager) => {
if (manager.generationArea) {
areasToDraw.push(manager.generationArea);
}
});
}
// 2. Get the area from the pending context (if it exists)
if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) {
areasToDraw.push(this.canvas.pendingBatchContext.outputArea);
}
if (areasToDraw.length === 0) {
return; return;
} }
// 3. Draw all collected areas // Calculate preview bounds based on original canvas size + preview extensions
areasToDraw.forEach(area => { const baseWidth = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.width : this.canvas.width;
const baseHeight = this.canvas.originalCanvasSize ? this.canvas.originalCanvasSize.height : this.canvas.height;
const ext = this.canvas.outputAreaExtensionPreview;
// Calculate preview bounds relative to original custom shape position, not (0,0)
const originalPos = this.canvas.originalOutputAreaPosition;
const previewBounds = {
x: originalPos.x - ext.left, // ✅ Względem oryginalnej pozycji custom shape
y: originalPos.y - ext.top, // ✅ Względem oryginalnej pozycji custom shape
width: baseWidth + ext.left + ext.right,
height: baseHeight + ext.top + ext.bottom
};
this.drawStyledRect(ctx, previewBounds, {
strokeStyle: 'rgba(255, 255, 0, 0.8)',
lineWidth: 3,
dashPattern: [8, 4]
});
}
drawPendingGenerationAreas(ctx) {
const pendingAreas = [];
// 1. Get all pending generation areas (from pendingBatchContext)
if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) {
pendingAreas.push(this.canvas.pendingBatchContext.outputArea);
}
// 2. Draw only those pending areas, które NIE mają aktywnego batch preview managera dla tego samego obszaru
const isAreaCoveredByBatch = (area) => {
if (!this.canvas.batchPreviewManagers)
return false;
return this.canvas.batchPreviewManagers.some((manager) => {
if (!manager.generationArea)
return false;
// Sprawdź czy obszary się pokrywają (prosty overlap AABB)
const a = area;
const b = manager.generationArea;
return !(a.x + a.width < b.x || b.x + b.width < a.x || a.y + a.height < b.y || b.y + b.height < a.y);
});
};
pendingAreas.forEach(area => {
if (!isAreaCoveredByBatch(area)) {
this.drawStyledRect(ctx, area, {
strokeStyle: 'rgba(0, 150, 255, 0.9)',
lineWidth: 3,
dashPattern: [12, 6]
});
}
});
}
drawMaskAreaBounds(ctx) {
// Only show mask area bounds when mask tool is active
if (!this.canvas.maskTool.isActive) {
return;
}
const maskTool = this.canvas.maskTool;
// Get mask canvas bounds in world coordinates
const maskBounds = {
x: maskTool.x,
y: maskTool.y,
width: maskTool.getMask().width,
height: maskTool.getMask().height
};
this.drawStyledRect(ctx, maskBounds, {
strokeStyle: 'rgba(255, 100, 100, 0.7)',
lineWidth: 2,
dashPattern: [6, 6]
});
// Add text label to show this is the mask drawing area
const textWorldX = maskBounds.x + maskBounds.width / 2;
const textWorldY = maskBounds.y - (10 / this.canvas.viewport.zoom);
this.drawTextWithBackground(ctx, "Mask Drawing Area", textWorldX, textWorldY, {
font: "12px sans-serif",
backgroundColor: "rgba(255, 100, 100, 0.8)",
padding: 8
});
}
/**
* Initialize overlay canvas for lightweight overlays like brush cursor
*/
initOverlay() {
// Setup overlay canvas to match main canvas
this.updateOverlaySize();
// Position overlay canvas on top of main canvas
this.canvas.overlayCanvas.style.position = 'absolute';
this.canvas.overlayCanvas.style.left = '0px';
this.canvas.overlayCanvas.style.top = '0px';
this.canvas.overlayCanvas.style.pointerEvents = 'none';
this.canvas.overlayCanvas.style.zIndex = '20'; // Above other overlays
// Add overlay to DOM when main canvas is added
this.addOverlayToDOM();
log.debug('Overlay canvas initialized');
}
/**
* Add overlay canvas to DOM if main canvas has a parent
*/
addOverlayToDOM() {
if (this.canvas.canvas.parentElement && !this.canvas.overlayCanvas.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.canvas.overlayCanvas);
log.debug('Overlay canvas added to DOM');
}
}
/**
* Update overlay canvas size to match main canvas
*/
updateOverlaySize() {
if (this.canvas.overlayCanvas.width !== this.canvas.canvas.clientWidth ||
this.canvas.overlayCanvas.height !== this.canvas.canvas.clientHeight) {
this.canvas.overlayCanvas.width = Math.max(1, this.canvas.canvas.clientWidth);
this.canvas.overlayCanvas.height = Math.max(1, this.canvas.canvas.clientHeight);
log.debug(`Overlay canvas resized to ${this.canvas.overlayCanvas.width}x${this.canvas.overlayCanvas.height}`);
}
}
/**
* Clear overlay canvas
*/
clearOverlay() {
this.canvas.overlayCtx.clearRect(0, 0, this.canvas.overlayCanvas.width, this.canvas.overlayCanvas.height);
}
/**
* Initialize a dedicated overlay for real-time mask stroke preview
*/
initStrokeOverlay() {
// Create canvas if not created yet
if (!this.strokeOverlayCanvas) {
this.strokeOverlayCanvas = document.createElement('canvas');
const ctx = this.strokeOverlayCanvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get 2D context for stroke overlay canvas');
}
this.strokeOverlayCtx = ctx;
}
// Size match main canvas
this.updateStrokeOverlaySize();
// Position above main canvas but below cursor overlay
this.strokeOverlayCanvas.style.position = 'absolute';
this.strokeOverlayCanvas.style.left = '1px';
this.strokeOverlayCanvas.style.top = '1px';
this.strokeOverlayCanvas.style.pointerEvents = 'none';
this.strokeOverlayCanvas.style.zIndex = '19'; // Below cursor overlay (20)
// Opacity is now controlled by MaskTool.previewOpacity
this.strokeOverlayCanvas.style.opacity = String(this.canvas.maskTool.previewOpacity || 0.5);
// Add to DOM
this.addStrokeOverlayToDOM();
log.debug('Stroke overlay canvas initialized');
}
/**
* Add stroke overlay canvas to DOM if needed
*/
addStrokeOverlayToDOM() {
if (this.canvas.canvas.parentElement && !this.strokeOverlayCanvas.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.strokeOverlayCanvas);
log.debug('Stroke overlay canvas added to DOM');
}
}
/**
* Ensure stroke overlay size matches main canvas
*/
updateStrokeOverlaySize() {
const w = Math.max(1, this.canvas.canvas.clientWidth);
const h = Math.max(1, this.canvas.canvas.clientHeight);
if (this.strokeOverlayCanvas.width !== w || this.strokeOverlayCanvas.height !== h) {
this.strokeOverlayCanvas.width = w;
this.strokeOverlayCanvas.height = h;
log.debug(`Stroke overlay resized to ${w}x${h}`);
}
}
/**
* Clear the stroke overlay
*/
clearMaskStrokeOverlay() {
if (!this.strokeOverlayCtx)
return;
this.strokeOverlayCtx.clearRect(0, 0, this.strokeOverlayCanvas.width, this.strokeOverlayCanvas.height);
}
/**
* Draw a preview stroke segment onto the stroke overlay in screen space
* Uses line drawing with gradient to match MaskTool's drawLineOnChunk exactly
*/
drawMaskStrokeSegment(startWorld, endWorld) {
// Ensure overlay is present and sized
this.updateStrokeOverlaySize();
const zoom = this.canvas.viewport.zoom;
const toScreen = (p) => ({
x: (p.x - this.canvas.viewport.x) * zoom,
y: (p.y - this.canvas.viewport.y) * zoom
});
const startScreen = toScreen(startWorld);
const endScreen = toScreen(endWorld);
const brushRadius = (this.canvas.maskTool.brushSize / 2) * zoom;
const hardness = this.canvas.maskTool.brushHardness;
const strength = this.canvas.maskTool.brushStrength;
// If strength is 0, don't draw anything
if (strength <= 0) {
return;
}
this.strokeOverlayCtx.save();
// Draw line segment exactly as MaskTool does
this.strokeOverlayCtx.beginPath();
this.strokeOverlayCtx.moveTo(startScreen.x, startScreen.y);
this.strokeOverlayCtx.lineTo(endScreen.x, endScreen.y);
// Match the gradient setup from MaskTool's drawLineOnChunk
if (hardness === 1) {
this.strokeOverlayCtx.strokeStyle = `rgba(255, 255, 255, ${strength})`;
}
else {
const innerRadius = brushRadius * hardness;
const gradient = this.strokeOverlayCtx.createRadialGradient(endScreen.x, endScreen.y, innerRadius, endScreen.x, endScreen.y, brushRadius);
gradient.addColorStop(0, `rgba(255, 255, 255, ${strength})`);
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
this.strokeOverlayCtx.strokeStyle = gradient;
}
// Match line properties from MaskTool
this.strokeOverlayCtx.lineWidth = this.canvas.maskTool.brushSize * zoom;
this.strokeOverlayCtx.lineCap = 'round';
this.strokeOverlayCtx.lineJoin = 'round';
this.strokeOverlayCtx.globalCompositeOperation = 'source-over';
this.strokeOverlayCtx.stroke();
this.strokeOverlayCtx.restore();
}
/**
* Redraws the entire stroke overlay from world coordinates
* Used when viewport changes during drawing to maintain visual consistency
*/
redrawMaskStrokeOverlay(strokePoints) {
if (strokePoints.length < 2)
return;
// Clear the overlay first
this.clearMaskStrokeOverlay();
// Redraw all segments with current viewport
for (let i = 1; i < strokePoints.length; i++) {
this.drawMaskStrokeSegment(strokePoints[i - 1], strokePoints[i]);
}
}
/**
* Draw mask brush cursor on overlay canvas with visual feedback for size, strength and hardness
* @param worldPoint World coordinates of cursor
*/
drawMaskBrushCursor(worldPoint) {
if (!this.canvas.maskTool.isActive || !this.canvas.isMouseOver) {
this.clearOverlay();
return;
}
// Update overlay size if needed
this.updateOverlaySize();
// Clear previous cursor
this.clearOverlay();
// Convert world coordinates to screen coordinates
const screenX = (worldPoint.x - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (worldPoint.y - this.canvas.viewport.y) * this.canvas.viewport.zoom;
// Get brush properties
const brushRadius = (this.canvas.maskTool.brushSize / 2) * this.canvas.viewport.zoom;
const brushStrength = this.canvas.maskTool.brushStrength;
const brushHardness = this.canvas.maskTool.brushHardness;
// Save context state
this.canvas.overlayCtx.save();
// If strength is 0, just draw outline
if (brushStrength > 0) {
// Draw inner fill to visualize brush effect - matches actual brush rendering
const gradient = this.canvas.overlayCtx.createRadialGradient(screenX, screenY, 0, screenX, screenY, brushRadius);
// Preview alpha - subtle to not obscure content
const previewAlpha = brushStrength * 0.15; // Very subtle preview (max 15% opacity)
if (brushHardness === 1) {
// Hard brush - uniform fill within radius
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
gradient.addColorStop(1, `rgba(255, 255, 255, ${previewAlpha})`);
}
else {
// Soft brush - gradient fade matching actual brush
gradient.addColorStop(0, `rgba(255, 255, 255, ${previewAlpha})`);
if (brushHardness > 0) {
gradient.addColorStop(brushHardness, `rgba(255, 255, 255, ${previewAlpha})`);
}
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
}
this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
this.canvas.overlayCtx.fillStyle = gradient;
this.canvas.overlayCtx.fill();
}
// Draw outer circle (SIZE indicator)
this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, brushRadius, 0, 2 * Math.PI);
// Stroke opacity based on strength (dimmer when strength is 0)
const strokeOpacity = brushStrength > 0 ? (0.4 + brushStrength * 0.4) : 0.3;
this.canvas.overlayCtx.strokeStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
this.canvas.overlayCtx.lineWidth = 1.5;
// Visual feedback for hardness
if (brushHardness > 0.8) {
// Hard brush - solid line
this.canvas.overlayCtx.setLineDash([]);
}
else {
// Soft brush - dashed line
const dashLength = 2 + (1 - brushHardness) * 4;
this.canvas.overlayCtx.setLineDash([dashLength, dashLength]);
}
this.canvas.overlayCtx.stroke();
// Center dot for small brushes
if (brushRadius < 5) {
this.canvas.overlayCtx.beginPath();
this.canvas.overlayCtx.arc(screenX, screenY, 1, 0, 2 * Math.PI);
this.canvas.overlayCtx.fillStyle = `rgba(255, 255, 255, ${strokeOpacity})`;
this.canvas.overlayCtx.fill();
}
// Restore context state
this.canvas.overlayCtx.restore();
}
/**
* Update overlay position when viewport changes
*/
updateOverlayPosition() {
// Overlay canvas is positioned absolutely, so it doesn't need repositioning
// Just ensure it's the right size
this.updateOverlaySize();
}
/**
* Draw grab icons in the center of selected layers
*/
drawGrabIcons(ctx) {
const selectedLayers = this.canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0)
return;
const iconRadius = 20 / this.canvas.viewport.zoom;
const innerRadius = 12 / this.canvas.viewport.zoom;
selectedLayers.forEach((layer) => {
if (!layer.visible)
return;
const centerX = layer.x + layer.width / 2;
const centerY = layer.y + layer.height / 2;
ctx.save(); ctx.save();
ctx.strokeStyle = 'rgba(0, 150, 255, 0.9)'; // Blue color // Draw outer circle (background)
ctx.lineWidth = 3 / this.canvas.viewport.zoom; ctx.beginPath();
ctx.setLineDash([12 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]); ctx.arc(centerX, centerY, iconRadius, 0, Math.PI * 2);
ctx.strokeRect(area.x, area.y, area.width, area.height); ctx.fillStyle = 'rgba(0, 150, 255, 0.7)';
ctx.fill();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.stroke();
// Draw hand/grab icon (simplified)
ctx.fillStyle = 'rgba(255, 255, 255, 0.95)';
ctx.strokeStyle = 'rgba(255, 255, 255, 0.95)';
ctx.lineWidth = 1.5 / this.canvas.viewport.zoom;
// Draw four dots representing grab points
const dotRadius = 2 / this.canvas.viewport.zoom;
const dotDistance = 6 / this.canvas.viewport.zoom;
// Top-left
ctx.beginPath();
ctx.arc(centerX - dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Top-right
ctx.beginPath();
ctx.arc(centerX + dotDistance, centerY - dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Bottom-left
ctx.beginPath();
ctx.arc(centerX - dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
// Bottom-right
ctx.beginPath();
ctx.arc(centerX + dotDistance, centerY + dotDistance, dotRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore(); ctx.restore();
}); });
} }
/**
* Draw transform handles for output area when in transform mode
*/
renderOutputAreaTransformHandles(ctx) {
if (this.canvas.canvasInteractions.interaction.mode !== 'transformingOutputArea') {
return;
}
const bounds = this.canvas.outputAreaBounds;
const handleRadius = 5 / this.canvas.viewport.zoom;
// Define handle positions
const handles = {
'nw': { x: bounds.x, y: bounds.y },
'n': { x: bounds.x + bounds.width / 2, y: bounds.y },
'ne': { x: bounds.x + bounds.width, y: bounds.y },
'e': { x: bounds.x + bounds.width, y: bounds.y + bounds.height / 2 },
'se': { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
's': { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height },
'sw': { x: bounds.x, y: bounds.y + bounds.height },
'w': { x: bounds.x, y: bounds.y + bounds.height / 2 },
};
// Draw handles
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
for (const [name, pos] of Object.entries(handles)) {
ctx.beginPath();
ctx.arc(pos.x, pos.y, handleRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
// Draw a highlight around the output area
ctx.strokeStyle = 'rgba(0, 150, 255, 0.8)';
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
ctx.setLineDash([]);
ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
}
} }

View File

@@ -1,4 +1,5 @@
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { generateUUID } from "./utils/CommonUtils.js";
const log = createModuleLogger('CanvasSelection'); const log = createModuleLogger('CanvasSelection');
export class CanvasSelection { export class CanvasSelection {
constructor(canvas) { constructor(canvas) {
@@ -18,7 +19,7 @@ export class CanvasSelection {
sortedLayers.forEach(layer => { sortedLayers.forEach(layer => {
const newLayer = { const newLayer = {
...layer, ...layer,
id: `layer_${+new Date()}_${Math.random().toString(36).substr(2, 9)}`, id: generateUUID(),
zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu
}; };
this.canvas.layers.push(newLayer); this.canvas.layers.push(newLayer);
@@ -40,7 +41,8 @@ export class CanvasSelection {
*/ */
updateSelection(newSelection) { updateSelection(newSelection) {
const previousSelection = this.selectedLayers.length; const previousSelection = this.selectedLayers.length;
this.selectedLayers = newSelection || []; // Filter out invisible layers from selection
this.selectedLayers = (newSelection || []).filter((layer) => layer.visible !== false);
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null; this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
// Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli // Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli
const hasChanged = previousSelection !== this.selectedLayers.length || const hasChanged = previousSelection !== this.selectedLayers.length ||

View File

@@ -1,6 +1,7 @@
import { getCanvasState, setCanvasState, saveImage, getImage } from "./db.js"; import { getCanvasState, setCanvasState, saveImage, getImage } from "./db.js";
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { generateUUID, cloneLayers, getStateSignature, debounce } from "./utils/CommonUtils.js"; import { showAlertNotification } from "./utils/NotificationUtils.js";
import { generateUUID, cloneLayers, getStateSignature, debounce, createCanvas } from "./utils/CommonUtils.js";
const log = createModuleLogger('CanvasState'); const log = createModuleLogger('CanvasState');
export class CanvasState { export class CanvasState {
constructor(canvas) { constructor(canvas) {
@@ -68,14 +69,29 @@ export class CanvasState {
y: -(this.canvas.height / 4), y: -(this.canvas.height / 4),
zoom: 0.8 zoom: 0.8
}; };
// Restore outputAreaBounds if saved, otherwise use default
if (savedState.outputAreaBounds) {
this.canvas.outputAreaBounds = savedState.outputAreaBounds;
log.debug(`Output Area bounds restored: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${this.canvas.outputAreaBounds.width}, h=${this.canvas.outputAreaBounds.height}`);
}
else {
// Fallback to default positioning for legacy saves
this.canvas.outputAreaBounds = {
x: -(this.canvas.width / 4),
y: -(this.canvas.height / 4),
width: this.canvas.width,
height: this.canvas.height
};
log.debug(`Output Area bounds set to default: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${this.canvas.outputAreaBounds.width}, h=${this.canvas.outputAreaBounds.height}`);
}
this.canvas.canvasLayers.updateOutputAreaSize(this.canvas.width, this.canvas.height, false); this.canvas.canvasLayers.updateOutputAreaSize(this.canvas.width, this.canvas.height, false);
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
const loadedLayers = await this._loadLayers(savedState.layers); const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter((l) => l !== null); this.canvas.layers = loadedLayers.filter((l) => l !== null);
log.info(`Loaded ${this.canvas.layers.length} layers.`); log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`);
if (this.canvas.layers.length === 0) { if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
log.warn("No valid layers loaded, state may be corrupted."); log.warn(`Failed to load any layers. Saved state had ${savedState.layers.length} layers but all failed to load. This may indicate corrupted IndexedDB data.`);
return false; // Don't return false - allow empty canvas to be valid
} }
this.canvas.updateSelectionAfterHistory(); this.canvas.updateSelectionAfterHistory();
this.canvas.render(); this.canvas.render();
@@ -184,6 +200,7 @@ export class CanvasState {
_createLayerFromSrc(layerData, imageSrc, index, resolve) { _createLayerFromSrc(layerData, imageSrc, index, resolve) {
if (typeof imageSrc === 'string') { if (typeof imageSrc === 'string') {
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { img.onload = () => {
log.debug(`Layer ${index}: Image loaded successfully.`); log.debug(`Layer ${index}: Image loaded successfully.`);
const newLayer = { ...layerData, image: img }; const newLayer = { ...layerData, image: img };
@@ -196,13 +213,11 @@ export class CanvasState {
img.src = imageSrc; img.src = imageSrc;
} }
else { else {
const canvas = document.createElement('canvas'); const { canvas, ctx } = createCanvas(imageSrc.width, imageSrc.height);
canvas.width = imageSrc.width;
canvas.height = imageSrc.height;
const ctx = canvas.getContext('2d');
if (ctx) { if (ctx) {
ctx.drawImage(imageSrc, 0, 0); ctx.drawImage(imageSrc, 0, 0);
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { img.onload = () => {
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`); log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
const newLayer = { ...layerData, image: img }; const newLayer = { ...layerData, image: img };
@@ -225,6 +240,20 @@ export class CanvasState {
log.error("Node ID is not available for saving state to DB."); log.error("Node ID is not available for saving state to DB.");
return; return;
} }
// Auto-correct node_id widget if needed before saving state
if (this.canvas.node && this.canvas.node.widgets) {
const nodeIdWidget = this.canvas.node.widgets.find((w) => w.name === "node_id");
if (nodeIdWidget) {
const correctId = String(this.canvas.node.id);
if (nodeIdWidget.value !== correctId) {
const prevValue = nodeIdWidget.value;
nodeIdWidget.value = correctId;
log.warn(`[CanvasState] node_id widget value (${prevValue}) did not match node.id (${correctId}) - auto-corrected (saveStateToDB).`);
showAlertNotification(`The value of node_id (${prevValue}) did not match the node number (${correctId}) and was automatically corrected.
If you see dark images or masks in the output, make sure node_id is set to ${correctId}.`);
}
}
}
log.info("Preparing state to be sent to worker..."); log.info("Preparing state to be sent to worker...");
const layers = await this._prepareLayers(); const layers = await this._prepareLayers();
const state = { const state = {
@@ -232,6 +261,7 @@ export class CanvasState {
viewport: this.canvas.viewport, viewport: this.canvas.viewport,
width: this.canvas.width, width: this.canvas.width,
height: this.canvas.height, height: this.canvas.height,
outputAreaBounds: this.canvas.outputAreaBounds,
}; };
if (state.layers.length === 0) { if (state.layers.length === 0) {
log.warn("No valid layers to save, skipping."); log.warn("No valid layers to save, skipping.");
@@ -315,10 +345,7 @@ export class CanvasState {
this.maskUndoStack.pop(); this.maskUndoStack.pop();
} }
const maskCanvas = this.canvas.maskTool.getMask(); const maskCanvas = this.canvas.maskTool.getMask();
const clonedCanvas = document.createElement('canvas'); const { canvas: clonedCanvas, ctx: clonedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', { willReadFrequently: true });
clonedCanvas.width = maskCanvas.width;
clonedCanvas.height = maskCanvas.height;
const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
if (clonedCtx) { if (clonedCtx) {
clonedCtx.drawImage(maskCanvas, 0, 0); clonedCtx.drawImage(maskCanvas, 0, 0);
} }
@@ -379,12 +406,10 @@ 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(); // Use the new restoreMaskFromSavedState method that properly clears chunks first
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); this.canvas.maskTool.restoreMaskFromSavedState(prevState);
if (maskCtx) { // Clear stroke overlay to prevent old drawing previews from persisting
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); this.canvas.canvasRenderer.clearMaskStrokeOverlay();
maskCtx.drawImage(prevState, 0, 0);
}
this.canvas.render(); this.canvas.render();
} }
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();
@@ -395,12 +420,10 @@ export class CanvasState {
const nextState = this.maskRedoStack.pop(); const nextState = this.maskRedoStack.pop();
if (nextState) { if (nextState) {
this.maskUndoStack.push(nextState); this.maskUndoStack.push(nextState);
const maskCanvas = this.canvas.maskTool.getMask(); // Use the new restoreMaskFromSavedState method that properly clears chunks first
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); this.canvas.maskTool.restoreMaskFromSavedState(nextState);
if (maskCtx) { // Clear stroke overlay to prevent old drawing previews from persisting
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height); this.canvas.canvasRenderer.clearMaskStrokeOverlay();
maskCtx.drawImage(nextState, 0, 0);
}
this.canvas.render(); this.canvas.render();
} }
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();

File diff suppressed because it is too large Load Diff

582
js/CustomShapeMenu.js Normal file
View File

@@ -0,0 +1,582 @@
import { createModuleLogger } from "./utils/LoggerUtils.js";
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
const log = createModuleLogger('CustomShapeMenu');
export class CustomShapeMenu {
constructor(canvas) {
this.isMinimized = false;
this.canvas = canvas;
this.element = null;
this.worldX = 0;
this.worldY = 0;
this.uiInitialized = false;
this.tooltip = null;
}
show() {
if (!this.canvas.outputAreaShape) {
return;
}
this._createUI();
if (this.element) {
this.element.style.display = 'block';
this._updateMinimizedState();
}
// Position in top-left corner of viewport (closer to edge)
const viewLeft = this.canvas.viewport.x;
const viewTop = this.canvas.viewport.y;
this.worldX = viewLeft + (8 / this.canvas.viewport.zoom);
this.worldY = viewTop + (8 / this.canvas.viewport.zoom);
this.updateScreenPosition();
}
hide() {
if (this.element) {
this.element.remove();
this.element = null;
this.uiInitialized = false;
}
this.hideTooltip();
}
updateScreenPosition() {
if (!this.element)
return;
const screenX = (this.worldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (this.worldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
this.element.style.transform = `translate(${screenX}px, ${screenY}px)`;
}
_createUI() {
if (this.uiInitialized)
return;
addStylesheet(getUrl('./css/custom_shape_menu.css'));
this.element = document.createElement('div');
this.element.id = 'layerforge-custom-shape-menu';
// --- MINIMIZED BAR ---
const minimizedBar = document.createElement('div');
minimizedBar.className = 'custom-shape-minimized-bar';
minimizedBar.textContent = "Custom Output Area Active";
minimizedBar.style.display = 'none';
minimizedBar.style.cursor = 'pointer';
minimizedBar.onclick = () => {
this.isMinimized = false;
this._updateMinimizedState();
};
this.element.appendChild(minimizedBar);
// --- FULL MENU ---
const fullMenu = document.createElement('div');
fullMenu.className = 'custom-shape-full-menu';
// Minimize button (top right)
const minimizeBtn = document.createElement('button');
minimizeBtn.innerHTML = "";
minimizeBtn.title = "Minimize menu";
minimizeBtn.className = 'custom-shape-minimize-btn';
minimizeBtn.style.position = 'absolute';
minimizeBtn.style.top = '4px';
minimizeBtn.style.right = '4px';
minimizeBtn.style.width = '24px';
minimizeBtn.style.height = '24px';
minimizeBtn.style.border = 'none';
minimizeBtn.style.background = 'transparent';
minimizeBtn.style.color = '#888';
minimizeBtn.style.fontSize = '20px';
minimizeBtn.style.cursor = 'pointer';
minimizeBtn.onclick = (e) => {
e.stopPropagation();
this.isMinimized = true;
this._updateMinimizedState();
};
fullMenu.appendChild(minimizeBtn);
// Create menu content
const lines = [
"Custom Output Area Active"
];
lines.forEach(line => {
const lineElement = document.createElement('div');
lineElement.textContent = line;
lineElement.className = 'menu-line';
fullMenu.appendChild(lineElement);
});
// Create a container for the entire shape mask feature set
const featureContainer = document.createElement('div');
featureContainer.id = 'shape-mask-feature-container';
featureContainer.className = 'feature-container';
// Add main auto-apply checkbox to the new container
const checkboxContainer = this._createCheckbox('auto-apply-checkbox', () => this.canvas.autoApplyShapeMask, 'Auto-apply shape mask', (e) => {
this.canvas.autoApplyShapeMask = e.target.checked;
if (this.canvas.autoApplyShapeMask) {
this.canvas.maskTool.applyShapeMask();
log.info("Auto-apply shape mask enabled - mask applied automatically");
}
else {
this.canvas.maskTool.removeShapeMask();
this.canvas.shapeMaskExpansion = false;
this.canvas.shapeMaskFeather = false;
log.info("Auto-apply shape mask disabled - mask area removed and sub-options reset.");
}
this._updateUI();
this.canvas.render();
}, "Automatically applies a mask based on the custom output area shape. When enabled, the mask will be applied to all layers within the shape boundary.");
featureContainer.appendChild(checkboxContainer);
// Add expansion checkbox
const expansionContainer = this._createCheckbox('expansion-checkbox', () => this.canvas.shapeMaskExpansion, 'Expand/Contract mask', (e) => {
this.canvas.shapeMaskExpansion = e.target.checked;
this._updateUI();
if (this.canvas.autoApplyShapeMask) {
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask();
this.canvas.render();
}
}, "Dilate (expand) or erode (contract) the shape mask. Positive values expand the mask outward, negative values shrink it inward.");
featureContainer.appendChild(expansionContainer);
// Add expansion slider container
const expansionSliderContainer = document.createElement('div');
expansionSliderContainer.id = 'expansion-slider-container';
expansionSliderContainer.className = 'slider-container';
const expansionSliderLabel = document.createElement('div');
expansionSliderLabel.textContent = 'Expansion amount:';
expansionSliderLabel.className = 'slider-label';
const expansionSlider = document.createElement('input');
expansionSlider.type = 'range';
expansionSlider.min = '-300';
expansionSlider.max = '300';
expansionSlider.value = String(this.canvas.shapeMaskExpansionValue);
const expansionValueDisplay = document.createElement('div');
expansionValueDisplay.className = 'slider-value-display';
let expansionValueBeforeDrag = this.canvas.shapeMaskExpansionValue;
const updateExpansionSliderDisplay = () => {
const value = parseInt(expansionSlider.value);
this.canvas.shapeMaskExpansionValue = value;
expansionValueDisplay.textContent = value > 0 ? `+${value}px` : `${value}px`;
};
let isExpansionDragging = false;
expansionSlider.onmousedown = () => {
isExpansionDragging = true;
expansionValueBeforeDrag = this.canvas.shapeMaskExpansionValue; // Store value before dragging
};
expansionSlider.oninput = () => {
updateExpansionSliderDisplay();
if (this.canvas.autoApplyShapeMask) {
if (isExpansionDragging) {
const featherValue = this.canvas.shapeMaskFeather ? this.canvas.shapeMaskFeatherValue : 0;
this.canvas.maskTool.showShapePreview(this.canvas.shapeMaskExpansionValue, featherValue);
}
else {
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask(false);
this.canvas.render();
}
}
};
expansionSlider.onmouseup = () => {
isExpansionDragging = false;
if (this.canvas.autoApplyShapeMask) {
const finalValue = parseInt(expansionSlider.value);
// If value changed during drag, remove old mask with previous expansion value
if (expansionValueBeforeDrag !== finalValue) {
// Temporarily set the previous value to remove the old mask properly
const tempValue = this.canvas.shapeMaskExpansionValue;
this.canvas.shapeMaskExpansionValue = expansionValueBeforeDrag;
this.canvas.maskTool.removeShapeMask();
this.canvas.shapeMaskExpansionValue = tempValue; // Restore current value
log.info(`Removed old shape mask with expansion: ${expansionValueBeforeDrag}px before applying new value: ${finalValue}px`);
}
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask(true);
this.canvas.render();
}
};
updateExpansionSliderDisplay();
expansionSliderContainer.appendChild(expansionSliderLabel);
expansionSliderContainer.appendChild(expansionSlider);
expansionSliderContainer.appendChild(expansionValueDisplay);
featureContainer.appendChild(expansionSliderContainer);
// Add feather checkbox
const featherContainer = this._createCheckbox('feather-checkbox', () => this.canvas.shapeMaskFeather, 'Feather edges', (e) => {
this.canvas.shapeMaskFeather = e.target.checked;
this._updateUI();
if (this.canvas.autoApplyShapeMask) {
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask();
this.canvas.render();
}
}, "Softens the edges of the shape mask by creating a gradual transition from opaque to transparent.");
featureContainer.appendChild(featherContainer);
// Add feather slider container
const featherSliderContainer = document.createElement('div');
featherSliderContainer.id = 'feather-slider-container';
featherSliderContainer.className = 'slider-container';
const featherSliderLabel = document.createElement('div');
featherSliderLabel.textContent = 'Feather amount:';
featherSliderLabel.className = 'slider-label';
const featherSlider = document.createElement('input');
featherSlider.type = 'range';
featherSlider.min = '0';
featherSlider.max = '300';
featherSlider.value = String(this.canvas.shapeMaskFeatherValue);
const featherValueDisplay = document.createElement('div');
featherValueDisplay.className = 'slider-value-display';
const updateFeatherSliderDisplay = () => {
const value = parseInt(featherSlider.value);
this.canvas.shapeMaskFeatherValue = value;
featherValueDisplay.textContent = `${value}px`;
};
let isFeatherDragging = false;
featherSlider.onmousedown = () => { isFeatherDragging = true; };
featherSlider.oninput = () => {
updateFeatherSliderDisplay();
if (this.canvas.autoApplyShapeMask) {
if (isFeatherDragging) {
const expansionValue = this.canvas.shapeMaskExpansion ? this.canvas.shapeMaskExpansionValue : 0;
this.canvas.maskTool.showShapePreview(expansionValue, this.canvas.shapeMaskFeatherValue);
}
else {
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask(false);
this.canvas.render();
}
}
};
featherSlider.onmouseup = () => {
isFeatherDragging = false;
if (this.canvas.autoApplyShapeMask) {
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask(true); // true = save state
this.canvas.render();
}
};
updateFeatherSliderDisplay();
featherSliderContainer.appendChild(featherSliderLabel);
featherSliderContainer.appendChild(featherSlider);
featherSliderContainer.appendChild(featherValueDisplay);
featureContainer.appendChild(featherSliderContainer);
fullMenu.appendChild(featureContainer);
// Create output area extension container
const extensionContainer = document.createElement('div');
extensionContainer.id = 'output-area-extension-container';
extensionContainer.className = 'feature-container';
// Add main extension checkbox
const extensionCheckboxContainer = this._createCheckbox('extension-checkbox', () => this.canvas.outputAreaExtensionEnabled, 'Extend output area', (e) => {
this.canvas.outputAreaExtensionEnabled = e.target.checked;
if (this.canvas.outputAreaExtensionEnabled) {
this.canvas.originalCanvasSize = { width: this.canvas.width, height: this.canvas.height };
this.canvas.outputAreaExtensions = { ...this.canvas.lastOutputAreaExtensions };
}
else {
this.canvas.lastOutputAreaExtensions = { ...this.canvas.outputAreaExtensions };
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
}
this._updateExtensionUI();
this._updateCanvasSize();
this.canvas.render();
}, "Allows extending the output area boundaries in all directions without changing the custom shape.");
extensionContainer.appendChild(extensionCheckboxContainer);
// Create sliders container
const slidersContainer = document.createElement('div');
slidersContainer.id = 'extension-sliders-container';
slidersContainer.className = 'slider-container';
// Helper function to create a slider with preview system
const createExtensionSlider = (label, direction) => {
const sliderContainer = document.createElement('div');
sliderContainer.className = 'extension-slider-container';
const sliderLabel = document.createElement('div');
sliderLabel.textContent = label;
sliderLabel.className = 'slider-label';
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '500';
slider.value = String(this.canvas.outputAreaExtensions[direction]);
const valueDisplay = document.createElement('div');
valueDisplay.className = 'slider-value-display';
const updateDisplay = () => {
const value = parseInt(slider.value);
valueDisplay.textContent = `${value}px`;
};
let isDragging = false;
slider.onmousedown = () => {
isDragging = true;
};
slider.oninput = () => {
updateDisplay();
if (isDragging) {
// During dragging, show preview
const previewExtensions = { ...this.canvas.outputAreaExtensions };
previewExtensions[direction] = parseInt(slider.value);
this.canvas.outputAreaExtensionPreview = previewExtensions;
this.canvas.render();
}
else {
// Not dragging, apply immediately (for keyboard navigation)
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
this._updateCanvasSize();
this.canvas.render();
}
};
slider.onmouseup = () => {
if (isDragging) {
isDragging = false;
// Apply the final value and clear preview
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
this.canvas.outputAreaExtensionPreview = null;
this._updateCanvasSize();
this.canvas.render();
}
};
// Handle mouse leave (in case user drags outside)
slider.onmouseleave = () => {
if (isDragging) {
isDragging = false;
// Apply the final value and clear preview
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
this.canvas.outputAreaExtensionPreview = null;
this._updateCanvasSize();
this.canvas.render();
}
};
updateDisplay();
sliderContainer.appendChild(sliderLabel);
sliderContainer.appendChild(slider);
sliderContainer.appendChild(valueDisplay);
return sliderContainer;
};
// Add all four sliders
slidersContainer.appendChild(createExtensionSlider('Top extension:', 'top'));
slidersContainer.appendChild(createExtensionSlider('Bottom extension:', 'bottom'));
slidersContainer.appendChild(createExtensionSlider('Left extension:', 'left'));
slidersContainer.appendChild(createExtensionSlider('Right extension:', 'right'));
extensionContainer.appendChild(slidersContainer);
fullMenu.appendChild(extensionContainer);
this.element.appendChild(fullMenu);
// Add to DOM
if (this.canvas.canvas.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.element);
}
else {
log.error("Could not find parent node to attach custom shape menu.");
}
this.uiInitialized = true;
this._updateUI();
this._updateMinimizedState();
// Add viewport change listener to update shape preview when zooming/panning
this._addViewportChangeListener();
}
_createCheckbox(id, getChecked, text, clickHandler, tooltipText) {
const container = document.createElement('label');
container.className = 'checkbox-container';
container.htmlFor = id;
const input = document.createElement('input');
input.type = 'checkbox';
input.id = id;
input.checked = getChecked();
const customCheckbox = document.createElement('div');
customCheckbox.className = 'custom-checkbox';
const labelText = document.createElement('span');
labelText.textContent = text;
container.appendChild(input);
container.appendChild(customCheckbox);
container.appendChild(labelText);
// Stop propagation to prevent menu from closing, but allow default checkbox behavior
container.onclick = (e) => {
e.stopPropagation();
};
input.onchange = (e) => {
clickHandler(e);
};
if (tooltipText) {
this._addTooltip(container, tooltipText);
}
return container;
}
_updateUI() {
if (!this.element)
return;
// Always update only the full menu part
const fullMenu = this.element.querySelector('.custom-shape-full-menu');
if (!fullMenu)
return;
const setChecked = (id, checked) => {
const input = fullMenu.querySelector(`#${id}`);
if (input)
input.checked = checked;
};
setChecked('auto-apply-checkbox', this.canvas.autoApplyShapeMask);
setChecked('expansion-checkbox', this.canvas.shapeMaskExpansion);
setChecked('feather-checkbox', this.canvas.shapeMaskFeather);
setChecked('extension-checkbox', this.canvas.outputAreaExtensionEnabled);
const expansionCheckbox = fullMenu.querySelector('#expansion-checkbox')?.parentElement;
if (expansionCheckbox) {
expansionCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'flex' : 'none';
}
const featherCheckbox = fullMenu.querySelector('#feather-checkbox')?.parentElement;
if (featherCheckbox) {
featherCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'flex' : 'none';
}
const expansionSliderContainer = fullMenu.querySelector('#expansion-slider-container');
if (expansionSliderContainer) {
expansionSliderContainer.style.display = (this.canvas.autoApplyShapeMask && this.canvas.shapeMaskExpansion) ? 'block' : 'none';
}
const featherSliderContainer = fullMenu.querySelector('#feather-slider-container');
if (featherSliderContainer) {
featherSliderContainer.style.display = (this.canvas.autoApplyShapeMask && this.canvas.shapeMaskFeather) ? 'block' : 'none';
}
}
_updateMinimizedState() {
if (!this.element)
return;
const minimizedBar = this.element.querySelector('.custom-shape-minimized-bar');
const fullMenu = this.element.querySelector('.custom-shape-full-menu');
if (this.isMinimized) {
minimizedBar.style.display = 'block';
fullMenu.style.display = 'none';
}
else {
minimizedBar.style.display = 'none';
fullMenu.style.display = 'block';
}
}
_updateExtensionUI() {
if (!this.element)
return;
// Toggle visibility of extension sliders based on the extension checkbox state
const extensionSlidersContainer = this.element.querySelector('#extension-sliders-container');
if (extensionSlidersContainer) {
extensionSlidersContainer.style.display = this.canvas.outputAreaExtensionEnabled ? 'block' : 'none';
}
// Update slider values if they exist
if (this.canvas.outputAreaExtensionEnabled) {
const sliders = extensionSlidersContainer?.querySelectorAll('input[type="range"]');
const directions = ['top', 'bottom', 'left', 'right'];
sliders?.forEach((slider, index) => {
const direction = directions[index];
if (direction) {
slider.value = String(this.canvas.outputAreaExtensions[direction]);
// Update the corresponding value display
const valueDisplay = slider.parentElement?.querySelector('div:last-child');
if (valueDisplay) {
valueDisplay.textContent = `${this.canvas.outputAreaExtensions[direction]}px`;
}
}
});
}
}
/**
* Add viewport change listener to update shape preview when zooming/panning
*/
_addViewportChangeListener() {
// Store previous viewport state to detect changes
let previousViewport = {
x: this.canvas.viewport.x,
y: this.canvas.viewport.y,
zoom: this.canvas.viewport.zoom
};
// Check for viewport changes in render loop
const checkViewportChange = () => {
if (this.canvas.maskTool.shapePreviewVisible) {
const current = this.canvas.viewport;
// Check if viewport has changed
if (current.x !== previousViewport.x ||
current.y !== previousViewport.y ||
current.zoom !== previousViewport.zoom) {
// Update shape preview with current expansion/feather values
const expansionValue = this.canvas.shapeMaskExpansionValue || 0;
const featherValue = this.canvas.shapeMaskFeather ? (this.canvas.shapeMaskFeatherValue || 0) : 0;
this.canvas.maskTool.showShapePreview(expansionValue, featherValue);
// Update previous viewport state
previousViewport = {
x: current.x,
y: current.y,
zoom: current.zoom
};
}
}
// Continue checking if UI is still active
if (this.uiInitialized) {
requestAnimationFrame(checkViewportChange);
}
};
// Start the viewport change detection
requestAnimationFrame(checkViewportChange);
}
_addTooltip(element, text) {
element.addEventListener('mouseenter', (e) => {
this.showTooltip(text, e);
});
element.addEventListener('mouseleave', () => {
this.hideTooltip();
});
element.addEventListener('mousemove', (e) => {
if (this.tooltip && this.tooltip.style.display === 'block') {
this.updateTooltipPosition(e);
}
});
}
showTooltip(text, event) {
this.hideTooltip(); // Hide any existing tooltip
this.tooltip = document.createElement('div');
this.tooltip.textContent = text;
this.tooltip.className = 'layerforge-tooltip';
document.body.appendChild(this.tooltip);
this.updateTooltipPosition(event);
// Fade in the tooltip
requestAnimationFrame(() => {
if (this.tooltip) {
this.tooltip.style.opacity = '1';
}
});
}
updateTooltipPosition(event) {
if (!this.tooltip)
return;
const tooltipRect = this.tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = event.clientX + 10;
let y = event.clientY - 10;
// Adjust if tooltip would go off the right edge
if (x + tooltipRect.width > viewportWidth) {
x = event.clientX - tooltipRect.width - 10;
}
// Adjust if tooltip would go off the bottom edge
if (y + tooltipRect.height > viewportHeight) {
y = event.clientY - tooltipRect.height - 10;
}
// Ensure tooltip doesn't go off the left or top edges
x = Math.max(5, x);
y = Math.max(5, y);
this.tooltip.style.left = `${x}px`;
this.tooltip.style.top = `${y}px`;
}
hideTooltip() {
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
}
}
_updateCanvasSize() {
if (!this.canvas.outputAreaExtensionEnabled) {
// When extensions are disabled, return to original custom shape position
// Use originalOutputAreaPosition instead of current bounds position
const originalPos = this.canvas.originalOutputAreaPosition;
this.canvas.outputAreaBounds = {
x: originalPos.x, // ✅ Return to original custom shape position
y: originalPos.y, // ✅ Return to original custom shape position
width: this.canvas.originalCanvasSize.width,
height: this.canvas.originalCanvasSize.height
};
this.canvas.updateOutputAreaSize(this.canvas.originalCanvasSize.width, this.canvas.originalCanvasSize.height, false);
return;
}
const ext = this.canvas.outputAreaExtensions;
const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right;
const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom;
// When extensions are enabled, calculate new bounds relative to original custom shape position
const originalPos = this.canvas.originalOutputAreaPosition;
this.canvas.outputAreaBounds = {
x: originalPos.x - ext.left, // Adjust position by left extension from original position
y: originalPos.y - ext.top, // Adjust position by top extension from original position
width: newWidth,
height: newHeight
};
// Zmień rozmiar canvas (fizyczny rozmiar dla renderowania)
this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
log.info(`Output area bounds updated: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${newWidth}, h=${newHeight}`);
log.info(`Extensions: top=${ext.top}, bottom=${ext.bottom}, left=${ext.left}, right=${ext.right}`);
}
}

View File

@@ -2,12 +2,16 @@
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
// @ts-ignore // @ts-ignore
import { ComfyApp } from "../../scripts/app.js"; import { ComfyApp } from "../../scripts/app.js";
// @ts-ignore
import { api } from "../../scripts/api.js";
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { showErrorNotification } from "./utils/NotificationUtils.js";
import { uploadImageBlob } from "./utils/ImageUploadUtils.js";
import { processImageToMask, processMaskForViewport } from "./utils/MaskProcessingUtils.js";
import { convertToImage } from "./utils/ImageUtils.js";
import { updateNodePreview } from "./utils/PreviewUtils.js";
import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js"; import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js";
const log = createModuleLogger('CanvasMask'); import { createCanvas } from "./utils/CommonUtils.js";
export class CanvasMask { const log = createModuleLogger('MaskEditorIntegration');
export class MaskEditorIntegration {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; this.canvas = canvas;
this.node = canvas.node; this.node = canvas.node;
@@ -48,7 +52,7 @@ export class CanvasMask {
} }
else { else {
log.debug('Getting flattened canvas for mask editor (with mask)'); log.debug('Getting flattened canvas for mask editor (with mask)');
blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor(); blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
} }
if (!blob) { if (!blob) {
log.warn("Canvas is empty, cannot open mask editor."); log.warn("Canvas is empty, cannot open mask editor.");
@@ -56,28 +60,11 @@ export class CanvasMask {
} }
log.debug('Canvas blob created successfully, size:', blob.size); log.debug('Canvas blob created successfully, size:', blob.size);
try { try {
const formData = new FormData(); // Use ImageUploadUtils to upload the blob
const filename = `layerforge-mask-edit-${+new Date()}.png`; const uploadResult = await uploadImageBlob(blob, {
formData.append("image", blob, filename); filenamePrefix: 'layerforge-mask-edit'
formData.append("overwrite", "true");
formData.append("type", "temp");
log.debug('Uploading image to server:', filename);
const response = await api.fetchApi("/upload/image", {
method: "POST",
body: formData,
}); });
if (!response.ok) { this.node.imgs = [uploadResult.imageElement];
throw new Error(`Failed to upload image: ${response.statusText}`);
}
const data = await response.json();
log.debug('Image uploaded successfully:', data);
const img = new Image();
img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
});
this.node.imgs = [img];
log.info('Opening ComfyUI mask editor'); 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;
@@ -92,7 +79,53 @@ export class CanvasMask {
} }
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}`); showErrorNotification(`Error: ${error.message}`);
}
}
/**
* Oblicza dynamiczny czas oczekiwania na podstawie rozmiaru obrazu
* @returns {number} Czas oczekiwania w milisekundach
*/
calculateDynamicWaitTime() {
try {
// Get canvas dimensions from output area bounds
const bounds = this.canvas.outputAreaBounds;
const width = bounds.width;
const height = bounds.height;
// Calculate total pixels
const totalPixels = width * height;
// Define wait time based on image size
let waitTime = 500; // Base wait time for small images
if (totalPixels <= 1000 * 1000) {
// Below 1MP (1000x1000) - 500ms
waitTime = 500;
}
else if (totalPixels <= 2000 * 2000) {
// 1MP to 4MP (2000x2000) - 1000ms
waitTime = 1000;
}
else if (totalPixels <= 4000 * 4000) {
// 4MP to 16MP (4000x4000) - 2000ms
waitTime = 2000;
}
else if (totalPixels <= 6000 * 6000) {
// 16MP to 36MP (6000x6000) - 4000ms
waitTime = 4000;
}
else {
// Above 36MP - 6000ms
waitTime = 6000;
}
log.debug("Calculated dynamic wait time", {
imageSize: `${width}x${height}`,
totalPixels: totalPixels,
waitTime: waitTime
});
return waitTime;
}
catch (error) {
log.warn("Error calculating dynamic wait time, using default 1000ms", error);
return 1000; // Fallback to 1 second
} }
} }
/** /**
@@ -141,11 +174,13 @@ export class CanvasMask {
} }
} }
if (editorReady) { if (editorReady) {
log.info("Applying mask to editor after", attempts * 100, "ms wait"); // Calculate dynamic wait time based on image size
const waitTime = this.calculateDynamicWaitTime();
log.info("Applying mask to editor after", waitTime, "ms wait (dynamic based on image size)");
setTimeout(() => { setTimeout(() => {
this.applyMaskToEditor(this.pendingMask); this.applyMaskToEditor(this.pendingMask);
this.pendingMask = null; this.pendingMask = null;
}, 300); }, waitTime);
} }
else if (attempts < maxAttempts) { else if (attempts < maxAttempts) {
if (attempts % 10 === 0) { if (attempts % 10 === 0) {
@@ -250,53 +285,16 @@ export class CanvasMask {
* @param {number} targetHeight - Docelowa wysokość * @param {number} targetHeight - Docelowa wysokość
* @param {Object} maskColor - Kolor maski {r, g, b} * @param {Object} maskColor - Kolor maski {r, g, b}
* @returns {HTMLCanvasElement} Przetworzona maska * @returns {HTMLCanvasElement} Przetworzona maska
*/ async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) { */
// Współrzędne przesunięcia (pan) widoku edytora async processMaskForEditor(maskData, targetWidth, targetHeight, maskColor) {
const panX = this.maskTool.x; // Pozycja maski w świecie względem output bounds
const panY = this.maskTool.y; const bounds = this.canvas.outputAreaBounds;
log.info("Processing mask for editor:", { const maskWorldX = this.maskTool.x;
sourceSize: { width: maskData.width, height: maskData.height }, const maskWorldY = this.maskTool.y;
targetSize: { width: targetWidth, height: targetHeight }, const panX = maskWorldX - bounds.x;
viewportPan: { x: panX, y: panY } const panY = maskWorldY - bounds.y;
}); // Use MaskProcessingUtils for viewport processing
const tempCanvas = document.createElement('canvas'); return await processMaskForViewport(maskData, targetWidth, targetHeight, { x: panX, y: panY }, maskColor);
tempCanvas.width = targetWidth;
tempCanvas.height = targetHeight;
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
const sourceX = -panX;
const sourceY = -panY;
if (tempCtx) {
tempCtx.drawImage(maskData, // Źródło: pełna maska z "output area"
sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000)
sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000)
targetWidth, // sWidth: Szerokość wycinanego fragmentu
targetHeight, // sHeight: Wysokość wycinanego fragmentu
0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0)
0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0)
targetWidth, // dWidth: Szerokość wklejanego obrazu
targetHeight // dHeight: Wysokość wklejanego obrazu
);
}
log.info("Mask viewport cropped correctly.", {
source: "maskData",
cropArea: { x: sourceX, y: sourceY, width: targetWidth, height: targetHeight }
});
// Reszta kodu (zmiana koloru) pozostaje bez zmian
if (tempCtx) {
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0) {
data[i] = maskColor.r;
data[i + 1] = maskColor.g;
data[i + 2] = maskColor.b;
}
}
tempCtx.putImageData(imageData, 0, 0);
}
log.info("Mask processing completed - color applied.");
return tempCanvas;
} }
/** /**
* Tworzy obiekt Image z obecnej maski canvas * Tworzy obiekt Image z obecnej maski canvas
@@ -334,10 +332,7 @@ export class CanvasMask {
return null; return null;
} }
const maskCanvas = this.maskTool.maskCanvas; const maskCanvas = this.maskTool.maskCanvas;
const savedCanvas = document.createElement('canvas'); const { canvas: savedCanvas, ctx: savedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', { willReadFrequently: true });
savedCanvas.width = maskCanvas.width;
savedCanvas.height = maskCanvas.height;
const savedCtx = savedCanvas.getContext('2d', { willReadFrequently: true });
if (savedCtx) { if (savedCtx) {
savedCtx.drawImage(maskCanvas, 0, 0); savedCtx.drawImage(maskCanvas, 0, 0);
} }
@@ -415,52 +410,23 @@ export class CanvasMask {
this.node.imgs = []; this.node.imgs = [];
return; return;
} }
log.debug("Creating temporary canvas for mask processing"); // Process image to mask using MaskProcessingUtils
const tempCanvas = document.createElement('canvas'); log.debug("Processing image to mask using utils");
tempCanvas.width = this.canvas.width; const bounds = this.canvas.outputAreaBounds;
tempCanvas.height = this.canvas.height; const processedMask = await processImageToMask(resultImage, {
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); targetWidth: bounds.width,
if (tempCtx) { targetHeight: bounds.height,
tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height); invertAlpha: true
log.debug("Processing image data to create mask"); });
const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); // Convert processed mask to image
const data = imageData.data; const maskAsImage = await convertToImage(processedMask);
for (let i = 0; i < data.length; i += 4) { log.debug("Applying mask using chunk system", {
const originalAlpha = data[i + 3]; boundsPos: { x: bounds.x, y: bounds.y },
data[i] = 255; maskSize: { width: bounds.width, height: bounds.height }
data[i + 1] = 255; });
data[i + 2] = 255; this.maskTool.setMask(maskAsImage);
data[i + 3] = 255 - originalAlpha; // Update node preview using PreviewUtils
} await updateNodePreview(this.canvas, this.node, true);
tempCtx.putImageData(imageData, 0, 0);
}
log.debug("Converting processed mask to image");
const maskAsImage = new Image();
maskAsImage.src = tempCanvas.toDataURL();
await new Promise(resolve => maskAsImage.onload = resolve);
const maskCtx = this.maskTool.maskCtx;
const destX = -this.maskTool.x;
const destY = -this.maskTool.y;
log.debug("Applying mask to canvas", { destX, destY });
maskCtx.globalCompositeOperation = 'source-over';
maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height);
maskCtx.drawImage(maskAsImage, destX, destY);
this.canvas.render();
this.canvas.saveState();
log.debug("Creating new preview image");
const new_preview = new Image();
const blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (blob) {
new_preview.src = URL.createObjectURL(blob);
await new Promise(r => new_preview.onload = r);
this.node.imgs = [new_preview];
log.debug("New preview image created successfully");
}
else {
this.node.imgs = [];
log.warn("Failed to create preview blob");
}
this.canvas.render();
this.savedMaskState = null; this.savedMaskState = null;
log.info("Mask editor result processed successfully"); log.info("Mask editor result processed successfully");
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,12 @@
import { api } from "../../scripts/api.js";
// @ts-ignore // @ts-ignore
import { ComfyApp } from "../../scripts/app.js"; import { ComfyApp } from "../../scripts/app.js";
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { showInfoNotification, showSuccessNotification, showErrorNotification } from "./utils/NotificationUtils.js";
import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.js";
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
import { convertToImage } from "./utils/ImageUtils.js";
import { updateNodePreview } from "./utils/PreviewUtils.js";
import { validateAndFixClipspace } from "./utils/ClipspaceUtils.js";
const log = createModuleLogger('SAMDetectorIntegration'); const log = createModuleLogger('SAMDetectorIntegration');
/** /**
* SAM Detector Integration for LayerForge * SAM Detector Integration for LayerForge
@@ -10,34 +15,18 @@ const log = createModuleLogger('SAMDetectorIntegration');
// Function to register image in clipspace for Impact Pack compatibility // Function to register image in clipspace for Impact Pack compatibility
export const registerImageInClipspace = async (node, blob) => { export const registerImageInClipspace = async (node, blob) => {
try { try {
// Upload the image to ComfyUI's temp storage for clipspace access // Use ImageUploadUtils to upload the blob
const formData = new FormData(); const uploadResult = await uploadImageBlob(blob, {
const filename = `layerforge-sam-${node.id}-${Date.now()}.png`; // Use timestamp for SAM Detector filenamePrefix: 'layerforge-sam',
formData.append("image", blob, filename); nodeId: node.id
formData.append("overwrite", "true");
formData.append("type", "temp");
const response = await api.fetchApi("/upload/image", {
method: "POST",
body: formData,
}); });
if (response.ok) { log.debug(`Image registered in clipspace for node ${node.id}: ${uploadResult.filename}`);
const data = await response.json(); return uploadResult.imageElement;
// Create a proper image element with the server URL
const clipspaceImg = new Image();
clipspaceImg.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
// Wait for image to load
await new Promise((resolve, reject) => {
clipspaceImg.onload = resolve;
clipspaceImg.onerror = reject;
});
log.debug(`Image registered in clipspace for node ${node.id}: ${filename}`);
return clipspaceImg;
}
} }
catch (error) { catch (error) {
log.debug("Failed to register image in clipspace:", error); log.debug("Failed to register image in clipspace:", error);
return null;
} }
return null;
}; };
// Function to monitor for SAM Detector modal closure and apply masks to LayerForge // Function to monitor for SAM Detector modal closure and apply masks to LayerForge
export function startSAMDetectorMonitoring(node) { export function startSAMDetectorMonitoring(node) {
@@ -186,7 +175,7 @@ function handleSAMDetectorModalClosed(node) {
else { else {
log.info("No new image detected after SAM Detector modal closure"); log.info("No new image detected after SAM Detector modal closure");
// Show info notification // Show info notification
showNotification("SAM Detector closed. No mask was applied.", "#4a6cd4", 3000); showInfoNotification("SAM Detector closed. No mask was applied.");
} }
} }
else { else {
@@ -229,7 +218,7 @@ function monitorSAMDetectorChanges(node) {
// Start monitoring after a short delay // Start monitoring after a short delay
setTimeout(checkForChanges, 500); setTimeout(checkForChanges, 500);
} }
// Function to handle SAM Detector result (using same logic as CanvasMask.handleMaskEditorClose) // Function to handle SAM Detector result (using same logic as MaskEditorIntegration.handleMaskEditorClose)
async function handleSAMDetectorResult(node, resultImage) { async function handleSAMDetectorResult(node, resultImage) {
try { try {
log.info("Handling SAM Detector result for node", node.id); log.info("Handling SAM Detector result for node", node.id);
@@ -240,7 +229,7 @@ async function handleSAMDetectorResult(node, resultImage) {
return; return;
} }
const canvas = canvasWidget; // canvasWidget is the Canvas object, not canvasWidget.canvas const canvas = canvasWidget; // canvasWidget is the Canvas object, not canvasWidget.canvas
// Wait for the result image to load (same as CanvasMask) // Wait for the result image to load (same as MaskEditorIntegration)
try { try {
// First check if the image is already loaded // First check if the image is already loaded
if (resultImage.complete && resultImage.naturalWidth > 0) { if (resultImage.complete && resultImage.naturalWidth > 0) {
@@ -253,141 +242,127 @@ async function handleSAMDetectorResult(node, resultImage) {
// Try to reload the image with a fresh request // Try to reload the image with a fresh request
log.debug("Attempting to reload SAM result image"); log.debug("Attempting to reload SAM result image");
const originalSrc = resultImage.src; const originalSrc = resultImage.src;
// Add cache-busting parameter to force fresh load // Check if it's a data URL (base64) - don't add parameters to data URLs
const url = new URL(originalSrc); if (originalSrc.startsWith('data:')) {
url.searchParams.set('_t', Date.now().toString()); log.debug("Image is a data URL, skipping reload with parameters");
await new Promise((resolve, reject) => { // For data URLs, just ensure the image is loaded
const img = new Image(); if (!resultImage.complete || resultImage.naturalWidth === 0) {
img.crossOrigin = "anonymous"; await new Promise((resolve, reject) => {
img.onload = () => { const img = new Image();
// Copy the loaded image data to the original image img.onload = () => {
resultImage.src = img.src; resultImage.width = img.width;
resultImage.width = img.width; resultImage.height = img.height;
resultImage.height = img.height; log.debug("Data URL image loaded successfully", {
log.debug("SAM result image reloaded successfully", { width: img.width,
width: img.width, height: img.height
height: img.height, });
originalSrc: originalSrc, resolve(img);
newSrc: img.src };
img.onerror = (error) => {
log.error("Failed to load data URL image", error);
reject(error);
};
img.src = originalSrc; // Use original src without modifications
}); });
resolve(img); }
}; }
img.onerror = (error) => { else {
log.error("Failed to reload SAM result image", { // For regular URLs, add cache-busting parameter
originalSrc: originalSrc, const url = new URL(originalSrc);
newSrc: url.toString(), url.searchParams.set('_t', Date.now().toString());
error: error await new Promise((resolve, reject) => {
}); const img = new Image();
reject(error); img.crossOrigin = "anonymous";
}; img.onload = () => {
img.src = url.toString(); // Copy the loaded image data to the original image
}); resultImage.src = img.src;
resultImage.width = img.width;
resultImage.height = img.height;
log.debug("SAM result image reloaded successfully", {
width: img.width,
height: img.height,
originalSrc: originalSrc,
newSrc: img.src
});
resolve(img);
};
img.onerror = (error) => {
log.error("Failed to reload SAM result image", {
originalSrc: originalSrc,
newSrc: url.toString(),
error: error
});
reject(error);
};
img.src = url.toString();
});
}
} }
} }
catch (error) { catch (error) {
log.error("Failed to load image from SAM Detector.", error); log.error("Failed to load image from SAM Detector.", error);
showNotification("Failed to load SAM Detector result. The mask file may not be available.", "#c54747", 5000); showErrorNotification("Failed to load SAM Detector result. The mask file may not be available.");
return; return;
} }
// Create temporary canvas for mask processing (same as CanvasMask) // Process image to mask using MaskProcessingUtils
log.debug("Creating temporary canvas for mask processing"); log.debug("Processing image to mask using utils");
const tempCanvas = document.createElement('canvas'); const processedMask = await processImageToMask(resultImage, {
tempCanvas.width = canvas.width; targetWidth: resultImage.width,
tempCanvas.height = canvas.height; targetHeight: resultImage.height,
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); invertAlpha: true
if (tempCtx) { });
tempCtx.drawImage(resultImage, 0, 0, canvas.width, canvas.height); // Convert processed mask to image
log.debug("Processing image data to create mask"); const maskAsImage = await convertToImage(processedMask);
const imageData = tempCtx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Convert to mask format (same as CanvasMask)
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = 255 - originalAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
}
// Convert processed mask to image (same as CanvasMask)
log.debug("Converting processed mask to image");
const maskAsImage = new Image();
maskAsImage.src = tempCanvas.toDataURL();
await new Promise(resolve => maskAsImage.onload = resolve);
// Apply mask to LayerForge canvas using MaskTool.setMask method // Apply mask to LayerForge canvas using MaskTool.setMask method
log.debug("Checking canvas and maskTool availability", { log.debug("Checking canvas and maskTool availability", {
hasCanvas: !!canvas, hasCanvas: !!canvas,
hasCanvasProperty: !!canvas.canvas,
canvasCanvasKeys: canvas.canvas ? Object.keys(canvas.canvas) : [],
hasMaskTool: !!canvas.maskTool, hasMaskTool: !!canvas.maskTool,
hasCanvasMaskTool: !!(canvas.canvas && canvas.canvas.maskTool),
maskToolType: typeof canvas.maskTool, maskToolType: typeof canvas.maskTool,
canvasMaskToolType: canvas.canvas ? typeof canvas.canvas.maskTool : 'undefined',
canvasKeys: Object.keys(canvas) canvasKeys: Object.keys(canvas)
}); });
if (!canvas.maskTool) { // Get the actual Canvas object and its maskTool
const actualCanvas = canvas.canvas || canvas;
const maskTool = actualCanvas.maskTool;
if (!maskTool) {
log.error("MaskTool is not available. Canvas state:", { log.error("MaskTool is not available. Canvas state:", {
hasCanvas: !!canvas, hasCanvas: !!canvas,
hasActualCanvas: !!actualCanvas,
canvasConstructor: canvas.constructor.name, canvasConstructor: canvas.constructor.name,
actualCanvasConstructor: actualCanvas ? actualCanvas.constructor.name : 'undefined',
canvasKeys: Object.keys(canvas), canvasKeys: Object.keys(canvas),
maskToolValue: canvas.maskTool actualCanvasKeys: actualCanvas ? Object.keys(actualCanvas) : [],
maskToolValue: maskTool
}); });
throw new Error("Mask tool not available or not initialized"); throw new Error("Mask tool not available or not initialized");
} }
log.debug("Applying SAM mask to canvas using addMask method"); log.debug("Applying SAM mask to canvas using setMask method");
// Use the addMask method which overlays on existing mask without clearing it // Use the setMask method which clears existing mask and sets new one
canvas.maskTool.addMask(maskAsImage); maskTool.setMask(maskAsImage);
// Update canvas and save state (same as CanvasMask) // Update canvas and save state (same as MaskEditorIntegration)
canvas.render(); actualCanvas.render();
canvas.saveState(); actualCanvas.saveState();
// Create new preview image (same as CanvasMask) // Update node preview using PreviewUtils
log.debug("Creating new preview image"); await updateNodePreview(actualCanvas, node, true);
const new_preview = new Image();
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (blob) {
new_preview.src = URL.createObjectURL(blob);
await new Promise(r => new_preview.onload = r);
node.imgs = [new_preview];
log.debug("New preview image created successfully");
}
else {
log.warn("Failed to create preview blob");
}
canvas.render();
log.info("SAM Detector mask applied successfully to LayerForge canvas"); log.info("SAM Detector mask applied successfully to LayerForge canvas");
// Show success notification // Show success notification
showNotification("SAM Detector mask applied to LayerForge!", "#4a7c59", 3000); showSuccessNotification("SAM Detector mask applied to LayerForge!");
} }
catch (error) { catch (error) {
log.error("Error processing SAM Detector result:", error); log.error("Error processing SAM Detector result:", error);
// Show error notification // Show error notification
showNotification(`Failed to apply SAM mask: ${error.message}`, "#c54747", 5000); showErrorNotification(`Failed to apply SAM mask: ${error.message}`);
} }
finally { finally {
node.samMonitoringActive = false; node.samMonitoringActive = false;
node.samOriginalImgSrc = null; node.samOriginalImgSrc = null;
} }
} }
// Helper function to show notifications // Store original onClipspaceEditorSave function to restore later
function showNotification(message, backgroundColor, duration) { let originalOnClipspaceEditorSave = null;
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${backgroundColor};
color: white;
padding: 12px 16px;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
z-index: 10001;
font-size: 14px;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, duration);
}
// Function to setup SAM Detector hook in menu options // Function to setup SAM Detector hook in menu options
export function setupSAMDetectorHook(node, options) { export function setupSAMDetectorHook(node, options) {
// Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously // Hook into "Open in SAM Detector" with delay since Impact Pack adds it asynchronously
@@ -401,55 +376,56 @@ export function setupSAMDetectorHook(node, options) {
try { try {
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring"); log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
// Automatically send canvas to clipspace and start monitoring // Automatically send canvas to clipspace and start monitoring
if (node.canvasWidget && node.canvasWidget.canvas) { if (node.canvasWidget) {
const canvas = node.canvasWidget; // canvasWidget IS the Canvas object const canvasWidget = node.canvasWidget;
// Get the flattened canvas as blob const canvas = canvasWidget.canvas || canvasWidget; // Get actual Canvas object
const blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob(); // Use ImageUploadUtils to upload canvas and get server URL (Impact Pack compatibility)
if (!blob) { const uploadResult = await uploadCanvasAsImage(canvas, {
throw new Error("Failed to generate canvas blob"); filenamePrefix: 'layerforge-sam',
} nodeId: node.id
// Upload the image to ComfyUI's temp storage
const formData = new FormData();
const filename = `layerforge-sam-${node.id}-${Date.now()}.png`; // Unique filename with timestamp
formData.append("image", blob, filename);
formData.append("overwrite", "true");
formData.append("type", "temp");
const response = await api.fetchApi("/upload/image", {
method: "POST",
body: formData,
}); });
if (!response.ok) { log.debug("Uploaded canvas for SAM Detector", {
throw new Error(`Failed to upload image: ${response.statusText}`); filename: uploadResult.filename,
} imageUrl: uploadResult.imageUrl,
const data = await response.json(); width: uploadResult.imageElement.width,
log.debug('Image uploaded for SAM Detector:', data); height: uploadResult.imageElement.height
// Create image element with proper URL
const img = new Image();
img.crossOrigin = "anonymous"; // Add CORS support
// Wait for image to load before setting src
const imageLoadPromise = new Promise((resolve, reject) => {
img.onload = () => {
log.debug("SAM Detector image loaded successfully", {
width: img.width,
height: img.height,
src: img.src.substring(0, 100) + '...'
});
resolve(img);
};
img.onerror = (error) => {
log.error("Failed to load SAM Detector image", error);
reject(new Error("Failed to load uploaded image"));
};
}); });
// Set src after setting up event handlers
img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
// Wait for image to load
await imageLoadPromise;
// Set the image to the node for clipspace // Set the image to the node for clipspace
node.imgs = [img]; node.imgs = [uploadResult.imageElement];
node.clipspaceImg = img; node.clipspaceImg = uploadResult.imageElement;
// Ensure proper clipspace structure for updated ComfyUI
if (!ComfyApp.clipspace) {
ComfyApp.clipspace = {};
}
// Set up clipspace with proper indices
ComfyApp.clipspace.imgs = [uploadResult.imageElement];
ComfyApp.clipspace.selectedIndex = 0;
ComfyApp.clipspace.combinedIndex = 0;
ComfyApp.clipspace.img_paste_mode = 'selected';
// Copy to ComfyUI clipspace // Copy to ComfyUI clipspace
ComfyApp.copyToClipspace(node); ComfyApp.copyToClipspace(node);
// Override onClipspaceEditorSave to fix clipspace structure before pasteFromClipspace
if (!originalOnClipspaceEditorSave) {
originalOnClipspaceEditorSave = ComfyApp.onClipspaceEditorSave;
ComfyApp.onClipspaceEditorSave = function () {
log.debug("SAM Detector onClipspaceEditorSave called, using unified clipspace validation");
// Use the unified clipspace validation function
const isValid = validateAndFixClipspace();
if (!isValid) {
log.error("Clipspace validation failed, cannot proceed with paste");
return;
}
// Call the original function
if (originalOnClipspaceEditorSave) {
originalOnClipspaceEditorSave.call(ComfyApp);
}
// Restore the original function after use
if (originalOnClipspaceEditorSave) {
ComfyApp.onClipspaceEditorSave = originalOnClipspaceEditorSave;
originalOnClipspaceEditorSave = null;
}
};
}
// Start monitoring for SAM Detector results // Start monitoring for SAM Detector results
startSAMDetectorMonitoring(node); startSAMDetectorMonitoring(node);
log.info("Canvas automatically sent to clipspace and monitoring started"); log.info("Canvas automatically sent to clipspace and monitoring started");

142
js/ShapeTool.js Normal file
View File

@@ -0,0 +1,142 @@
import { createModuleLogger } from "./utils/LoggerUtils.js";
const log = createModuleLogger('ShapeTool');
export class ShapeTool {
constructor(canvas) {
this.isActive = false;
this.canvas = canvas;
this.shape = {
points: [],
isClosed: false,
};
}
toggle() {
this.isActive = !this.isActive;
if (this.isActive) {
log.info('ShapeTool activated. Press "S" to exit.');
this.reset();
}
else {
log.info('ShapeTool deactivated.');
this.reset();
}
this.canvas.render();
}
activate() {
if (!this.isActive) {
this.isActive = true;
log.info('ShapeTool activated. Hold Shift+S to draw.');
this.reset();
this.canvas.render();
}
}
deactivate() {
if (this.isActive) {
this.isActive = false;
log.info('ShapeTool deactivated.');
this.reset();
this.canvas.render();
}
}
addPoint(point) {
if (this.shape.isClosed) {
this.reset();
}
// Check if the new point is close to the start point to close the shape
if (this.shape.points.length > 2) {
const firstPoint = this.shape.points[0];
const dx = point.x - firstPoint.x;
const dy = point.y - firstPoint.y;
if (Math.sqrt(dx * dx + dy * dy) < 10 / this.canvas.viewport.zoom) {
this.closeShape();
return;
}
}
this.shape.points.push(point);
this.canvas.render();
}
closeShape() {
if (this.shape.points.length > 2) {
this.shape.isClosed = true;
log.info('Shape closed with', this.shape.points.length, 'points.');
this.canvas.defineOutputAreaWithShape(this.shape);
this.reset();
}
this.canvas.render();
}
getBoundingBox() {
if (this.shape.points.length === 0) {
return null;
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.shape.points.forEach(p => {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
});
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
reset() {
this.shape = {
points: [],
isClosed: false,
};
log.info('ShapeTool reset.');
this.canvas.render();
}
render(ctx) {
if (this.shape.points.length === 0) {
return;
}
ctx.save();
ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]);
ctx.beginPath();
const startPoint = this.shape.points[0];
ctx.moveTo(startPoint.x, startPoint.y);
for (let i = 1; i < this.shape.points.length; i++) {
ctx.lineTo(this.shape.points[i].x, this.shape.points[i].y);
}
if (this.shape.isClosed) {
ctx.closePath();
ctx.fillStyle = 'rgba(0, 255, 255, 0.2)';
ctx.fill();
}
else if (this.isActive) {
// Draw a line to the current mouse position
ctx.lineTo(this.canvas.lastMousePosition.x, this.canvas.lastMousePosition.y);
}
ctx.stroke();
// Draw vertices
const mouse = this.canvas.lastMousePosition;
const firstPoint = this.shape.points[0];
let highlightFirst = false;
if (!this.shape.isClosed && this.shape.points.length > 2 && mouse) {
const dx = mouse.x - firstPoint.x;
const dy = mouse.y - firstPoint.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 10 / this.canvas.viewport.zoom) {
highlightFirst = true;
}
}
this.shape.points.forEach((point, index) => {
ctx.beginPath();
if (index === 0 && highlightFirst) {
ctx.arc(point.x, point.y, 8 / this.canvas.viewport.zoom, 0, 2 * Math.PI);
ctx.fillStyle = 'yellow';
}
else {
ctx.arc(point.x, point.y, 4 / this.canvas.viewport.zoom, 0, 2 * Math.PI);
ctx.fillStyle = 'rgba(0, 255, 255, 1)';
}
ctx.fill();
});
ctx.restore();
}
}

170
js/css/blend_mode_menu.css Normal file
View File

@@ -0,0 +1,170 @@
/* Blend Mode Menu Styles */
#blend-mode-menu {
position: absolute;
top: 0;
left: 0;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
z-index: 10000;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
min-width: 200px;
}
#blend-mode-menu .blend-menu-title-bar {
background: #3a3a3a;
color: white;
padding: 8px 10px;
cursor: move;
user-select: none;
border-radius: 3px 3px 0 0;
font-size: 12px;
font-weight: bold;
border-bottom: 1px solid #4a4a4a;
display: flex;
justify-content: space-between;
align-items: center;
}
#blend-mode-menu .blend-menu-title-text {
flex: 1;
cursor: move;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#blend-mode-menu .blend-menu-close-button {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 0;
margin: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
transition: background-color 0.2s;
}
#blend-mode-menu .blend-menu-close-button:hover {
background-color: #4a4a4a;
}
#blend-mode-menu .blend-menu-close-button:focus {
background-color: transparent;
}
#blend-mode-menu .blend-menu-content {
padding: 5px;
}
#blend-mode-menu .blend-area-container {
padding: 5px 10px;
border-bottom: 1px solid #4a4a4a;
}
#blend-mode-menu .blend-area-label {
color: white;
display: block;
margin-bottom: 5px;
font-size: 12px;
}
#blend-mode-menu .blend-area-slider {
width: 100%;
margin: 5px 0;
-webkit-appearance: none;
height: 4px;
background: #555;
border-radius: 2px;
outline: none;
}
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
transition: background 0.2s;
}
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb:hover {
background: #fff;
}
#blend-mode-menu .blend-area-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
}
#blend-mode-menu .blend-mode-container {
margin-bottom: 5px;
}
#blend-mode-menu .blend-mode-option {
padding: 5px 10px;
color: white;
cursor: pointer;
transition: background-color 0.2s;
}
#blend-mode-menu .blend-mode-option:hover {
background-color: #3a3a3a;
}
#blend-mode-menu .blend-mode-option.active {
background-color: #3a3a3a;
}
#blend-mode-menu .blend-opacity-slider {
width: 100%;
margin: 5px 0;
display: none;
-webkit-appearance: none;
height: 4px;
background: #555;
border-radius: 2px;
outline: none;
}
#blend-mode-menu .blend-mode-container.active .blend-opacity-slider {
display: block;
}
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
transition: background 0.2s;
}
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb:hover {
background: #fff;
}
#blend-mode-menu .blend-opacity-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
}

View File

@@ -1,54 +1,125 @@
.painter-button { .painter-button {
background: linear-gradient(to bottom, #4a4a4a, #3a3a3a); background-color: #444;
border: 1px solid #2a2a2a; border: 1px solid #555;
border-radius: 4px; border-radius: 5px;
color: #ffffff; color: #fff;
padding: 6px 12px; padding: 6px 14px;
font-size: 12px; font-size: 12px;
font-weight: 550;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
min-width: 80px; min-width: 80px;
text-align: center; text-align: center;
margin: 2px; margin: 2px;
text-shadow: 0 1px 1px rgba(0,0,0,0.2); box-shadow: 0 1px 2px rgba(0,0,0,0.1);
text-shadow: 0 1px 2px rgb(0,0,0);
} }
.painter-button:hover { .painter-button:hover {
background: linear-gradient(to bottom, #5a5a5a, #4a4a4a); background-color: #555;
box-shadow: 0 1px 3px rgba(0,0,0,0.2); border-color: #666;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
transform: translateY(-1px);
} }
.painter-button:active { .painter-button:active {
background: linear-gradient(to bottom, #3a3a3a, #4a4a4a); background-color: #3a3a3a;
transform: translateY(1px); transform: translateY(0);
box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);
} }
.painter-button:disabled, .painter-button:disabled,
.painter-button:disabled:hover { .painter-button:disabled:hover {
background: #555; background-color: #3a3a3a;
color: #888; color: #777;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
box-shadow: none; box-shadow: none;
border-color: #444; border-color: #4a4a4a;
opacity: 0.6;
} }
.painter-button.primary { .painter-button.primary {
background: linear-gradient(to bottom, #4a6cd4, #3a5cc4); background-color: #3a76d6;
border-color: #2a4cb4; border-color: #2a6ac4;
color: #fff;
text-shadow: 0 1px 2px rgb(0,0,0);
} }
.painter-button.primary:hover { .painter-button.primary:hover {
background: linear-gradient(to bottom, #5a7ce4, #4a6cd4); background-color: #4a86e4;
border-color: #3a76d6;
}
/* Crop mode button styling */
.painter-button#crop-mode-btn {
background-color: #444;
border-color: #555;
color: #fff;
transition: all 0.2s ease-in-out;
}
.painter-button#crop-mode-btn.primary {
background-color: #0080ff;
border-color: #0070e0;
color: #fff;
box-shadow: 0 0 8px rgba(0, 128, 255, 0.3);
}
.painter-button#crop-mode-btn.primary:hover {
background-color: #1090ff;
border-color: #0080ff;
box-shadow: 0 0 12px rgba(0, 128, 255, 0.4);
}
.painter-button#crop-mode-btn:hover {
background-color: #555;
border-color: #666;
}
.painter-button.success {
border-color: #4ae27a;
background-color: #444;
color: #fff;
box-shadow: 0 0 0 1.5px #4ae27a88;
}
.painter-button.success:hover {
border-color: #6aff9a;
box-shadow: 0 0 0 2.5px #6aff9a88;
background-color: #555;
}
.painter-button.danger {
border-color: #e24a4a;
background-color: #444;
color: #fff;
box-shadow: 0 0 0 1.5px #e24a4a88;
}
.painter-button.danger:hover {
border-color: #ff6a6a;
box-shadow: 0 0 0 2.5px #ff6a6a88;
background-color: #555;
}
.painter-button.icon-button {
width: 30px;
height: 30px;
min-width: 30px;
padding: 0;
font-size: 16px;
line-height: 30px; /* Match height */
display: flex;
align-items: center;
justify-content: center;
} }
.painter-controls { .painter-controls {
background: linear-gradient(to bottom, #404040, #383838); background-color: #2f2f2f;
border-bottom: 1px solid #2a2a2a; border-bottom: 1px solid #202020;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 1px 3px rgba(0,0,0,0.2);
padding: 8px; padding: 10px;
display: flex; display: flex;
gap: 6px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
@@ -56,57 +127,235 @@
.painter-slider-container { .painter-slider-container {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 4px;
color: #fff; color: #fff;
font-size: 12px; font-size: 12px;
min-width: 100px;
} }
.painter-slider-container input[type="range"] { .painter-slider-container input[type="range"] {
-webkit-appearance: none;
width: 80px; width: 80px;
height: 4px;
background: #555;
border-radius: 2px;
outline: none;
padding: 0;
margin: 0;
} }
.painter-slider-container input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
transition: background 0.2s;
}
.painter-slider-container input[type="range"]::-webkit-slider-thumb:hover {
background: #fff;
}
.painter-slider-container input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
}
.slider-value {
font-size: 11px;
color: #bbb;
margin-top: 2px;
min-height: 14px;
text-align: center;
}
.painter-button-group { .painter-button-group {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 4px;
background-color: rgba(0,0,0,0.2); background-color: transparent;
padding: 4px; padding: 0;
border-radius: 6px; border-radius: 6px;
} }
.painter-clipboard-group { .painter-clipboard-group {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 4px;
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 { .painter-clipboard-group .painter-button {
margin: 1px; margin: 1px;
height: 30px; /* Match switch height */
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
} }
/* --- Clipboard Switch Modern --- */
.clipboard-switch {
position: relative;
width: 90px;
height: 30px;
box-sizing: border-box;
background: linear-gradient(to right, #5a5a5a 30%, #3a76d6);
border-radius: 5px;
border: 1px solid #555;
cursor: pointer;
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
user-select: none;
padding: 0;
font-family: inherit;
font-size: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.clipboard-switch:hover {
background: linear-gradient(to right, #6a6a6a 30%, #4a86e4);
border-color: #666;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
transform: translateY(-1px);
}
/* Mask switch: szaro-czarny gradient tylko dla maski */
.clipboard-switch.mask-switch {
background: linear-gradient(to right, #5a5a5a 30%, #e53935);
}
.clipboard-switch.mask-switch:hover {
background: linear-gradient(to right, #6a6a6a 30%, #ff5252);
}
.clipboard-switch:active {
background: linear-gradient(135deg, #3a76d6, #3a3a3a);
}
.clipboard-switch input[type="checkbox"] {
display: none;
}
.clipboard-switch .switch-track {
display: none;
}
.clipboard-switch .switch-knob {
position: absolute;
top: 2px;
left: 2px;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background-color: #5a5a5a;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: 2;
}
.clipboard-switch:hover .switch-knob {
background-color: #6a6a6a;
}
.clipboard-switch:hover .switch-knob {
background-color: #6a6a6a;
}
.clipboard-switch .switch-labels {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 550;
color: #ffffff;
pointer-events: none;
z-index: 1;
transition: opacity 0.3s ease-in-out;
text-shadow: 0 1px 2px rgb(0, 0, 0);
}
.clipboard-switch .switch-labels .text-clipspace,
.clipboard-switch .switch-labels .text-system {
position: absolute;
transition: opacity 0.2s ease-in-out;
}
.clipboard-switch .switch-labels .text-clipspace { opacity: 0; }
.clipboard-switch .switch-labels .text-system { opacity: 1; padding-left: 20px; }
.clipboard-switch .switch-knob .switch-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.clipboard-switch .switch-knob .switch-icon img {
width: 100%;
height: 100%;
}
/* Checked state */
.clipboard-switch:has(input:checked) {
background: linear-gradient(to right, #3a76d6, #5a5a5a 70%);
border-color: #2a6ac4;
}
.clipboard-switch:has(input:checked):hover {
background: linear-gradient(to right, #4a86e4, #6a6a6a 70%);
border-color: #3a76d6;
}
.clipboard-switch input:checked ~ .switch-knob {
left: calc(100% - 26px);
}
.clipboard-switch input:checked ~ .switch-knob .switch-icon img {
filter: none;
}
.clipboard-switch input:checked ~ .switch-labels .text-clipspace {
opacity: 1;
color: #fff;
padding-right: 20px;
}
.clipboard-switch input:checked ~ .switch-labels .text-system {
opacity: 0;
}
/* Disabled state for switch */
.clipboard-switch.disabled {
cursor: not-allowed;
opacity: 0.6;
background: #3a3a3a !important; /* Override gradient */
border-color: #4a4a4a !important;
transform: none !important;
box-shadow: none !important;
}
.clipboard-switch.disabled .switch-knob {
background-color: #4a4a4a !important;
}
.clipboard-switch.disabled .switch-labels {
color: #777 !important;
}
.painter-separator { .painter-separator {
width: 1px; width: 1px;
height: 28px; height: 24px;
background-color: #2a2a2a; background-color: #444;
margin: 0 8px; margin: 0 8px;
} }
@@ -182,17 +431,18 @@
.painter-tooltip { .painter-tooltip {
position: fixed; position: fixed;
display: none; display: none;
background: #3a3a3a; background: #2B2B2B;
color: #f0f0f0; color: #f0f0f0;
border: 1px solid #555; border: 1px solid #444;
border-radius: 8px; border-top: 2px solid #4a90e2;
border-radius: 6px;
padding: 12px 18px; padding: 12px 18px;
z-index: 9999; z-index: 9999;
font-size: 13px; font-size: 12px;
line-height: 1.7; line-height: 1.5;
width: auto; width: auto;
max-width: min(500px, calc(100vw - 40px)); max-width: min(450px, calc(100vw - 30px));
box-shadow: 0 4px 12px rgba(0,0,0,0.3); box-shadow: 0 4px 15px rgba(0,0,0,0.3);
pointer-events: none; pointer-events: none;
transform-origin: top left; transform-origin: top left;
transition: transform 0.2s ease; transition: transform 0.2s ease;
@@ -216,8 +466,9 @@
} }
.painter-tooltip table td { .painter-tooltip table td {
padding: 2px 8px; padding: 4px 8px;
vertical-align: middle; vertical-align: middle;
transition: background-color 0.2s;
} }
.painter-tooltip table td:first-child { .painter-tooltip table td:first-child {
@@ -231,7 +482,10 @@
} }
.painter-tooltip table tr:nth-child(odd) td { .painter-tooltip table tr:nth-child(odd) td {
background-color: rgba(0,0,0,0.1); background-color: rgba(255, 255, 255, 0.02);
}
.painter-tooltip table tr:hover td {
background-color: rgba(74, 144, 226, 0.15);
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -304,10 +558,15 @@
.painter-tooltip h4 { .painter-tooltip h4 {
margin-top: 10px; margin-top: 10px;
margin-bottom: 5px; margin-bottom: 6px;
color: #4a90e2; /* Jasnoniebieski akcent */ color: #4a90e2;
border-bottom: 1px solid #555; border-bottom: 1px solid #4a90e2;
padding-bottom: 4px; padding-bottom: 4px;
font-size: 14px;
font-weight: 600;
}
.painter-tooltip h4:first-child {
margin-top: 0;
} }
.painter-tooltip ul { .painter-tooltip ul {
@@ -317,13 +576,18 @@
} }
.painter-tooltip kbd { .painter-tooltip kbd {
background-color: #2a2a2a; background-color: #444;
border: 1px solid #1a1a1a; border: 1px solid #555;
border-radius: 3px; border-bottom-width: 2px;
border-radius: 4px;
padding: 2px 6px; padding: 2px 6px;
font-family: monospace; font-family: monospace;
font-size: 12px; font-size: 11px;
color: #d0d0d0; color: #e0e0e0;
box-shadow: 0 1px 1px rgba(0,0,0,0.15);
margin: 0 1px;
display: inline-block;
vertical-align: middle;
} }
.painter-container.has-focus { .painter-container.has-focus {
@@ -374,7 +638,7 @@
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: 111; z-index: 999999;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@@ -0,0 +1,204 @@
#layerforge-custom-shape-menu {
position: absolute;
top: 0;
left: 0;
background-color: #2f2f2f;
color: #e0e0e0;
padding: 12px;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
display: none;
flex-direction: column;
gap: 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
z-index: 1001;
border: 1px solid #202020;
user-select: none;
min-width: 220px;
}
#layerforge-custom-shape-menu .menu-line {
font-weight: 600;
color: #4a90e2;
padding-bottom: 5px;
border-bottom: 1px solid #444;
margin-bottom: 5px;
display: flex;
align-items: center;
gap: 8px;
}
/* --- MINIMIZED BAR INTERACTIVE STYLE --- */
.custom-shape-minimized-bar {
font-size: 13px;
font-weight: 600;
padding: 6px 12px;
border-radius: 6px;
background: #222;
color: #4a90e2;
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
margin: 0 0 8px 0;
user-select: none;
cursor: pointer;
border: 1px solid #444;
transition: background 0.18s, color 0.18s, box-shadow 0.18s, border 0.18s;
outline: none;
text-shadow: none;
display: flex;
align-items: center;
gap: 8px;
}
.custom-shape-minimized-bar:hover, .custom-shape-minimized-bar:focus {
background: #2a2a2a;
color: #4a90e2;
border: 1.5px solid #4a90e2;
box-shadow: 0 4px 16px #4a90e244;
}
#layerforge-custom-shape-menu .feature-container {
background-color: #3a3a3a;
border-radius: 6px;
padding: 10px 12px;
border: 1px solid #4a4a4a;
margin-bottom: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
#layerforge-custom-shape-menu .feature-container:last-child {
margin-bottom: 0;
}
#layerforge-custom-shape-menu .slider-container {
margin-top: 6px;
margin-bottom: 0;
display: none;
gap: 6px;
}
#layerforge-custom-shape-menu .slider-label {
font-size: 12px;
margin-bottom: 6px;
color: #e0e0e0;
}
#layerforge-custom-shape-menu input[type="range"] {
-webkit-appearance: none;
width: 100%;
height: 4px;
background: #555;
border-radius: 2px;
outline: none;
padding: 0;
margin: 0;
}
#layerforge-custom-shape-menu input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
transition: background 0.2s;
}
#layerforge-custom-shape-menu input[type="range"]::-webkit-slider-thumb:hover {
background: #fff;
}
#layerforge-custom-shape-menu input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
}
#layerforge-custom-shape-menu .slider-value-display {
font-size: 11px;
text-align: center;
margin-top: 4px;
color: #bbb;
min-height: 14px;
}
#layerforge-custom-shape-menu .extension-slider-container {
margin: 10px 0;
}
#layerforge-custom-shape-menu .checkbox-container {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s;
position: relative;
}
#layerforge-custom-shape-menu .checkbox-container:hover {
background-color: #4a4a4a;
}
#layerforge-custom-shape-menu .checkbox-container input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
#layerforge-custom-shape-menu .checkbox-container .custom-checkbox {
height: 16px;
width: 16px;
background-color: #2a2a2a;
border: 1px solid #666;
border-radius: 3px;
transition: all 0.2s;
position: relative;
flex-shrink: 0;
}
#layerforge-custom-shape-menu .checkbox-container input:checked ~ .custom-checkbox {
background-color: #3a76d6;
border-color: #3a76d6;
}
#layerforge-custom-shape-menu .checkbox-container .custom-checkbox::after {
content: "";
position: absolute;
display: none;
left: 5px;
top: 1px;
width: 4px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
#layerforge-custom-shape-menu .checkbox-container input:checked ~ .custom-checkbox::after {
display: block;
}
.layerforge-tooltip {
position: fixed;
background-color: #2f2f2f;
color: #e0e0e0;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.4;
max-width: 250px;
word-wrap: break-word;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
border: 1px solid #202020;
z-index: 10000;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}

309
js/css/layers_panel.css Normal file
View File

@@ -0,0 +1,309 @@
/* Layers Panel Styles */
.layers-panel {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 8px;
height: 100%;
overflow: hidden;
font-family: Arial, sans-serif;
font-size: 12px;
color: #ffffff;
user-select: none;
display: flex;
flex-direction: column;
}
.layers-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid #3a3a3a;
margin-bottom: 8px;
}
.checkbox-container {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s;
position: relative;
}
.checkbox-container:hover {
background-color: #4a4a4a;
}
.checkbox-container input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkbox-container .custom-checkbox {
height: 16px;
width: 16px;
background-color: #2a2a2a;
border: 1px solid #666;
border-radius: 3px;
transition: all 0.2s;
position: relative;
flex-shrink: 0;
}
.checkbox-container input:checked ~ .custom-checkbox {
background-color: #3a76d6;
border-color: #3a76d6;
}
.checkbox-container .custom-checkbox::after {
content: "";
position: absolute;
display: none;
left: 5px;
top: 1px;
width: 4px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.checkbox-container input:checked ~ .custom-checkbox::after {
display: block;
}
.checkbox-container input:indeterminate ~ .custom-checkbox {
background-color: #3a76d6;
border-color: #3a76d6;
}
.checkbox-container input:indeterminate ~ .custom-checkbox::after {
display: block;
content: "";
position: absolute;
top: 7px;
left: 3px;
width: 8px;
height: 2px;
background-color: white;
border: none;
transform: none;
box-shadow: none;
}
.checkbox-container:hover {
background-color: #4a4a4a;
}
.layers-panel-title {
font-weight: bold;
color: #ffffff;
}
.layers-panel-controls {
display: flex;
gap: 4px;
}
.layers-btn {
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #ffffff;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.layers-btn:hover {
background: #4a4a4a;
}
.layers-btn:active {
background: #5a5a5a;
}
.layers-btn:disabled {
background: #2a2a2a;
color: #666666;
cursor: not-allowed;
opacity: 0.5;
}
.layers-btn:disabled:hover {
background: #2a2a2a;
}
.layers-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.layer-row {
display: flex;
align-items: center;
padding: 6px 4px;
margin-bottom: 2px;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.15s ease;
position: relative;
gap: 6px;
}
.layer-row:hover {
background: rgba(255, 255, 255, 0.05);
}
.layer-row.selected {
background: #2d5aa0 !important;
box-shadow: inset 0 0 0 1px #4a7bc8;
}
.layer-row.dragging {
opacity: 0.6;
}
.layer-thumbnail {
width: 48px;
height: 48px;
border: 1px solid #4a4a4a;
border-radius: 2px;
background: transparent;
position: relative;
flex-shrink: 0;
overflow: hidden;
}
.layer-thumbnail canvas {
width: 100%;
height: 100%;
display: block;
}
.layer-thumbnail::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(45deg, #555 25%, transparent 25%),
linear-gradient(-45deg, #555 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #555 75%),
linear-gradient(-45deg, transparent 75%, #555 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
z-index: 1;
}
.layer-thumbnail canvas {
position: relative;
z-index: 2;
}
.layer-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 4px;
border-radius: 2px;
color: #ffffff;
}
.layer-name.editing {
background: #4a4a4a;
border: 1px solid #6a6a6a;
outline: none;
color: #ffffff;
}
.layer-name input {
background: transparent;
border: none;
color: #ffffff;
font-size: 12px;
width: 100%;
outline: none;
}
.drag-insertion-line {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: #4a7bc8;
border-radius: 1px;
z-index: 1000;
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
}
.layers-container::-webkit-scrollbar {
width: 6px;
}
.layers-container::-webkit-scrollbar-track {
background: #2a2a2a;
}
.layers-container::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 3px;
}
.layers-container::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}
.layer-visibility-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 2px;
font-size: 14px;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.layer-visibility-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Icon container styles */
.layers-panel .icon-container {
display: flex;
align-items: center;
justify-content: center;
}
.layers-panel .icon-container img {
filter: brightness(0) invert(1);
}
.layers-panel .icon-container.visibility-hidden {
opacity: 0.5;
}
.layers-panel .icon-container.visibility-hidden img {
filter: brightness(0) invert(1);
opacity: 0.3;
}
.layers-panel .icon-container.fallback-text {
font-size: 10px;
color: #888888;
}

View File

@@ -4,7 +4,9 @@
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr> <tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
<tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr> <tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr>
<tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr> <tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr>
<tr><td><kbd>Shift + S + Left Click</kbd></td><td>Draw custom shape for output area</td></tr>
<tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr> <tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Close fullscreen editor mode</td></tr>
</table> </table>
<h4>Clipboard & I/O</h4> <h4>Clipboard & I/O</h4>

View File

@@ -1,26 +1,24 @@
import { createModuleLogger } from "./LoggerUtils.js"; import { createModuleLogger } from "./LoggerUtils.js";
import { showNotification, showInfoNotification, showErrorNotification, showWarningNotification } from "./NotificationUtils.js";
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
import { safeClipspacePaste } from "./ClipspaceUtils.js";
// @ts-ignore // @ts-ignore
import { api } from "../../../scripts/api.js"; import { api } from "../../../scripts/api.js";
// @ts-ignore
import { ComfyApp } from "../../../scripts/app.js";
const log = createModuleLogger('ClipboardManager'); const log = createModuleLogger('ClipboardManager');
export class ClipboardManager { export class ClipboardManager {
constructor(canvas) { constructor(canvas) {
this.canvas = canvas; /**
this.clipboardPreference = 'system'; // 'system', 'clipspace' * Main paste handler that delegates to appropriate methods
} * @param {AddMode} addMode - The mode for adding the layer
/** * @param {ClipboardPreference} preference - Clipboard preference ('system' or 'clipspace')
* Main paste handler that delegates to appropriate methods * @returns {Promise<boolean>} - True if successful, false otherwise
* @param {AddMode} addMode - The mode for adding the layer */
* @param {ClipboardPreference} preference - Clipboard preference ('system' or 'clipspace') this.handlePaste = withErrorHandling(async (addMode = 'mouse', preference = 'system') => {
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async handlePaste(addMode = 'mouse', preference = 'system') {
try {
log.info(`ClipboardManager handling paste with preference: ${preference}`); log.info(`ClipboardManager handling paste with preference: ${preference}`);
if (this.canvas.canvasLayers.internalClipboard.length > 0) { if (this.canvas.canvasLayers.internalClipboard.length > 0) {
log.info("Found layers in internal clipboard, pasting layers"); log.info("Found layers in internal clipboard, pasting layers");
this.canvas.canvasLayers.pasteLayers(); this.canvas.canvasLayers.pasteLayers();
showInfoNotification("Layers pasted from internal clipboard");
return true; return true;
} }
if (preference === 'clipspace') { if (preference === 'clipspace') {
@@ -30,24 +28,34 @@ export class ClipboardManager {
return true; return true;
} }
log.info("No image found in ComfyUI Clipspace"); log.info("No image found in ComfyUI Clipspace");
// Don't show error here, will try system clipboard next
} }
log.info("Attempting paste from system clipboard"); log.info("Attempting paste from system clipboard");
return await this.trySystemClipboardPaste(addMode); const systemSuccess = await this.trySystemClipboardPaste(addMode);
} if (!systemSuccess) {
catch (err) { // No valid image found in any clipboard
log.error("ClipboardManager paste operation failed:", err); if (preference === 'clipspace') {
return false; showWarningNotification("No valid image found in Clipspace or system clipboard");
} }
} else {
/** showWarningNotification("No valid image found in clipboard");
* Attempts to paste from ComfyUI Clipspace }
* @param {AddMode} addMode - The mode for adding the layer }
* @returns {Promise<boolean>} - True if successful, false otherwise return systemSuccess;
*/ }, 'ClipboardManager.handlePaste');
async tryClipspacePaste(addMode) { /**
try { * Attempts to paste from ComfyUI Clipspace
* @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
this.tryClipspacePaste = withErrorHandling(async (addMode) => {
log.info("Attempting to paste from ComfyUI Clipspace"); log.info("Attempting to paste from ComfyUI Clipspace");
ComfyApp.pasteFromClipspace(this.canvas.node); // Use the unified clipspace validation and paste function
const pasteSuccess = safeClipspacePaste(this.canvas.node);
if (!pasteSuccess) {
log.debug("Safe clipspace paste failed");
return false;
}
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) { if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
const clipspaceImage = this.canvas.node.imgs[0]; const clipspaceImage = this.canvas.node.imgs[0];
if (clipspaceImage && clipspaceImage.src) { if (clipspaceImage && clipspaceImage.src) {
@@ -55,17 +63,65 @@ export class ClipboardManager {
const img = new Image(); const img = new Image();
img.onload = async () => { img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image pasted from Clipspace");
}; };
img.src = clipspaceImage.src; img.src = clipspaceImage.src;
return true; return true;
} }
} }
return false; return false;
} }, 'ClipboardManager.tryClipspacePaste');
catch (clipspaceError) { /**
log.warn("ComfyUI Clipspace paste failed:", clipspaceError); * Loads a local file via the ComfyUI backend endpoint
return false; * @param {string} filePath - The file path to load
} * @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
this.loadFileViaBackend = withErrorHandling(async (filePath, addMode) => {
if (!filePath) {
throw createValidationError("File path is required", { filePath });
}
log.info("Loading file via ComfyUI backend:", filePath);
const response = await api.fetchApi("/ycnode/load_image_from_path", {
method: "POST",
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath
})
});
if (!response.ok) {
const errorData = await response.json();
throw createNetworkError(`Backend failed to load image: ${errorData.error}`, {
filePath,
status: response.status,
statusText: response.statusText
});
}
const data = await response.json();
if (!data.success) {
throw createFileError(`Backend returned error: ${data.error}`, { filePath, backendError: data.error });
}
log.info("Successfully loaded image via ComfyUI backend:", filePath);
const img = new Image();
const success = await new Promise((resolve) => {
img.onload = async () => {
log.info("Successfully loaded image from backend response");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image loaded from file path");
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load image from backend response");
resolve(false);
};
img.src = data.image_data;
});
return success;
}, 'ClipboardManager.loadFileViaBackend');
this.canvas = canvas;
this.clipboardPreference = 'system'; // 'system', 'clipspace'
} }
/** /**
* System clipboard paste - handles both image data and text paths * System clipboard paste - handles both image data and text paths
@@ -89,6 +145,7 @@ export class ClipboardManager {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from system clipboard"); log.info("Successfully loaded image from system clipboard");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image pasted from system clipboard");
}; };
if (event.target?.result) { if (event.target?.result) {
img.src = event.target.result; img.src = event.target.result;
@@ -131,11 +188,22 @@ export class ClipboardManager {
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
log.debug("Found text in clipboard:", text); log.debug("Found text in clipboard:", text);
if (text && this.isValidImagePath(text)) { if (text) {
log.info("Found valid image path in clipboard:", text); // Check if it's a data URI (base64 encoded image)
const success = await this.loadImageFromPath(text, addMode); if (this.isDataURI(text)) {
if (success) { log.info("Found data URI in clipboard");
return true; const success = await this.loadImageFromDataURI(text, addMode);
if (success) {
return true;
}
}
// Check if it's a regular file path or URL
else if (this.isValidImagePath(text)) {
log.info("Found valid image path in clipboard:", text);
const success = await this.loadImageFromPath(text, addMode);
if (success) {
return true;
}
} }
} }
} }
@@ -146,6 +214,48 @@ export class ClipboardManager {
log.debug("No images or valid image paths found in system clipboard"); log.debug("No images or valid image paths found in system clipboard");
return false; return false;
} }
/**
* Checks if a text string is a data URI (base64 encoded image)
* @param {string} text - The text to check
* @returns {boolean} - True if the text is a data URI
*/
isDataURI(text) {
if (!text || typeof text !== 'string') {
return false;
}
// Check if it starts with data:image
return text.trim().startsWith('data:image/');
}
/**
* Loads an image from a data URI (base64 encoded image)
* @param {string} dataURI - The data URI to load
* @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async loadImageFromDataURI(dataURI, addMode) {
return new Promise((resolve) => {
try {
const img = new Image();
img.onload = async () => {
log.info("Successfully loaded image from data URI");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image pasted from clipboard (base64)");
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load image from data URI");
showErrorNotification("Failed to load base64 image from clipboard", 5000, true);
resolve(false);
};
img.src = dataURI;
}
catch (error) {
log.error("Error loading data URI:", error);
showErrorNotification("Error processing base64 image from clipboard", 5000, true);
resolve(false);
}
});
}
/** /**
* Validates if a text string is a valid image file path or URL * Validates if a text string is a valid image file path or URL
* @param {string} text - The text to validate * @param {string} text - The text to validate
@@ -210,10 +320,12 @@ export class ClipboardManager {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from URL"); log.info("Successfully loaded image from URL");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image loaded from URL");
resolve(true); resolve(true);
}; };
img.onerror = () => { img.onerror = () => {
log.warn("Failed to load image from URL:", filePath); log.warn("Failed to load image from URL:", filePath);
showErrorNotification(`Failed to load image from URL\nThe link might be incorrect or may not point to an image file.: ${filePath}`, 5000, true);
resolve(false); resolve(false);
}; };
img.src = filePath; img.src = filePath;
@@ -247,55 +359,6 @@ export class ClipboardManager {
this.showFilePathMessage(filePath); this.showFilePathMessage(filePath);
return false; return false;
} }
/**
* Loads a local file via the ComfyUI backend endpoint
* @param {string} filePath - The file path to load
* @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async loadFileViaBackend(filePath, addMode) {
try {
log.info("Loading file via ComfyUI backend:", filePath);
const response = await api.fetchApi("/ycnode/load_image_from_path", {
method: "POST",
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath
})
});
if (!response.ok) {
const errorData = await response.json();
log.debug("Backend failed to load image:", errorData.error);
return false;
}
const data = await response.json();
if (!data.success) {
log.debug("Backend returned error:", data.error);
return false;
}
log.info("Successfully loaded image via ComfyUI backend:", filePath);
const img = new Image();
const success = await new Promise((resolve) => {
img.onload = async () => {
log.info("Successfully loaded image from backend response");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load image from backend response");
resolve(false);
};
img.src = data.image_data;
});
return success;
}
catch (error) {
log.debug("Error loading file via ComfyUI backend:", error);
return false;
}
}
/** /**
* Prompts the user to select a file when a local path is detected * Prompts the user to select a file when a local path is detected
* @param {string} originalPath - The original file path from clipboard * @param {string} originalPath - The original file path from clipboard
@@ -320,6 +383,7 @@ export class ClipboardManager {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from file picker"); log.info("Successfully loaded image from file picker");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image loaded from selected file");
resolve(true); resolve(true);
}; };
img.onerror = () => { img.onerror = () => {
@@ -352,7 +416,7 @@ export class ClipboardManager {
document.body.removeChild(fileInput); document.body.removeChild(fileInput);
resolve(false); resolve(false);
}; };
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000); showInfoNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
document.body.appendChild(fileInput); document.body.appendChild(fileInput);
fileInput.click(); fileInput.click();
}); });
@@ -364,7 +428,7 @@ export class ClipboardManager {
showFilePathMessage(filePath) { showFilePathMessage(filePath) {
const fileName = filePath.split(/[\\\/]/).pop(); const fileName = filePath.split(/[\\\/]/).pop();
const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`; const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`;
this.showNotification(message, 5000); showNotification(message, "#c54747", 5000);
log.info("Showed file path limitation message to user"); log.info("Showed file path limitation message to user");
} }
/** /**
@@ -431,33 +495,4 @@ export class ClipboardManager {
}, 12000); }, 12000);
log.info("Showed enhanced empty clipboard message with file picker option"); 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

@@ -0,0 +1,99 @@
import { createModuleLogger } from "./LoggerUtils.js";
// @ts-ignore
import { ComfyApp } from "../../../scripts/app.js";
const log = createModuleLogger('ClipspaceUtils');
/**
* Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors
* @returns {boolean} - True if clipspace is valid and ready to use, false otherwise
*/
export function validateAndFixClipspace() {
log.debug("Validating and fixing clipspace structure");
// Check if clipspace exists
if (!ComfyApp.clipspace) {
log.debug("ComfyUI clipspace is not available");
return false;
}
// Validate clipspace structure
if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) {
log.debug("ComfyUI clipspace has no images");
return false;
}
log.debug("Current clipspace state:", {
hasImgs: !!ComfyApp.clipspace.imgs,
imgsLength: ComfyApp.clipspace.imgs?.length,
selectedIndex: ComfyApp.clipspace.selectedIndex,
combinedIndex: ComfyApp.clipspace.combinedIndex,
img_paste_mode: ComfyApp.clipspace.img_paste_mode
});
// Ensure required indices are set
if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) {
ComfyApp.clipspace.selectedIndex = 0;
log.debug("Fixed clipspace selectedIndex to 0");
}
if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) {
ComfyApp.clipspace.combinedIndex = 0;
log.debug("Fixed clipspace combinedIndex to 0");
}
if (!ComfyApp.clipspace.img_paste_mode) {
ComfyApp.clipspace.img_paste_mode = 'selected';
log.debug("Fixed clipspace img_paste_mode to 'selected'");
}
// Ensure indices are within bounds
const maxIndex = ComfyApp.clipspace.imgs.length - 1;
if (ComfyApp.clipspace.selectedIndex > maxIndex) {
ComfyApp.clipspace.selectedIndex = maxIndex;
log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`);
}
if (ComfyApp.clipspace.combinedIndex > maxIndex) {
ComfyApp.clipspace.combinedIndex = maxIndex;
log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`);
}
// Verify the image at combinedIndex exists and has src
const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
if (!combinedImg || !combinedImg.src) {
log.debug("Image at combinedIndex is missing or has no src, trying to find valid image");
// Try to use the first available image
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) {
ComfyApp.clipspace.combinedIndex = i;
log.debug(`Fixed combinedIndex to ${i} (first valid image)`);
break;
}
}
// Final check - if still no valid image found
const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
if (!finalImg || !finalImg.src) {
log.error("No valid images found in clipspace after attempting fixes");
return false;
}
}
log.debug("Final clipspace structure:", {
selectedIndex: ComfyApp.clipspace.selectedIndex,
combinedIndex: ComfyApp.clipspace.combinedIndex,
img_paste_mode: ComfyApp.clipspace.img_paste_mode,
imgsLength: ComfyApp.clipspace.imgs?.length,
combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...'
});
return true;
}
/**
* Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure
* @param {any} node - The ComfyUI node to paste to
* @returns {boolean} - True if paste was successful, false otherwise
*/
export function safeClipspacePaste(node) {
log.debug("Attempting safe clipspace paste");
if (!validateAndFixClipspace()) {
log.debug("Clipspace validation failed, cannot paste");
return false;
}
try {
ComfyApp.pasteFromClipspace(node);
log.debug("Successfully called pasteFromClipspace");
return true;
}
catch (error) {
log.error("Error calling pasteFromClipspace:", error);
return false;
}
}

191
js/utils/IconLoader.js Normal file
View File

@@ -0,0 +1,191 @@
import { createModuleLogger } from "./LoggerUtils.js";
import { createCanvas } from "./CommonUtils.js";
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
const log = createModuleLogger('IconLoader');
// Define tool constants for LayerForge
export const LAYERFORGE_TOOLS = {
VISIBILITY: 'visibility',
MOVE: 'move',
ROTATE: 'rotate',
SCALE: 'scale',
DELETE: 'delete',
DUPLICATE: 'duplicate',
BLEND_MODE: 'blend_mode',
OPACITY: 'opacity',
MASK: 'mask',
BRUSH: 'brush',
ERASER: 'eraser',
SHAPE: 'shape',
SETTINGS: 'settings',
SYSTEM_CLIPBOARD: 'system_clipboard',
CLIPSPACE: 'clipspace',
CROP: 'crop',
TRANSFORM: 'transform',
};
// SVG Icons for LayerForge tools
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <defs> <mask id="cutout"> <rect width="100%" height="100%" fill="white"/> <path d="M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" fill="black" transform="translate(4.8 4.8) scale(0.6)" /> </mask> </defs> <path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z" fill="#ffffff" mask="url(#cutout)" /></svg>`;
const CROP_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 15h3V7c0-1.1-.9-2-2-2H10v3h7v7zM7 18V1H4v4H0v3h4v10c0 2 1 3 3 3h10v4h3v-4h4v-3H24z"/></svg>';
const TRANSFORM_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M11.3 17.096c.092-.044.34-.052 1.028-.044l.912.008.124.124c.184.184.184.408.004.584l-.128.132-.896.012c-.72.008-.924 0-1.036-.048-.18-.072-.284-.264-.256-.452.028-.168.092-.248.248-.316Zm-3.164 0c.096-.044.328-.052 1.036-.044l.916.008.116.132c.16.18.16.396 0 .576l-.116.132-.876.012c-.552.008-.928-.004-1.02-.032-.388-.112-.428-.62-.056-.784Zm-4.6-1.168.112-.096 1.42.004 1.424.004.116.116.116.116V17.48v1.408l-.116.116-.116.116H5.068h-1.42l-.112-.096-.112-.096L3.42 17.48V16.032l.112-.096ZM4.78 12.336c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.964.964l-.116.128c-.1.112-.144.132-.304.132s-.204-.02-.304-.132L4.644 14.4l-.004-.964v-.964l.136-.136Zm8.868-.648c-.008-.024-.004-.048.008-.048s1.504.512 3.312 1.136c1.812.624 4.252 1.464 5.424 1.868 1.168.404 2.128.744 2.128.76 0 .012-.24.108-.528.212-.292.104-1.468.52-2.616.928l-2.08.74-.936 2.62c-.512 1.44-.944 2.616-.956 2.616-.016 0-.86-2.424-1.88-5.392-1.02-2.964-1.864-5.412-1.876-5.44ZM19.292 9.08c.216-.088.432-.02.548.168.076.124.08.188.072 1.06l-.012.928-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12-.012-.928c-.008-.872-.004-.936.072-1.06.044-.072.12-.148.172-.168Zm-14.516.096c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.956c0 1.064-.004 1.088-.268 1.2-.18.072-.376.012-.492-.148-.076-.104-.08-.172-.08-1.06V9.312l.136-.136ZM19.192 6c.096-.088.168-.116.288-.116s.192.028.288.116l.132.116V7.1v.98l-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12V7.096 6.112l.132-.116ZM4.816 5.964c.048-.044.152-.072.256-.072.144 0 .196.02.292.124l.116.124v.98.968l-.116.116c-.092.092-.152.116-.284.116-.408 0-.44-.28-.44-1.22s.012-1.016.176-1.148Zm9.516-3.192.14-.136.968.004h.968l.112.116c.152.152.188.3.108.468-.124.252-.196.276-1.044.288-.42.008-.84.004-.936-.012-.24-.036-.38-.192-.436-.408-.02-.156-.008-.184.12-.312Zm-3.156-.268.136.136h.956c1.064 0 1.088.004 1.2.268.072.172.016.372-.136.492-.096.076-.16.08-1.06.08h-.96l-.136-.136c-.104-.104-.136-.168-.136-.284s.032-.18.136-.284Zm-3.16 0 .136.136h.96c.94 0 .964.004 1.068.088.2.176.196.508-.004.668-.1.08-.156.084-1.064.084h-.96l-.136-.136c-.188-.188-.188-.38 0-.568Zm10.04-1.14c.044-.02.712-.032 1.476-.028l1.396.008.096.112.096.112v1.424 1.5l-.116.116-.116.116L19.48 4.72H18.072l-.116-.116-.116-.116V3.072c0-1.524.004-1.544.216-1.632ZM3.62 1.456c.184-.08 2.74-.08 2.896 0 .196.104.204.164.204 1.604s-.008 1.5-.204 1.604c-.148.076-2.732.084-2.896.008-.212-.096-.22-.148-.22-1.608s.008-1.516.22-1.608Z"/></svg>';
const LAYERFORGE_TOOL_ICONS = {
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
[LAYERFORGE_TOOLS.CROP]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CROP_ICON_SVG)}`,
[LAYERFORGE_TOOLS.TRANSFORM]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRANSFORM_ICON_SVG)}`,
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
[LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,6V9L16,5L12,1V4A8,8 0 0,0 4,12C4,13.57 4.46,15.03 5.24,16.26L6.7,14.8C6.25,13.97 6,13 6,12A6,6 0 0,1 12,6M18.76,7.74L17.3,9.2C17.74,10.04 18,11 18,12A6,6 0 0,1 12,18V15L8,19L12,23V20A8,8 0 0,0 20,12C20,10.43 19.54,8.97 18.76,7.74Z"/></svg>')}`,
[LAYERFORGE_TOOLS.SCALE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M22,18V22H18V20H20V18H22M22,6V10H20V8H18V6H22M2,6V10H4V8H6V6H2M2,18V22H6V20H4V18H2M16,8V10H14V12H16V14H14V16H12V14H10V12H12V10H10V8H12V6H14V8H16Z"/></svg>')}`,
[LAYERFORGE_TOOLS.DELETE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M7,6H17V19H7V6M9,8V17H11V8H9M13,8V17H15V8H13Z"/></svg>')}`,
[LAYERFORGE_TOOLS.DUPLICATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"/></svg>')}`,
[LAYERFORGE_TOOLS.BLEND_MODE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20V4Z"/></svg>')}`,
[LAYERFORGE_TOOLS.OPACITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,20A6,6 0 0,1 6,14C6,10 12,3.25 12,3.25S18,10 18,14A6,6 0 0,1 12,20Z"/></svg>')}`,
[LAYERFORGE_TOOLS.MASK]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="#ffffff" stroke-width="2"/><circle cx="12" cy="12" r="5" fill="#ffffff"/></svg>')}`,
[LAYERFORGE_TOOLS.BRUSH]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M15.4565 9.67503L15.3144 9.53297C14.6661 8.90796 13.8549 8.43369 12.9235 8.18412C10.0168 7.40527 7.22541 9.05273 6.43185 12.0143C6.38901 12.1742 6.36574 12.3537 6.3285 12.8051C6.17423 14.6752 5.73449 16.0697 4.5286 17.4842C6.78847 18.3727 9.46572 18.9986 11.5016 18.9986C13.9702 18.9986 16.1644 17.3394 16.8126 14.9202C17.3306 12.9869 16.7513 11.0181 15.4565 9.67503ZM13.2886 6.21301L18.2278 2.37142C18.6259 2.0618 19.1922 2.09706 19.5488 2.45367L22.543 5.44787C22.8997 5.80448 22.9349 6.37082 22.6253 6.76891L18.7847 11.7068C19.0778 12.8951 19.0836 14.1721 18.7444 15.4379C17.8463 18.7897 14.8142 20.9986 11.5016 20.9986C8 20.9986 3.5 19.4967 1 17.9967C4.97978 14.9967 4.04722 13.1865 4.5 11.4967C5.55843 7.54658 9.34224 5.23935 13.2886 6.21301ZM16.7015 8.09161C16.7673 8.15506 16.8319 8.21964 16.8952 8.28533L18.0297 9.41984L20.5046 6.23786L18.7589 4.4921L15.5769 6.96698L16.7015 8.09161Z"/></svg>')}`,
[LAYERFORGE_TOOLS.ERASER]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M8.58564 8.85449L3.63589 13.8042L8.83021 18.9985L9.99985 18.9978V18.9966H11.1714L14.9496 15.2184L8.58564 8.85449ZM9.99985 7.44027L16.3638 13.8042L19.1922 10.9758L12.8283 4.61185L9.99985 7.44027ZM13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966Z"/></svg>')}`,
[LAYERFORGE_TOOLS.SHAPE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M3 4H21C21.5523 4 22 4.44772 22 5V19C22 19.5523 21.5523 20 21 20H3C2.44772 20 2 19.5523 2 19V5C2 4.44772 2.44772 4 3 4ZM4 6V18H20V6H4Z"/></svg>')}`,
[LAYERFORGE_TOOLS.SETTINGS]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.5,11L5.13,18.93C5.17,19.18 5.38,19.36 5.63,19.36H18.37C18.62,19.36 18.83,19.18 18.87,18.93L19.5,11L21.54,9.37Z"/></svg>')}`
};
// Tool colors for LayerForge
const LAYERFORGE_TOOL_COLORS = {
[LAYERFORGE_TOOLS.VISIBILITY]: '#4285F4',
[LAYERFORGE_TOOLS.MOVE]: '#34A853',
[LAYERFORGE_TOOLS.ROTATE]: '#FBBC05',
[LAYERFORGE_TOOLS.SCALE]: '#EA4335',
[LAYERFORGE_TOOLS.DELETE]: '#FF6D01',
[LAYERFORGE_TOOLS.DUPLICATE]: '#46BDC6',
[LAYERFORGE_TOOLS.BLEND_MODE]: '#9C27B0',
[LAYERFORGE_TOOLS.OPACITY]: '#8BC34A',
[LAYERFORGE_TOOLS.MASK]: '#607D8B',
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292',
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
};
export class IconLoader {
constructor() {
this._iconCache = {};
this._loadingPromises = new Map();
/**
* Preload all LayerForge tool icons
*/
this.preloadToolIcons = withErrorHandling(async () => {
log.info('Starting to preload LayerForge tool icons');
const loadPromises = Object.keys(LAYERFORGE_TOOL_ICONS).map(tool => {
return this.loadIcon(tool);
});
await Promise.all(loadPromises);
log.info(`Successfully preloaded ${loadPromises.length} tool icons`);
}, 'IconLoader.preloadToolIcons');
/**
* Load a specific icon by tool name
*/
this.loadIcon = withErrorHandling(async (tool) => {
if (!tool) {
throw createValidationError("Tool name is required", { tool });
}
// Check if already cached
if (this._iconCache[tool] && this._iconCache[tool] instanceof HTMLImageElement) {
return this._iconCache[tool];
}
// Check if already loading
if (this._loadingPromises.has(tool)) {
return this._loadingPromises.get(tool);
}
// Create fallback canvas first
const fallbackCanvas = this.createFallbackIcon(tool);
this._iconCache[tool] = fallbackCanvas;
// Start loading the SVG icon
const loadPromise = new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
this._iconCache[tool] = img;
this._loadingPromises.delete(tool);
log.debug(`Successfully loaded icon for tool: ${tool}`);
resolve(img);
};
img.onerror = (error) => {
log.warn(`Failed to load SVG icon for tool: ${tool}, using fallback`);
this._loadingPromises.delete(tool);
// Keep the fallback canvas in cache
reject(error);
};
const iconData = LAYERFORGE_TOOL_ICONS[tool];
if (iconData) {
img.src = iconData;
}
else {
log.warn(`No icon data found for tool: ${tool}`);
reject(createValidationError(`No icon data for tool: ${tool}`, { tool, availableTools: Object.keys(LAYERFORGE_TOOL_ICONS) }));
}
});
this._loadingPromises.set(tool, loadPromise);
return loadPromise;
}, 'IconLoader.loadIcon');
log.info('IconLoader initialized');
}
/**
* Create a fallback canvas icon with colored background and text
*/
createFallbackIcon(tool) {
const { canvas, ctx } = createCanvas(24, 24);
if (!ctx) {
log.error('Failed to get canvas context for fallback icon');
return canvas;
}
// Fill background with tool color
const color = LAYERFORGE_TOOL_COLORS[tool] || '#888888';
ctx.fillStyle = color;
ctx.fillRect(0, 0, 24, 24);
// Add border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, 23, 23);
// Add text
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const firstChar = tool.charAt(0).toUpperCase();
ctx.fillText(firstChar, 12, 12);
return canvas;
}
/**
* Get cached icon (canvas or image)
*/
getIcon(tool) {
return this._iconCache[tool] || null;
}
/**
* Check if icon is loaded (as image, not fallback canvas)
*/
isIconLoaded(tool) {
return this._iconCache[tool] instanceof HTMLImageElement;
}
/**
* Clear all cached icons
*/
clearCache() {
this._iconCache = {};
this._loadingPromises.clear();
log.info('Icon cache cleared');
}
/**
* Get all available tool names
*/
getAvailableTools() {
return Object.values(LAYERFORGE_TOOLS);
}
/**
* Get tool color
*/
getToolColor(tool) {
return LAYERFORGE_TOOL_COLORS[tool] || '#888888';
}
}
// Export singleton instance
export const iconLoader = new IconLoader();
// Export for external use
export { LAYERFORGE_TOOL_ICONS, LAYERFORGE_TOOL_COLORS };

230
js/utils/ImageAnalysis.js Normal file
View File

@@ -0,0 +1,230 @@
import { createModuleLogger } from "./LoggerUtils.js";
import { createCanvas } from "./CommonUtils.js";
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
const log = createModuleLogger('ImageAnalysis');
/**
* Creates a distance field mask based on the alpha channel of an image.
* The mask will have gradients from the edges of visible pixels inward.
* @param image - The source image to analyze
* @param blendArea - The percentage (0-100) of the area to apply blending
* @returns HTMLCanvasElement containing the distance field mask
*/
/**
* Synchronous version of createDistanceFieldMask for use in synchronous rendering
*/
export function createDistanceFieldMaskSync(image, blendArea) {
if (!image) {
log.error("Image is required for distance field mask");
return createCanvas(1, 1).canvas;
}
if (typeof blendArea !== 'number' || blendArea < 0 || blendArea > 100) {
log.error("Blend area must be a number between 0 and 100");
return createCanvas(1, 1).canvas;
}
const { canvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
if (!ctx) {
log.error('Failed to create canvas context for distance field mask');
return canvas;
}
// Draw the image to extract pixel data
ctx.drawImage(image, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const width = canvas.width;
const height = canvas.height;
// Check if image has transparency (any alpha < 255)
let hasTransparency = false;
for (let i = 0; i < width * height; i++) {
if (data[i * 4 + 3] < 255) {
hasTransparency = true;
break;
}
}
let distanceField;
let maxDistance;
if (hasTransparency) {
// For images with transparency, use alpha-based distance transform
const binaryMask = new Uint8Array(width * height);
for (let i = 0; i < width * height; i++) {
binaryMask[i] = data[i * 4 + 3] > 0 ? 1 : 0;
}
distanceField = calculateDistanceTransform(binaryMask, width, height);
}
else {
// For opaque images, calculate distance from edges of the rectangle
distanceField = calculateDistanceFromEdges(width, height);
}
// Find the maximum distance to normalize
maxDistance = 0;
for (let i = 0; i < distanceField.length; i++) {
if (distanceField[i] > maxDistance) {
maxDistance = distanceField[i];
}
}
// Create the gradient mask based on blendArea
const maskData = ctx.createImageData(width, height);
const threshold = maxDistance * (blendArea / 100);
for (let i = 0; i < width * height; i++) {
const distance = distanceField[i];
const alpha = data[i * 4 + 3];
if (alpha === 0) {
// Transparent pixels remain transparent
maskData.data[i * 4] = 255;
maskData.data[i * 4 + 1] = 255;
maskData.data[i * 4 + 2] = 255;
maskData.data[i * 4 + 3] = 0;
}
else if (distance <= threshold) {
// Edge area - apply gradient alpha
const gradientValue = distance / threshold;
const alphaValue = Math.floor(gradientValue * 255);
maskData.data[i * 4] = 255;
maskData.data[i * 4 + 1] = 255;
maskData.data[i * 4 + 2] = 255;
maskData.data[i * 4 + 3] = alphaValue;
}
else {
// Inner area - full alpha (no blending effect)
maskData.data[i * 4] = 255;
maskData.data[i * 4 + 1] = 255;
maskData.data[i * 4 + 2] = 255;
maskData.data[i * 4 + 3] = 255;
}
}
// Clear canvas and put the mask data
ctx.clearRect(0, 0, width, height);
ctx.putImageData(maskData, 0, 0);
return canvas;
}
/**
* Async version with error handling for use in async contexts
*/
export const createDistanceFieldMask = withErrorHandling(function (image, blendArea) {
return createDistanceFieldMaskSync(image, blendArea);
}, 'createDistanceFieldMask');
/**
* Calculates the Euclidean distance transform of a binary mask.
* Uses a two-pass algorithm for efficiency.
* @param binaryMask - Binary mask where 1 = inside, 0 = outside
* @param width - Width of the mask
* @param height - Height of the mask
* @returns Float32Array containing distance values
*/
function calculateDistanceTransform(binaryMask, width, height) {
const distances = new Float32Array(width * height);
const infinity = width + height; // A value larger than any possible distance
// Initialize distances
for (let i = 0; i < width * height; i++) {
distances[i] = binaryMask[i] === 1 ? infinity : 0;
}
// Forward pass (top-left to bottom-right)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (distances[idx] > 0) {
let minDist = distances[idx];
// Check top neighbor
if (y > 0) {
minDist = Math.min(minDist, distances[(y - 1) * width + x] + 1);
}
// Check left neighbor
if (x > 0) {
minDist = Math.min(minDist, distances[y * width + (x - 1)] + 1);
}
// Check top-left diagonal
if (x > 0 && y > 0) {
minDist = Math.min(minDist, distances[(y - 1) * width + (x - 1)] + Math.sqrt(2));
}
// Check top-right diagonal
if (x < width - 1 && y > 0) {
minDist = Math.min(minDist, distances[(y - 1) * width + (x + 1)] + Math.sqrt(2));
}
distances[idx] = minDist;
}
}
}
// Backward pass (bottom-right to top-left)
for (let y = height - 1; y >= 0; y--) {
for (let x = width - 1; x >= 0; x--) {
const idx = y * width + x;
if (distances[idx] > 0) {
let minDist = distances[idx];
// Check bottom neighbor
if (y < height - 1) {
minDist = Math.min(minDist, distances[(y + 1) * width + x] + 1);
}
// Check right neighbor
if (x < width - 1) {
minDist = Math.min(minDist, distances[y * width + (x + 1)] + 1);
}
// Check bottom-right diagonal
if (x < width - 1 && y < height - 1) {
minDist = Math.min(minDist, distances[(y + 1) * width + (x + 1)] + Math.sqrt(2));
}
// Check bottom-left diagonal
if (x > 0 && y < height - 1) {
minDist = Math.min(minDist, distances[(y + 1) * width + (x - 1)] + Math.sqrt(2));
}
distances[idx] = minDist;
}
}
}
return distances;
}
/**
* Calculates distance from edges of a rectangle for opaque images.
* @param width - Width of the rectangle
* @param height - Height of the rectangle
* @returns Float32Array containing distance values from edges
*/
function calculateDistanceFromEdges(width, height) {
const distances = new Float32Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
// Calculate distance to nearest edge
const distToLeft = x;
const distToRight = width - 1 - x;
const distToTop = y;
const distToBottom = height - 1 - y;
// Minimum distance to any edge
const minDistToEdge = Math.min(distToLeft, distToRight, distToTop, distToBottom);
distances[idx] = minDistToEdge;
}
}
return distances;
}
/**
* Creates a simple radial gradient mask (fallback for rectangular areas).
* @param width - Width of the mask
* @param height - Height of the mask
* @param blendArea - The percentage (0-100) of the area to apply blending
* @returns HTMLCanvasElement containing the radial gradient mask
*/
export const createRadialGradientMask = withErrorHandling(function (width, height, blendArea) {
if (typeof width !== 'number' || width <= 0) {
throw createValidationError("Width must be a positive number", { width });
}
if (typeof height !== 'number' || height <= 0) {
throw createValidationError("Height must be a positive number", { height });
}
if (typeof blendArea !== 'number' || blendArea < 0 || blendArea > 100) {
throw createValidationError("Blend area must be a number between 0 and 100", { blendArea });
}
const { canvas, ctx } = createCanvas(width, height);
if (!ctx) {
log.error('Failed to create canvas context for radial gradient mask');
return canvas;
}
const centerX = width / 2;
const centerY = height / 2;
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
const innerRadius = maxRadius * (1 - blendArea / 100);
// Create radial gradient
const gradient = ctx.createRadialGradient(centerX, centerY, innerRadius, centerX, centerY, maxRadius);
gradient.addColorStop(0, 'white');
gradient.addColorStop(1, 'black');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
return canvas;
}, 'createRadialGradientMask');

View File

@@ -0,0 +1,131 @@
// @ts-ignore
import { api } from "../../../scripts/api.js";
import { createModuleLogger } from "./LoggerUtils.js";
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
const log = createModuleLogger('ImageUploadUtils');
/**
* Uploads an image blob to ComfyUI server and returns image element
* @param blob - Image blob to upload
* @param options - Upload options
* @returns Promise with upload result
*/
export const uploadImageBlob = withErrorHandling(async function (blob, options = {}) {
if (!blob) {
throw createValidationError("Blob is required", { blob });
}
if (blob.size === 0) {
throw createValidationError("Blob cannot be empty", { blobSize: blob.size });
}
const { filenamePrefix = 'layerforge', overwrite = true, type = 'temp', nodeId } = options;
// Generate unique filename
const timestamp = Date.now();
const nodeIdSuffix = nodeId ? `-${nodeId}` : '';
const filename = `${filenamePrefix}${nodeIdSuffix}-${timestamp}.png`;
log.debug('Uploading image blob:', {
filename,
blobSize: blob.size,
type,
overwrite
});
// Create FormData
const formData = new FormData();
formData.append("image", blob, filename);
formData.append("overwrite", overwrite.toString());
formData.append("type", type);
// Upload to server
const response = await api.fetchApi("/upload/image", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw createNetworkError(`Failed to upload image: ${response.statusText}`, {
status: response.status,
statusText: response.statusText,
filename,
blobSize: blob.size
});
}
const data = await response.json();
log.debug('Image uploaded successfully:', data);
// Create image element with proper URL
const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
const imageElement = new Image();
imageElement.crossOrigin = "anonymous";
// Wait for image to load
await new Promise((resolve, reject) => {
imageElement.onload = () => {
log.debug("Uploaded image loaded successfully", {
width: imageElement.width,
height: imageElement.height,
src: imageElement.src.substring(0, 100) + '...'
});
resolve();
};
imageElement.onerror = (error) => {
log.error("Failed to load uploaded image", error);
reject(createNetworkError("Failed to load uploaded image", { error, imageUrl, filename }));
};
imageElement.src = imageUrl;
});
return {
data,
filename,
imageUrl,
imageElement
};
}, 'uploadImageBlob');
/**
* Uploads canvas content as image blob
* @param canvas - Canvas element or Canvas object with canvasLayers
* @param options - Upload options
* @returns Promise with upload result
*/
export const uploadCanvasAsImage = withErrorHandling(async function (canvas, options = {}) {
if (!canvas) {
throw createValidationError("Canvas is required", { canvas });
}
let blob = null;
// Handle different canvas types
if (canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') {
// LayerForge Canvas object
blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob();
}
else if (canvas instanceof HTMLCanvasElement) {
// Standard HTML Canvas
blob = await new Promise(resolve => canvas.toBlob(resolve));
}
else {
throw createValidationError("Unsupported canvas type", {
canvas,
hasCanvasLayers: !!canvas.canvasLayers,
isHTMLCanvas: canvas instanceof HTMLCanvasElement
});
}
if (!blob) {
throw createValidationError("Failed to generate canvas blob", { canvas, options });
}
return uploadImageBlob(blob, options);
}, 'uploadCanvasAsImage');
/**
* Uploads canvas with mask as image blob
* @param canvas - Canvas object with canvasLayers
* @param options - Upload options
* @returns Promise with upload result
*/
export const uploadCanvasWithMaskAsImage = withErrorHandling(async function (canvas, options = {}) {
if (!canvas) {
throw createValidationError("Canvas is required", { canvas });
}
if (!canvas.canvasLayers || typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob !== 'function') {
throw createValidationError("Canvas does not support mask operations", {
canvas,
hasCanvasLayers: !!canvas.canvasLayers,
hasMaskMethod: !!(canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function')
});
}
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (!blob) {
throw createValidationError("Failed to generate canvas with mask blob", { canvas, options });
}
return uploadImageBlob(blob, options);
}, 'uploadCanvasWithMaskAsImage');

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";
import { createCanvas } from "./CommonUtils.js";
const log = createModuleLogger('ImageUtils'); const log = createModuleLogger('ImageUtils');
export function validateImageData(data) { export function validateImageData(data) {
log.debug("Validating data structure:", { log.debug("Validating data structure:", {
@@ -126,10 +127,7 @@ 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, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image.width;
canvas.height = image.height;
if (ctx) { if (ctx) {
ctx.drawImage(image, 0, 0); ctx.drawImage(image, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
@@ -154,10 +152,7 @@ export const tensorToImage = withErrorHandling(async function (tensor) {
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, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width;
canvas.height = height;
if (ctx) { if (ctx) {
const imageData = ctx.createImageData(width, height); const imageData = ctx.createImageData(width, height);
const data = tensor.data; const data = tensor.data;
@@ -183,15 +178,12 @@ export const resizeImage = withErrorHandling(async function (image, maxWidth, ma
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const originalWidth = image.width; const originalWidth = image.width;
const originalHeight = image.height; const originalHeight = image.height;
const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight); const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
const newWidth = Math.round(originalWidth * scale); const newWidth = Math.round(originalWidth * scale);
const newHeight = Math.round(originalHeight * scale); const newHeight = Math.round(originalHeight * scale);
canvas.width = newWidth; const { canvas, ctx } = createCanvas(newWidth, newHeight, '2d', { willReadFrequently: true });
canvas.height = newHeight;
if (ctx) { if (ctx) {
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high';
@@ -212,10 +204,9 @@ export const imageToBase64 = withErrorHandling(function (image, format = 'png',
if (!image) { if (!image) {
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
canvas.width = image instanceof HTMLImageElement ? image.naturalWidth : image.width; const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
canvas.height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
if (ctx) { if (ctx) {
ctx.drawImage(image, 0, 0); ctx.drawImage(image, 0, 0);
const mimeType = `image/${format}`; const mimeType = `image/${format}`;
@@ -262,10 +253,7 @@ export function createImageFromSource(source) {
}); });
} }
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, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width;
canvas.height = height;
if (ctx) { if (ctx) {
if (color !== 'transparent') { if (color !== 'transparent') {
ctx.fillStyle = color; ctx.fillStyle = color;
@@ -280,3 +268,148 @@ export const createEmptyImage = withErrorHandling(function (width, height, color
} }
throw new Error("Canvas context not available"); throw new Error("Canvas context not available");
}, 'createEmptyImage'); }, 'createEmptyImage');
/**
* Converts a canvas or image to an Image element
* Consolidated from MaskProcessingUtils.convertToImage()
* @param source - Source canvas or image
* @returns Promise with Image element
*/
export async function convertToImage(source) {
if (source instanceof HTMLImageElement) {
return source; // Already an image
}
const image = new Image();
image.src = source.toDataURL();
await new Promise((resolve, reject) => {
image.onload = () => resolve();
image.onerror = reject;
});
return image;
}
/**
* Creates a mask from image source for use in mask editor
* Consolidated from mask_utils.create_mask_from_image_src()
* @param imageSrc - Image source (URL or data URL)
* @returns Promise returning Image object
*/
export function createMaskFromImageSrc(imageSrc) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = imageSrc;
});
}
/**
* Converts canvas to Image for use as mask
* Consolidated from mask_utils.canvas_to_mask_image()
* @param canvas - Canvas to convert
* @returns Promise returning Image object
*/
export function canvasToMaskImage(canvas) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = canvas.toDataURL();
});
}
/**
* Scales an image to fit within specified bounds while maintaining aspect ratio
* @param image - Image to scale
* @param targetWidth - Target width to fit within
* @param targetHeight - Target height to fit within
* @returns Promise with scaled Image element
*/
export async function scaleImageToFit(image, targetWidth, targetHeight) {
const scale = Math.min(targetWidth / image.width, targetHeight / image.height);
const scaledWidth = Math.max(1, Math.round(image.width * scale));
const scaledHeight = Math.max(1, Math.round(image.height * scale));
const { canvas, ctx } = createCanvas(scaledWidth, scaledHeight, '2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create scaled image context");
ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight);
return new Promise((resolve, reject) => {
const scaledImg = new Image();
scaledImg.onload = () => resolve(scaledImg);
scaledImg.onerror = reject;
scaledImg.src = canvas.toDataURL();
});
}
/**
* Unified tensor to image data conversion
* Handles both RGB images and grayscale masks
* @param tensor - Input tensor data
* @param mode - 'rgb' for images or 'grayscale' for masks
* @returns ImageData object
*/
export function tensorToImageData(tensor, mode = 'rgb') {
try {
const shape = tensor.shape;
const height = shape[1];
const width = shape[2];
const channels = shape[3] || 1; // Default to 1 for masks
log.debug("Converting tensor:", { shape, channels, mode });
const imageData = new ImageData(width, height);
const data = new Uint8ClampedArray(width * height * 4);
const flatData = tensor.data;
const pixelCount = width * height;
const min = tensor.min_val ?? 0;
const max = tensor.max_val ?? 1;
const denom = (max - min) || 1;
for (let i = 0; i < pixelCount; i++) {
const pixelIndex = i * 4;
const tensorIndex = i * channels;
let lum;
if (mode === 'grayscale' || channels === 1) {
lum = flatData[tensorIndex];
}
else {
// Compute luminance for RGB
const r = flatData[tensorIndex + 0] ?? 0;
const g = flatData[tensorIndex + 1] ?? 0;
const b = flatData[tensorIndex + 2] ?? 0;
lum = 0.299 * r + 0.587 * g + 0.114 * b;
}
let norm = (lum - min) / denom;
if (!isFinite(norm))
norm = 0;
norm = Math.max(0, Math.min(1, norm));
const value = Math.round(norm * 255);
if (mode === 'grayscale') {
// For masks: RGB = value, A = 255 (MaskTool reads luminance)
data[pixelIndex] = value;
data[pixelIndex + 1] = value;
data[pixelIndex + 2] = value;
data[pixelIndex + 3] = 255;
}
else {
// For images: RGB from channels, A = 255
for (let c = 0; c < Math.min(3, channels); c++) {
const channelValue = flatData[tensorIndex + c];
const channelNorm = (channelValue - min) / denom;
data[pixelIndex + c] = Math.round(channelNorm * 255);
}
data[pixelIndex + 3] = 255;
}
}
imageData.data.set(data);
return imageData;
}
catch (error) {
log.error("Error converting tensor:", error);
return null;
}
}
/**
* Creates an HTMLImageElement from ImageData
* @param imageData - Input ImageData
* @returns Promise with HTMLImageElement
*/
export async function createImageFromImageData(imageData) {
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
if (!ctx)
throw new Error("Could not create canvas context");
ctx.putImageData(imageData, 0, 0);
return await createImageFromSource(canvas.toDataURL());
}

View File

@@ -0,0 +1,170 @@
import { createModuleLogger } from "./LoggerUtils.js";
import { createCanvas } from "./CommonUtils.js";
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
const log = createModuleLogger('MaskProcessingUtils');
/**
* Processes an image to create a mask with inverted alpha channel
* @param sourceImage - Source image or canvas element
* @param options - Processing options
* @returns Promise with processed mask as HTMLCanvasElement
*/
export const processImageToMask = withErrorHandling(async function (sourceImage, options = {}) {
if (!sourceImage) {
throw createValidationError("Source image is required", { sourceImage });
}
const { targetWidth = sourceImage.width, targetHeight = sourceImage.height, invertAlpha = true, maskColor = { r: 255, g: 255, b: 255 } } = options;
log.debug('Processing image to mask:', {
sourceSize: { width: sourceImage.width, height: sourceImage.height },
targetSize: { width: targetWidth, height: targetHeight },
invertAlpha,
maskColor
});
// Create temporary canvas for processing
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true });
if (!tempCtx) {
throw createValidationError("Failed to get 2D context for mask processing");
}
// Draw the source image
tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight);
// Get image data for processing
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
const data = imageData.data;
// Process pixels to create mask
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
// Set RGB to mask color
data[i] = maskColor.r; // Red
data[i + 1] = maskColor.g; // Green
data[i + 2] = maskColor.b; // Blue
// Handle alpha channel
if (invertAlpha) {
data[i + 3] = 255 - originalAlpha; // Invert alpha
}
else {
data[i + 3] = originalAlpha; // Keep original alpha
}
}
// Put processed data back to canvas
tempCtx.putImageData(imageData, 0, 0);
log.debug('Mask processing completed');
return tempCanvas;
}, 'processImageToMask');
/**
* Processes image data with custom pixel transformation
* @param sourceImage - Source image or canvas element
* @param pixelTransform - Custom pixel transformation function
* @param options - Processing options
* @returns Promise with processed image as HTMLCanvasElement
*/
export const processImageWithTransform = withErrorHandling(async function (sourceImage, pixelTransform, options = {}) {
if (!sourceImage) {
throw createValidationError("Source image is required", { sourceImage });
}
if (!pixelTransform || typeof pixelTransform !== 'function') {
throw createValidationError("Pixel transform function is required", { pixelTransform });
}
const { targetWidth = sourceImage.width, targetHeight = sourceImage.height } = options;
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true });
if (!tempCtx) {
throw createValidationError("Failed to get 2D context for image processing");
}
tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight);
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const [r, g, b, a] = pixelTransform(data[i], data[i + 1], data[i + 2], data[i + 3], i / 4);
data[i] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = a;
}
tempCtx.putImageData(imageData, 0, 0);
return tempCanvas;
}, 'processImageWithTransform');
/**
* Crops an image to a specific region
* @param sourceImage - Source image or canvas
* @param cropArea - Crop area {x, y, width, height}
* @returns Promise with cropped image as HTMLCanvasElement
*/
export const cropImage = withErrorHandling(async function (sourceImage, cropArea) {
if (!sourceImage) {
throw createValidationError("Source image is required", { sourceImage });
}
if (!cropArea || typeof cropArea !== 'object') {
throw createValidationError("Crop area is required", { cropArea });
}
const { x, y, width, height } = cropArea;
if (width <= 0 || height <= 0) {
throw createValidationError("Crop area must have positive width and height", { cropArea });
}
log.debug('Cropping image:', {
sourceSize: { width: sourceImage.width, height: sourceImage.height },
cropArea
});
const { canvas, ctx } = createCanvas(width, height);
if (!ctx) {
throw createValidationError("Failed to get 2D context for image cropping");
}
ctx.drawImage(sourceImage, x, y, width, height, // Source rectangle
0, 0, width, height // Destination rectangle
);
return canvas;
}, 'cropImage');
/**
* Applies a mask to an image using viewport positioning
* @param maskImage - Mask image or canvas
* @param targetWidth - Target viewport width
* @param targetHeight - Target viewport height
* @param viewportOffset - Viewport offset {x, y}
* @param maskColor - Mask color (default: white)
* @returns Promise with processed mask for viewport
*/
export const processMaskForViewport = withErrorHandling(async function (maskImage, targetWidth, targetHeight, viewportOffset, maskColor = { r: 255, g: 255, b: 255 }) {
if (!maskImage) {
throw createValidationError("Mask image is required", { maskImage });
}
if (!viewportOffset || typeof viewportOffset !== 'object') {
throw createValidationError("Viewport offset is required", { viewportOffset });
}
if (targetWidth <= 0 || targetHeight <= 0) {
throw createValidationError("Target dimensions must be positive", { targetWidth, targetHeight });
}
log.debug("Processing mask for viewport:", {
sourceSize: { width: maskImage.width, height: maskImage.height },
targetSize: { width: targetWidth, height: targetHeight },
viewportOffset
});
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true });
if (!tempCtx) {
throw createValidationError("Failed to get 2D context for viewport mask processing");
}
// Calculate source coordinates based on viewport offset
const sourceX = -viewportOffset.x;
const sourceY = -viewportOffset.y;
// Draw the mask with viewport cropping
tempCtx.drawImage(maskImage, // Source: full mask from "output area"
sourceX, // sx: Real X coordinate on large mask
sourceY, // sy: Real Y coordinate on large mask
targetWidth, // sWidth: Width of cropped fragment
targetHeight, // sHeight: Height of cropped fragment
0, // dx: Where to paste in target canvas (always 0)
0, // dy: Where to paste in target canvas (always 0)
targetWidth, // dWidth: Width of pasted image
targetHeight // dHeight: Height of pasted image
);
// Apply mask color
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0) {
data[i] = maskColor.r;
data[i + 1] = maskColor.g;
data[i + 2] = maskColor.b;
}
}
tempCtx.putImageData(imageData, 0, 0);
log.debug("Viewport mask processing completed");
return tempCanvas;
}, 'processMaskForViewport');

View File

@@ -0,0 +1,303 @@
import { createModuleLogger } from "./LoggerUtils.js";
const log = createModuleLogger('NotificationUtils');
// Store active notifications for deduplication
const activeNotifications = new Map();
/**
* Utility functions for showing notifications to the user
*/
/**
* Shows a temporary notification to the user
* @param message - The message to show
* @param backgroundColor - Background color (default: #4a6cd4)
* @param duration - Duration in milliseconds (default: 3000)
* @param type - Type of notification
* @param deduplicate - If true, will not show duplicate messages and will refresh existing ones (default: false)
*/
export function showNotification(message, backgroundColor = "#4a6cd4", duration = 3000, type = "info", deduplicate = false) {
// Remove any existing prefix to avoid double prefixing
message = message.replace(/^\[Layer Forge\]\s*/, "");
// If deduplication is enabled, check if this message already exists
if (deduplicate) {
const existingNotification = activeNotifications.get(message);
if (existingNotification) {
log.debug(`Notification already exists, refreshing timer: ${message}`);
// Clear existing timeout
if (existingNotification.timeout !== null) {
clearTimeout(existingNotification.timeout);
}
// Find the progress bar and restart its animation
const progressBar = existingNotification.element.querySelector('div[style*="animation"]');
if (progressBar) {
// Reset animation
progressBar.style.animation = 'none';
// Force reflow
void progressBar.offsetHeight;
// Restart animation
progressBar.style.animation = `lf-progress ${duration / 1000}s linear`;
}
// Set new timeout
const newTimeout = window.setTimeout(() => {
const notification = existingNotification.element;
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
notification.addEventListener('animationend', () => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
activeNotifications.delete(message);
const container = document.getElementById('lf-notification-container');
if (container && container.children.length === 0) {
container.remove();
}
}
});
}, duration);
existingNotification.timeout = newTimeout;
return; // Don't create a new notification
}
}
// Type-specific config
const config = {
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
error: { icon: "❌", title: "Error", bg: "#ff6f6f" },
info: { icon: "", title: "Info", bg: "#4a6cd4" },
warning: { icon: "⚠️", title: "Warning", bg: "#ffd43b" },
alert: { icon: "⚠️", title: "Alert", bg: "#fff7cc" }
}[type];
// --- Get or create the main notification container ---
let container = document.getElementById('lf-notification-container');
if (!container) {
container = document.createElement('div');
container.id = 'lf-notification-container';
container.style.cssText = `
position: fixed;
top: 24px;
right: 24px;
z-index: 10001;
display: flex;
flex-direction: row-reverse;
gap: 16px;
align-items: flex-start;
`;
document.body.appendChild(container);
}
// --- Dark, modern notification style ---
const notification = document.createElement('div');
notification.style.cssText = `
min-width: 380px;
max-width: 440px;
max-height: 80vh;
background: rgba(30, 32, 41, 0.9);
color: #fff;
border-radius: 12px;
box-shadow: 0 4px 32px rgba(0,0,0,0.25);
display: flex;
flex-direction: column;
padding: 0;
font-family: 'Segoe UI', 'Arial', sans-serif;
overflow: hidden;
border: 1px solid rgba(80, 80, 80, 0.5);
backdrop-filter: blur(8px);
animation: lf-fadein 0.3s ease-out;
`;
// --- Header (non-scrollable) ---
const header = document.createElement('div');
header.style.cssText = `display: flex; align-items: flex-start; padding: 16px 20px; position: relative; flex-shrink: 0;`;
const leftBar = document.createElement('div');
leftBar.style.cssText = `position: absolute; left: 0; top: 0; bottom: 0; width: 6px; background: ${config.bg}; box-shadow: 0 0 12px ${config.bg}; border-radius: 3px 0 0 3px;`;
const iconContainer = document.createElement('div');
iconContainer.style.cssText = `width: 48px; height: 48px; min-width: 48px; min-height: 48px; display: flex; align-items: center; justify-content: center; margin-left: 18px; margin-right: 18px;`;
iconContainer.innerHTML = {
success: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-succ"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 4 L44 14 L44 34 L24 44 L4 34 L4 14 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/><g filter="url(#f-succ)"><path d="M16 24 L22 30 L34 18" stroke="#fff" stroke-width="3" fill="none"/></g></svg>`,
error: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-err"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M14 14 L34 34 M34 14 L14 34" fill="none" stroke="#fff" stroke-width="3"/><g filter="url(#f-err)"><path d="M24,4 L42,12 L42,36 L24,44 L6,36 L6,12 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
info: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-info"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 16 M24 22 L24 34" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-info)"><path d="M12,4 L36,4 L44,12 L44,36 L36,44 L12,44 L4,36 L4,12 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
warning: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-warn"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 28 M24 34 L24 36" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-warn)"><path d="M24,4 L46,24 L24,44 L2,24 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
alert: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-alert"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 28 M24 34 L24 36" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-alert)"><path d="M24,4 L46,24 L24,44 L2,24 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`
}[type];
const headerTextContent = document.createElement('div');
headerTextContent.style.cssText = `display: flex; flex-direction: column; justify-content: center; flex: 1; min-width: 0;`;
const titleSpan = document.createElement('div');
titleSpan.style.cssText = `font-weight: 700; font-size: 16px; margin-bottom: 4px; color: #fff; text-transform: uppercase; letter-spacing: 0.5px;`;
titleSpan.textContent = config.title;
headerTextContent.appendChild(titleSpan);
const topRightContainer = document.createElement('div');
topRightContainer.style.cssText = `position: absolute; top: 14px; right: 18px; display: flex; align-items: center; gap: 12px;`;
const tag = document.createElement('span');
tag.style.cssText = `font-size: 11px; font-weight: 600; color: #fff; background: ${config.bg}; border-radius: 4px; padding: 2px 8px; box-shadow: 0 0 8px ${config.bg};`;
tag.innerHTML = '🎨 Layer Forge';
const getTextColorForBg = (hexColor) => {
const r = parseInt(hexColor.slice(1, 3), 16), g = parseInt(hexColor.slice(3, 5), 16), b = parseInt(hexColor.slice(5, 7), 16);
return ((0.299 * r + 0.587 * g + 0.114 * b) / 255) > 0.5 ? '#000' : '#fff';
};
tag.style.color = getTextColorForBg(config.bg);
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '&times;';
closeBtn.setAttribute("aria-label", "Close notification");
closeBtn.style.cssText = `background: none; border: none; color: #ccc; font-size: 22px; font-weight: bold; cursor: pointer; padding: 0; opacity: 0.7; transition: opacity 0.15s; line-height: 1;`;
topRightContainer.appendChild(tag);
topRightContainer.appendChild(closeBtn);
header.appendChild(iconContainer);
header.appendChild(headerTextContent);
header.appendChild(topRightContainer);
// --- Scrollable Body ---
const body = document.createElement('div');
body.style.cssText = `padding: 0px 20px 16px 20px; overflow-y: auto; flex: 1;`;
const msgSpan = document.createElement('div');
msgSpan.style.cssText = `font-size: 14px; color: #ccc; line-height: 1.5; white-space: pre-wrap; word-break: break-word;`;
msgSpan.textContent = message;
body.appendChild(msgSpan);
// --- Progress Bar ---
const progressBar = document.createElement('div');
progressBar.style.cssText = `height: 4px; width: 100%; background: ${config.bg}; box-shadow: 0 0 12px ${config.bg}; transform-origin: left; animation: lf-progress ${duration / 1000}s linear; flex-shrink: 0;`;
// --- Assemble Notification ---
notification.appendChild(leftBar);
notification.appendChild(header);
notification.appendChild(body);
if (type === 'error') {
const footer = document.createElement('div');
footer.style.cssText = `padding: 0 20px 12px 86px; flex-shrink: 0;`;
const copyButton = document.createElement('button');
copyButton.textContent = 'Copy Error';
copyButton.style.cssText = `background: rgba(255, 111, 111, 0.2); border: 1px solid #ff6f6f; color: #ffafaf; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; transition: background 0.2s;`;
copyButton.onmouseenter = () => copyButton.style.background = 'rgba(255, 111, 111, 0.3)';
copyButton.onmouseleave = () => copyButton.style.background = 'rgba(255, 111, 111, 0.2)';
copyButton.onclick = () => {
navigator.clipboard.writeText(message)
.then(() => showSuccessNotification("Error message copied!", 2000))
.catch(err => console.error('Failed to copy error message: ', err));
};
footer.appendChild(copyButton);
notification.appendChild(footer);
}
notification.appendChild(progressBar);
// Add to DOM
container.appendChild(notification);
// --- Keyframes and Timer Logic ---
const styleSheet = document.getElementById('lf-notification-styles');
if (!styleSheet) {
const newStyleSheet = document.createElement("style");
newStyleSheet.id = 'lf-notification-styles';
newStyleSheet.innerText = `
@keyframes lf-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
@keyframes lf-progress-rewind { to { transform: scaleX(1); } }
@keyframes lf-fadein { from { opacity: 0; transform: scale(0.95) translateX(20px); } to { opacity: 1; transform: scale(1) translateX(0); } }
@keyframes lf-fadeout { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } }
.notification-scrollbar::-webkit-scrollbar { width: 8px; }
.notification-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); border-radius: 4px; }
.notification-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.25); border-radius: 4px; }
.notification-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.4); }
`;
document.head.appendChild(newStyleSheet);
}
body.classList.add('notification-scrollbar');
let dismissTimeout = null;
const closeNotification = () => {
// Remove from active notifications map if deduplicate is enabled
if (deduplicate) {
activeNotifications.delete(message);
}
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
notification.addEventListener('animationend', () => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
if (container && container.children.length === 0) {
container.remove();
}
}
});
};
closeBtn.onclick = closeNotification;
const startDismissTimer = () => {
dismissTimeout = window.setTimeout(closeNotification, duration);
progressBar.style.animation = `lf-progress ${duration / 1000}s linear`;
};
const pauseAndRewindTimer = () => {
if (dismissTimeout !== null)
clearTimeout(dismissTimeout);
dismissTimeout = null;
const computedStyle = window.getComputedStyle(progressBar);
progressBar.style.transform = computedStyle.transform;
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
};
notification.addEventListener('mouseenter', () => {
pauseAndRewindTimer();
// Update stored timeout if deduplicate is enabled
if (deduplicate) {
const stored = activeNotifications.get(message);
if (stored) {
stored.timeout = null;
}
}
});
notification.addEventListener('mouseleave', () => {
startDismissTimer();
// Update stored timeout if deduplicate is enabled
if (deduplicate) {
const stored = activeNotifications.get(message);
if (stored) {
stored.timeout = dismissTimeout;
}
}
});
startDismissTimer();
// Store notification if deduplicate is enabled
if (deduplicate) {
activeNotifications.set(message, { element: notification, timeout: dismissTimeout });
}
log.debug(`Notification shown: [Layer Forge] ${message}`);
}
/**
* Shows a success notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 3000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showSuccessNotification(message, duration = 3000, deduplicate = false) {
showNotification(message, undefined, duration, "success", deduplicate);
}
/**
* Shows an error notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 5000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showErrorNotification(message, duration = 5000, deduplicate = false) {
showNotification(message, undefined, duration, "error", deduplicate);
}
/**
* Shows an info notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 3000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showInfoNotification(message, duration = 3000, deduplicate = false) {
showNotification(message, undefined, duration, "info", deduplicate);
}
/**
* Shows a warning notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 3000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showWarningNotification(message, duration = 3000, deduplicate = false) {
showNotification(message, undefined, duration, "warning", deduplicate);
}
/**
* Shows an alert notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 3000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showAlertNotification(message, duration = 3000, deduplicate = false) {
showNotification(message, undefined, duration, "alert", deduplicate);
}
/**
* Shows a sequence of all notification types for debugging purposes.
*/
export function showAllNotificationTypes(message) {
const types = ["success", "error", "info", "warning", "alert"];
types.forEach((type, index) => {
const notificationMessage = message || `This is a '${type}' notification.`;
setTimeout(() => {
showNotification(notificationMessage, undefined, 3000, type, false);
}, index * 400); // Stagger the notifications
});
}

192
js/utils/PreviewUtils.js Normal file
View File

@@ -0,0 +1,192 @@
import { createModuleLogger } from "./LoggerUtils.js";
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
const log = createModuleLogger('PreviewUtils');
/**
* Creates a preview image from canvas and updates node
* @param canvas - Canvas object with canvasLayers
* @param node - ComfyUI node to update
* @param options - Preview options
* @returns Promise with created Image element
*/
export const createPreviewFromCanvas = withErrorHandling(async function (canvas, node, options = {}) {
if (!canvas) {
throw createValidationError("Canvas is required", { canvas });
}
if (!node) {
throw createValidationError("Node is required", { node });
}
const { includeMask = true, updateNodeImages = true, customBlob } = options;
log.debug('Creating preview from canvas:', {
includeMask,
updateNodeImages,
hasCustomBlob: !!customBlob,
nodeId: node.id
});
let blob = customBlob || null;
// Get blob from canvas if not provided
if (!blob) {
if (!canvas.canvasLayers) {
throw createValidationError("Canvas does not have canvasLayers", { canvas });
}
if (includeMask && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function') {
blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
}
else if (typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') {
blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob();
}
else {
throw createValidationError("Canvas does not support required blob generation methods", {
canvas,
availableMethods: Object.getOwnPropertyNames(canvas.canvasLayers)
});
}
}
if (!blob) {
throw createValidationError("Failed to generate canvas blob for preview", { canvas, options });
}
// Create preview image
const previewImage = new Image();
previewImage.src = URL.createObjectURL(blob);
// Wait for image to load
await new Promise((resolve, reject) => {
previewImage.onload = () => {
log.debug("Preview image loaded successfully", {
width: previewImage.width,
height: previewImage.height,
nodeId: node.id
});
resolve();
};
previewImage.onerror = (error) => {
log.error("Failed to load preview image", error);
reject(createValidationError("Failed to load preview image", { error, blob: blob?.size }));
};
});
// Update node images if requested
if (updateNodeImages) {
node.imgs = [previewImage];
log.debug("Node images updated with new preview");
}
return previewImage;
}, 'createPreviewFromCanvas');
/**
* Creates a preview image from a blob
* @param blob - Image blob
* @param node - ComfyUI node to update (optional)
* @param updateNodeImages - Whether to update node.imgs (default: false)
* @returns Promise with created Image element
*/
export const createPreviewFromBlob = withErrorHandling(async function (blob, node, updateNodeImages = false) {
if (!blob) {
throw createValidationError("Blob is required", { blob });
}
if (blob.size === 0) {
throw createValidationError("Blob cannot be empty", { blobSize: blob.size });
}
log.debug('Creating preview from blob:', {
blobSize: blob.size,
updateNodeImages,
hasNode: !!node
});
const previewImage = new Image();
previewImage.src = URL.createObjectURL(blob);
await new Promise((resolve, reject) => {
previewImage.onload = () => {
log.debug("Preview image from blob loaded successfully", {
width: previewImage.width,
height: previewImage.height
});
resolve();
};
previewImage.onerror = (error) => {
log.error("Failed to load preview image from blob", error);
reject(createValidationError("Failed to load preview image from blob", { error, blobSize: blob.size }));
};
});
if (updateNodeImages && node) {
node.imgs = [previewImage];
log.debug("Node images updated with blob preview");
}
return previewImage;
}, 'createPreviewFromBlob');
/**
* Updates node preview after canvas changes
* @param canvas - Canvas object
* @param node - ComfyUI node
* @param includeMask - Whether to include mask in preview
* @returns Promise with updated preview image
*/
export const updateNodePreview = withErrorHandling(async function (canvas, node, includeMask = true) {
if (!canvas) {
throw createValidationError("Canvas is required", { canvas });
}
if (!node) {
throw createValidationError("Node is required", { node });
}
log.info('Updating node preview:', {
nodeId: node.id,
includeMask
});
// Trigger canvas render and save state
if (typeof canvas.render === 'function') {
canvas.render();
}
if (typeof canvas.saveState === 'function') {
canvas.saveState();
}
// Create new preview
const previewImage = await createPreviewFromCanvas(canvas, node, {
includeMask,
updateNodeImages: true
});
log.info('Node preview updated successfully');
return previewImage;
}, 'updateNodePreview');
/**
* Clears node preview images
* @param node - ComfyUI node
*/
export function clearNodePreview(node) {
log.debug('Clearing node preview:', { nodeId: node.id });
node.imgs = [];
}
/**
* Checks if node has preview images
* @param node - ComfyUI node
* @returns True if node has preview images
*/
export function hasNodePreview(node) {
return !!(node.imgs && node.imgs.length > 0 && node.imgs[0].src);
}
/**
* Gets the current preview image from node
* @param node - ComfyUI node
* @returns Current preview image or null
*/
export function getCurrentPreview(node) {
if (hasNodePreview(node) && node.imgs) {
return node.imgs[0];
}
return null;
}
/**
* Creates a preview with custom processing
* @param canvas - Canvas object
* @param node - ComfyUI node
* @param processor - Custom processing function that takes canvas and returns blob
* @returns Promise with processed preview image
*/
export const createCustomPreview = withErrorHandling(async function (canvas, node, processor) {
if (!canvas) {
throw createValidationError("Canvas is required", { canvas });
}
if (!node) {
throw createValidationError("Node is required", { node });
}
if (!processor || typeof processor !== 'function') {
throw createValidationError("Processor function is required", { processor });
}
log.debug('Creating custom preview:', { nodeId: node.id });
const blob = await processor(canvas);
return createPreviewFromBlob(blob, node, true);
}, 'createCustomPreview');

View File

@@ -1,6 +1,13 @@
// @ts-ignore // @ts-ignore
import { $el } from "../../../scripts/ui.js"; import { $el } from "../../../scripts/ui.js";
export function addStylesheet(url) { import { createModuleLogger } from "./LoggerUtils.js";
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
const log = createModuleLogger('ResourceManager');
export const addStylesheet = withErrorHandling(function (url) {
if (!url) {
throw createValidationError("URL is required", { url });
}
log.debug('Adding stylesheet:', { url });
if (url.endsWith(".js")) { if (url.endsWith(".js")) {
url = url.substr(0, url.length - 2) + "css"; url = url.substr(0, url.length - 2) + "css";
} }
@@ -10,8 +17,12 @@ export function addStylesheet(url) {
type: "text/css", type: "text/css",
href: url.startsWith("http") ? url : getUrl(url), href: url.startsWith("http") ? url : getUrl(url),
}); });
} log.debug('Stylesheet added successfully:', { finalUrl: url });
}, 'addStylesheet');
export function getUrl(path, baseUrl) { export function getUrl(path, baseUrl) {
if (!path) {
throw createValidationError("Path is required", { path });
}
if (baseUrl) { if (baseUrl) {
return new URL(path, baseUrl).toString(); return new URL(path, baseUrl).toString();
} }
@@ -20,11 +31,21 @@ export function getUrl(path, baseUrl) {
return new URL("../" + path, import.meta.url).toString(); return new URL("../" + path, import.meta.url).toString();
} }
} }
export async function loadTemplate(path, baseUrl) { export const loadTemplate = withErrorHandling(async function (path, baseUrl) {
if (!path) {
throw createValidationError("Path is required", { path });
}
const url = getUrl(path, baseUrl); const url = getUrl(path, baseUrl);
log.debug('Loading template:', { path, url });
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load template: ${url}`); throw createNetworkError(`Failed to load template: ${url}`, {
url,
status: response.status,
statusText: response.statusText
});
} }
return await response.text(); const content = await response.text();
} log.debug('Template loaded successfully:', { path, contentLength: content.length });
return content;
}, 'loadTemplate');

View File

@@ -1,30 +1,23 @@
import { createModuleLogger } from "./LoggerUtils.js"; import { createModuleLogger } from "./LoggerUtils.js";
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
const log = createModuleLogger('WebSocketManager'); const log = createModuleLogger('WebSocketManager');
class WebSocketManager { class WebSocketManager {
constructor(url) { constructor(url) {
this.url = url; this.url = url;
this.socket = null; this.connect = withErrorHandling(() => {
this.messageQueue = []; if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.isConnecting = false; log.debug("WebSocket is already open.");
this.reconnectAttempts = 0; return;
this.maxReconnectAttempts = 10; }
this.reconnectInterval = 5000; // 5 seconds if (this.isConnecting) {
this.ackCallbacks = new Map(); log.debug("Connection attempt already in progress.");
this.messageIdCounter = 0; return;
this.connect(); }
} if (!this.url) {
connect() { throw createValidationError("WebSocket URL is required", { url: this.url });
if (this.socket && this.socket.readyState === WebSocket.OPEN) { }
log.debug("WebSocket is already open."); this.isConnecting = true;
return; log.info(`Connecting to WebSocket at ${this.url}...`);
}
if (this.isConnecting) {
log.debug("Connection attempt already in progress.");
return;
}
this.isConnecting = true;
log.info(`Connecting to WebSocket at ${this.url}...`);
try {
this.socket = new WebSocket(this.url); this.socket = new WebSocket(this.url);
this.socket.onopen = () => { this.socket.onopen = () => {
this.isConnecting = false; this.isConnecting = false;
@@ -61,14 +54,71 @@ class WebSocketManager {
}; };
this.socket.onerror = (error) => { this.socket.onerror = (error) => {
this.isConnecting = false; this.isConnecting = false;
log.error("WebSocket error:", error); throw createNetworkError("WebSocket connection error", { error, url: this.url });
}; };
} }, 'WebSocketManager.connect');
catch (error) { this.sendMessage = withErrorHandling(async (data, requiresAck = false) => {
this.isConnecting = false; if (!data || typeof data !== 'object') {
log.error("Failed to create WebSocket connection:", error); throw createValidationError("Message data is required", { data });
this.handleReconnect(); }
} const nodeId = data.nodeId;
if (requiresAck && !nodeId) {
throw createValidationError("A nodeId is required for messages that need acknowledgment", { data, requiresAck });
}
return new Promise((resolve, reject) => {
const message = JSON.stringify(data);
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(message);
log.debug("Sent message:", data);
if (requiresAck && nodeId) {
log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`);
const timeout = setTimeout(() => {
this.ackCallbacks.delete(nodeId);
reject(createNetworkError(`ACK timeout for nodeId ${nodeId}`, { nodeId, timeout: 10000 }));
log.warn(`ACK timeout for nodeId ${nodeId}.`);
}, 10000); // 10-second timeout
this.ackCallbacks.set(nodeId, {
resolve: (responseData) => {
clearTimeout(timeout);
resolve(responseData);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
}
});
}
else {
resolve(); // Resolve immediately if no ACK is needed
}
}
else {
log.warn("WebSocket not open. Queuing message.");
this.messageQueue.push(message);
if (!this.isConnecting) {
this.connect();
}
if (requiresAck) {
reject(createNetworkError("Cannot send message with ACK required while disconnected", {
socketState: this.socket?.readyState,
isConnecting: this.isConnecting
}));
}
else {
resolve();
}
}
});
}, 'WebSocketManager.sendMessage');
this.socket = null;
this.messageQueue = [];
this.isConnecting = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectInterval = 5000; // 5 seconds
this.ackCallbacks = new Map();
this.messageIdCounter = 0;
this.connect();
} }
handleReconnect() { handleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) { if (this.reconnectAttempts < this.maxReconnectAttempts) {
@@ -80,53 +130,6 @@ class WebSocketManager {
log.error("Max reconnect attempts reached. Giving up."); log.error("Max reconnect attempts reached. Giving up.");
} }
} }
sendMessage(data, requiresAck = false) {
return new Promise((resolve, reject) => {
const nodeId = data.nodeId;
if (requiresAck && !nodeId) {
return reject(new Error("A nodeId is required for messages that need acknowledgment."));
}
const message = JSON.stringify(data);
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(message);
log.debug("Sent message:", data);
if (requiresAck && nodeId) {
log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`);
const timeout = setTimeout(() => {
this.ackCallbacks.delete(nodeId);
reject(new Error(`ACK timeout for nodeId ${nodeId}`));
log.warn(`ACK timeout for nodeId ${nodeId}.`);
}, 10000); // 10-second timeout
this.ackCallbacks.set(nodeId, {
resolve: (responseData) => {
clearTimeout(timeout);
resolve(responseData);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
}
});
}
else {
resolve(); // Resolve immediately if no ACK is needed
}
}
else {
log.warn("WebSocket not open. Queuing message.");
this.messageQueue.push(message);
if (!this.isConnecting) {
this.connect();
}
if (requiresAck) {
reject(new Error("Cannot send message with ACK required while disconnected."));
}
else {
resolve();
}
}
});
}
flushMessageQueue() { flushMessageQueue() {
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) {

View File

@@ -1,4 +1,5 @@
import { createModuleLogger } from "./LoggerUtils.js"; import { createModuleLogger } from "./LoggerUtils.js";
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
const log = createModuleLogger('MaskUtils'); const log = createModuleLogger('MaskUtils');
export function new_editor(app) { export function new_editor(app) {
if (!app) if (!app)
@@ -125,47 +126,25 @@ export function press_maskeditor_cancel(app) {
* @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia * @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski) * @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
*/ */
export function start_mask_editor_with_predefined_mask(canvasInstance, maskImage, sendCleanImage = true) { export const start_mask_editor_with_predefined_mask = withErrorHandling(function (canvasInstance, maskImage, sendCleanImage = true) {
if (!canvasInstance || !maskImage) { if (!canvasInstance) {
log.error('Canvas instance and mask image are required'); throw createValidationError('Canvas instance is required', { canvasInstance });
return; }
if (!maskImage) {
throw createValidationError('Mask image is required', { maskImage });
} }
canvasInstance.startMaskEditor(maskImage, sendCleanImage); canvasInstance.startMaskEditor(maskImage, sendCleanImage);
} }, 'start_mask_editor_with_predefined_mask');
/** /**
* Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska) * Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska)
* @param {Canvas} canvasInstance - Instancja Canvas * @param {Canvas} canvasInstance - Instancja Canvas
*/ */
export function start_mask_editor_auto(canvasInstance) { export const start_mask_editor_auto = withErrorHandling(function (canvasInstance) {
if (!canvasInstance) { if (!canvasInstance) {
log.error('Canvas instance is required'); throw createValidationError('Canvas instance is required', { canvasInstance });
return;
} }
canvasInstance.startMaskEditor(null, true); canvasInstance.startMaskEditor(null, true);
} }, 'start_mask_editor_auto');
/** // Duplikowane funkcje zostały przeniesione do ImageUtils.ts:
* Tworzy maskę z obrazu dla użycia w mask editorze // - create_mask_from_image_src -> createMaskFromImageSrc
* @param {string} imageSrc - Źródło obrazu (URL lub data URL) // - canvas_to_mask_image -> canvasToMaskImage
* @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
*/
export function create_mask_from_image_src(imageSrc) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = imageSrc;
});
}
/**
* Konwertuje canvas do Image dla użycia jako maska
* @param {HTMLCanvasElement} canvas - Canvas do konwersji
* @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
*/
export function canvas_to_mask_image(canvas) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = canvas.toDataURL();
});
}

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.4.0" version = "1.5.11"
license = { text = "MIT License" } license = { text = "MIT License" }
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"] dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]

View File

@@ -166,10 +166,17 @@ export class BatchPreviewManager {
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible; this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
if (this.maskWasVisible) { if (this.maskWasVisible) {
this.canvas.maskTool.toggleOverlayVisibility(); this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`); const toggleSwitch = document.getElementById(`toggle-mask-switch-${this.canvas.node.id}`);
if (toggleBtn) { if (toggleSwitch) {
toggleBtn.classList.remove('primary'); const checkbox = toggleSwitch.querySelector('input[type="checkbox"]') as HTMLInputElement;
toggleBtn.textContent = "Hide Mask"; if (checkbox) {
checkbox.checked = false;
}
toggleSwitch.classList.remove('primary');
const iconContainer = toggleSwitch.querySelector('.switch-icon') as HTMLElement;
if (iconContainer) {
iconContainer.style.opacity = '0.5';
}
} }
this.canvas.render(); this.canvas.render();
} }
@@ -191,6 +198,11 @@ export class BatchPreviewManager {
this.worldY += paddingInWorld; this.worldY += paddingInWorld;
} }
// Hide all batch layers initially, then show only the first one
this.layers.forEach((layer: Layer) => {
layer.visible = false;
});
this._update(); this._update();
} }
@@ -210,15 +222,31 @@ export class BatchPreviewManager {
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) { if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
this.canvas.maskTool.toggleOverlayVisibility(); this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${String(this.canvas.node.id)}`); const toggleSwitch = document.getElementById(`toggle-mask-switch-${String(this.canvas.node.id)}`);
if (toggleBtn) { if (toggleSwitch) {
toggleBtn.classList.add('primary'); const checkbox = toggleSwitch.querySelector('input[type="checkbox"]') as HTMLInputElement;
toggleBtn.textContent = "Show Mask"; if (checkbox) {
checkbox.checked = true;
}
toggleSwitch.classList.add('primary');
const iconContainer = toggleSwitch.querySelector('.switch-icon') as HTMLElement;
if (iconContainer) {
iconContainer.style.opacity = '1';
}
} }
} }
this.maskWasVisible = false; this.maskWasVisible = false;
this.canvas.layers.forEach((l: Layer) => (l as any).visible = true); // Only make visible the layers that were part of the batch preview
this.layers.forEach((layer: Layer) => {
layer.visible = true;
});
// Update the layers panel to reflect visibility changes
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
this.canvas.render(); this.canvas.render();
} }
@@ -264,14 +292,27 @@ export class BatchPreviewManager {
private _focusOnLayer(layer: Layer): void { private _focusOnLayer(layer: Layer): void {
if (!layer) return; if (!layer) return;
log.debug(`Focusing on layer ${layer.id}`); log.debug(`Focusing on layer ${layer.id} using visibility toggle`);
// Move the selected layer to the top of the layer stack // Hide all batch layers first
this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 }); this.layers.forEach((l: Layer) => {
l.visible = false;
});
// Show only the current layer
layer.visible = true;
// Deselect only this layer if it is selected
const selected = this.canvas.canvasSelection.selectedLayers;
if (selected && selected.includes(layer)) {
this.canvas.updateSelection(selected.filter((l: Layer) => l !== layer));
}
// Update the layers panel to reflect visibility changes
if (this.canvas.canvasLayersPanel) {
this.canvas.canvasLayersPanel.onLayersChanged();
}
this.canvas.updateSelection([layer]);
// Render is called by moveLayers, but we call it again to be safe
this.canvas.render(); this.canvas.render();
} }
} }

View File

@@ -7,6 +7,8 @@ import {ComfyApp} from "../../scripts/app.js";
import {removeImage} from "./db.js"; import {removeImage} from "./db.js";
import {MaskTool} from "./MaskTool.js"; import {MaskTool} from "./MaskTool.js";
import {ShapeTool} from "./ShapeTool.js";
import {CustomShapeMenu} from "./CustomShapeMenu.js";
import {CanvasState} from "./CanvasState.js"; import {CanvasState} from "./CanvasState.js";
import {CanvasInteractions} from "./CanvasInteractions.js"; import {CanvasInteractions} from "./CanvasInteractions.js";
import {CanvasLayers} from "./CanvasLayers.js"; import {CanvasLayers} from "./CanvasLayers.js";
@@ -16,10 +18,10 @@ import {CanvasIO} from "./CanvasIO.js";
import {ImageReferenceManager} from "./ImageReferenceManager.js"; import {ImageReferenceManager} from "./ImageReferenceManager.js";
import {BatchPreviewManager} from "./BatchPreviewManager.js"; import {BatchPreviewManager} from "./BatchPreviewManager.js";
import {createModuleLogger} from "./utils/LoggerUtils.js"; import {createModuleLogger} from "./utils/LoggerUtils.js";
import { debounce } from "./utils/CommonUtils.js"; import { debounce, createCanvas } from "./utils/CommonUtils.js";
import {CanvasMask} from "./CanvasMask.js"; import {MaskEditorIntegration} from "./MaskEditorIntegration.js";
import {CanvasSelection} from "./CanvasSelection.js"; import {CanvasSelection} from "./CanvasSelection.js";
import type { ComfyNode, Layer, Viewport, Point, AddMode } from './types'; import type { ComfyNode, Layer, Viewport, Point, AddMode, Shape, OutputAreaBounds } from './types';
const useChainCallback = (original: any, next: any) => { const useChainCallback = (original: any, next: any) => {
if (original === undefined || original === null) { if (original === undefined || original === null) {
@@ -45,11 +47,12 @@ const log = createModuleLogger('Canvas');
export class Canvas { export class Canvas {
batchPreviewManagers: BatchPreviewManager[]; batchPreviewManagers: BatchPreviewManager[];
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
canvasContainer: HTMLDivElement | null;
canvasIO: CanvasIO; canvasIO: CanvasIO;
canvasInteractions: CanvasInteractions; canvasInteractions: CanvasInteractions;
canvasLayers: CanvasLayers; canvasLayers: CanvasLayers;
canvasLayersPanel: CanvasLayersPanel; canvasLayersPanel: CanvasLayersPanel;
canvasMask: CanvasMask; maskEditorIntegration: MaskEditorIntegration;
canvasRenderer: CanvasRenderer; canvasRenderer: CanvasRenderer;
canvasSelection: CanvasSelection; canvasSelection: CanvasSelection;
canvasState: CanvasState; canvasState: CanvasState;
@@ -63,13 +66,36 @@ export class Canvas {
lastMousePosition: Point; lastMousePosition: Point;
layers: Layer[]; layers: Layer[];
maskTool: MaskTool; maskTool: MaskTool;
shapeTool: ShapeTool;
customShapeMenu: CustomShapeMenu;
outputAreaShape: Shape | null;
autoApplyShapeMask: boolean;
shapeMaskExpansion: boolean;
shapeMaskExpansionValue: number;
shapeMaskFeather: boolean;
shapeMaskFeatherValue: number;
outputAreaExtensions: { top: number, bottom: number, left: number, right: number };
outputAreaExtensionEnabled: boolean;
outputAreaExtensionPreview: { top: number, bottom: number, left: number, right: number } | null;
lastOutputAreaExtensions: { top: number, bottom: number, left: number, right: number };
originalCanvasSize: { width: number, height: number };
originalOutputAreaPosition: { x: number, y: number };
outputAreaBounds: OutputAreaBounds;
node: ComfyNode; node: ComfyNode;
offscreenCanvas: HTMLCanvasElement; offscreenCanvas: HTMLCanvasElement;
offscreenCtx: CanvasRenderingContext2D | null; offscreenCtx: CanvasRenderingContext2D | null;
overlayCanvas: HTMLCanvasElement;
overlayCtx: CanvasRenderingContext2D;
onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined; onHistoryChange: ((historyInfo: { canUndo: boolean; canRedo: boolean; }) => void) | undefined;
onViewportChange: (() => void) | null;
onStateChange: (() => void) | undefined; onStateChange: (() => void) | undefined;
pendingBatchContext: any; pendingBatchContext: any;
pendingDataCheck: number | null; pendingDataCheck: number | null;
pendingInputDataCheck: number | null;
inputDataLoaded: boolean;
lastLoadedImageSrc?: string;
lastLoadedLinkId?: number;
lastLoadedMaskLinkId?: number;
previewVisible: boolean; previewVisible: boolean;
requestSaveState: () => void; requestSaveState: () => void;
viewport: Viewport; viewport: Viewport;
@@ -79,36 +105,72 @@ export class Canvas {
constructor(node: ComfyNode, widget: any, callbacks: { onStateChange?: () => void, onHistoryChange?: (historyInfo: { canUndo: boolean; canRedo: boolean; }) => void } = {}) { constructor(node: ComfyNode, widget: any, callbacks: { onStateChange?: () => void, onHistoryChange?: (historyInfo: { canUndo: boolean; canRedo: boolean; }) => void } = {}) {
this.node = node; this.node = node;
this.widget = widget; this.widget = widget;
this.canvas = document.createElement('canvas'); const { canvas, ctx } = createCanvas(0, 0, '2d', {willReadFrequently: true});
const ctx = this.canvas.getContext('2d', {willReadFrequently: true});
if (!ctx) throw new Error("Could not create canvas context"); if (!ctx) throw new Error("Could not create canvas context");
this.canvas = canvas;
this.ctx = ctx; this.ctx = ctx;
this.width = 512; this.width = 512;
this.height = 512; this.height = 512;
this.layers = []; this.layers = [];
this.onStateChange = callbacks.onStateChange; this.onStateChange = callbacks.onStateChange;
this.onHistoryChange = callbacks.onHistoryChange; this.onHistoryChange = callbacks.onHistoryChange;
this.onViewportChange = null;
this.lastMousePosition = {x: 0, y: 0}; this.lastMousePosition = {x: 0, y: 0};
this.viewport = { this.viewport = {
x: -(this.width / 4), x: -(this.width / 1.5),
y: -(this.height / 4), y: -(this.height / 2),
zoom: 0.8, zoom: 0.8,
}; };
this.offscreenCanvas = document.createElement('canvas'); const { canvas: offscreenCanvas, ctx: offscreenCtx } = createCanvas(0, 0, '2d', {
this.offscreenCtx = this.offscreenCanvas.getContext('2d', {
alpha: false, alpha: false,
willReadFrequently: true willReadFrequently: true
}); });
this.offscreenCanvas = offscreenCanvas;
this.offscreenCtx = offscreenCtx;
// Create overlay canvas for brush cursor and other lightweight overlays
const { canvas: overlayCanvas, ctx: overlayCtx } = createCanvas(0, 0, '2d', {
alpha: true,
willReadFrequently: false
});
if (!overlayCtx) throw new Error("Could not create overlay canvas context");
this.overlayCanvas = overlayCanvas;
this.overlayCtx = overlayCtx;
this.canvasContainer = null;
this.dataInitialized = false; this.dataInitialized = false;
this.pendingDataCheck = null; this.pendingDataCheck = null;
this.pendingInputDataCheck = null;
this.inputDataLoaded = false;
this.imageCache = new Map(); this.imageCache = new Map();
this.requestSaveState = () => {}; this.requestSaveState = () => {};
this.outputAreaShape = null;
this.autoApplyShapeMask = false;
this.shapeMaskExpansion = false;
this.shapeMaskExpansionValue = 0;
this.shapeMaskFeather = false;
this.shapeMaskFeatherValue = 0;
this.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
this.outputAreaExtensionEnabled = false;
this.outputAreaExtensionPreview = null;
this.lastOutputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
this.originalCanvasSize = { width: this.width, height: this.height };
this.originalOutputAreaPosition = { x: -(this.width / 4), y: -(this.height / 4) };
// Initialize outputAreaBounds centered in viewport, similar to how canvas resize/move work
this.outputAreaBounds = {
x: -(this.width / 4),
y: -(this.height / 4),
width: this.width,
height: this.height
};
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange}); this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
this.canvasMask = new CanvasMask(this); this.shapeTool = new ShapeTool(this);
this.customShapeMenu = new CustomShapeMenu(this);
this.maskEditorIntegration = new MaskEditorIntegration(this);
this.canvasState = new CanvasState(this); this.canvasState = new CanvasState(this);
this.canvasSelection = new CanvasSelection(this); this.canvasSelection = new CanvasSelection(this);
this.canvasInteractions = new CanvasInteractions(this); this.canvasInteractions = new CanvasInteractions(this);
@@ -136,7 +198,6 @@ export class Canvas {
this.previewVisible = false; this.previewVisible = false;
} }
async waitForWidget(name: any, node: any, interval = 100, timeout = 20000) { async waitForWidget(name: any, node: any, interval = 100, timeout = 20000) {
const startTime = Date.now(); const startTime = Date.now();
@@ -156,7 +217,6 @@ export class Canvas {
}); });
} }
/** /**
* Kontroluje widoczność podglądu canvas * Kontroluje widoczność podglądu canvas
* @param {boolean} visible - Czy podgląd ma być widoczny * @param {boolean} visible - Czy podgląd ma być widoczny
@@ -233,7 +293,6 @@ export class Canvas {
})); }));
} }
/** /**
* Ładuje stan canvas z bazy danych * Ładuje stan canvas z bazy danych
*/ */
@@ -285,7 +344,6 @@ export class Canvas {
log.debug('Undo completed, layers count:', this.layers.length); log.debug('Undo completed, layers count:', this.layers.length);
} }
/** /**
* Ponów cofniętą operację * Ponów cofniętą operację
*/ */
@@ -358,13 +416,6 @@ export class Canvas {
return this.canvasSelection.removeSelectedLayers(); return this.canvasSelection.removeSelectedLayers();
} }
/**
* Duplikuje zaznaczone warstwy (w pamięci, bez zapisu stanu)
*/
duplicateSelectedLayers() {
return this.canvasSelection.duplicateSelectedLayers();
}
/** /**
* Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty. * Aktualizuje zaznaczenie warstw i powiadamia wszystkie komponenty.
* To jest "jedyne źródło prawdy" o zmianie zaznaczenia. * To jest "jedyne źródło prawdy" o zmianie zaznaczenia.
@@ -381,6 +432,10 @@ export class Canvas {
return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); return this.canvasSelection.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
} }
defineOutputAreaWithShape(shape: Shape): void {
this.canvasInteractions.defineOutputAreaWithShape(shape);
}
/** /**
* Zmienia rozmiar obszaru wyjściowego * Zmienia rozmiar obszaru wyjściowego
* @param {number} width - Nowa szerokość * @param {number} width - Nowa szerokość
@@ -388,7 +443,20 @@ export class Canvas {
* @param {boolean} saveHistory - Czy zapisać w historii * @param {boolean} saveHistory - Czy zapisać w historii
*/ */
updateOutputAreaSize(width: number, height: number, saveHistory = true) { updateOutputAreaSize(width: number, height: number, saveHistory = true) {
return this.canvasLayers.updateOutputAreaSize(width, height, saveHistory); const result = this.canvasLayers.updateOutputAreaSize(width, height, saveHistory);
// Update mask canvas to ensure it covers the new output area
this.maskTool.updateMaskCanvasForOutputArea();
return result;
}
/**
* Ustawia nowy rozmiar output area zgodnie z nowym systemem (resetuje rozszerzenia, pozycję, rozmiar)
* (Fasada: deleguje do CanvasLayers)
*/
setOutputAreaSize(width: number, height: number) {
this.canvasLayers.setOutputAreaSize(width, height);
} }
/** /**
@@ -422,21 +490,26 @@ export class Canvas {
}; };
const handleExecutionStart = () => { const handleExecutionStart = () => {
// Check for input data when execution starts, but don't reset the flag
log.debug('Execution started, checking for input data...');
// On start, only allow images; mask should load on mask-connect or after execution completes
this.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: 'execution_start' });
if (getAutoRefreshValue()) { if (getAutoRefreshValue()) {
lastExecutionStartTime = Date.now(); lastExecutionStartTime = Date.now();
// Store a snapshot of the context for the upcoming batch // Store a snapshot of the context for the upcoming batch
this.pendingBatchContext = { this.pendingBatchContext = {
// For the menu position // For the menu position - position relative to outputAreaBounds, not canvas center
spawnPosition: { spawnPosition: {
x: this.width / 2, x: this.outputAreaBounds.x + this.outputAreaBounds.width / 2,
y: this.height y: this.outputAreaBounds.y + this.outputAreaBounds.height
}, },
// For the image placement // For the image placement - use actual outputAreaBounds instead of hardcoded (0,0)
outputArea: { outputArea: {
x: 0, x: this.outputAreaBounds.x,
y: 0, y: this.outputAreaBounds.y,
width: this.width, width: this.outputAreaBounds.width,
height: this.height height: this.outputAreaBounds.height
} }
}; };
log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext); log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext);
@@ -445,6 +518,10 @@ export class Canvas {
}; };
const handleExecutionSuccess = async () => { const handleExecutionSuccess = async () => {
// Always check for input data after execution completes
log.debug('Execution success, checking for input data...');
await this.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: 'execution_success' });
if (getAutoRefreshValue()) { if (getAutoRefreshValue()) {
log.info('Auto-refresh triggered, importing latest images.'); log.info('Auto-refresh triggered, importing latest images.');
@@ -488,23 +565,21 @@ export class Canvas {
log.debug('Auto-refresh handlers setup complete, reading from node widget: auto_refresh_after_generation'); log.debug('Auto-refresh handlers setup complete, reading from node widget: auto_refresh_after_generation');
} }
/** /**
* Uruchamia edytor masek * Uruchamia edytor masek
* @param {Image|HTMLCanvasElement|null} predefinedMask - Opcjonalna maska do nałożenia po otwarciu editora * @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 * @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez maski) do editora
*/ */
async startMaskEditor(predefinedMask: HTMLImageElement | HTMLCanvasElement | null = null, sendCleanImage: boolean = true) { async startMaskEditor(predefinedMask: HTMLImageElement | HTMLCanvasElement | null = null, sendCleanImage: boolean = true) {
return this.canvasMask.startMaskEditor(predefinedMask as any, sendCleanImage); return this.maskEditorIntegration.startMaskEditor(predefinedMask as any, sendCleanImage);
} }
/** /**
* Inicjalizuje podstawowe właściwości canvas * Inicjalizuje podstawowe właściwości canvas
*/ */
initCanvas() { initCanvas() {
this.canvas.width = this.width; // Don't set canvas.width/height here - let the render loop handle it based on clientWidth/clientHeight
this.canvas.height = this.height; // this.width and this.height are for the OUTPUT AREA, not the display canvas
this.canvas.style.border = '1px solid black'; this.canvas.style.border = '1px solid black';
this.canvas.style.maxWidth = '100%'; this.canvas.style.maxWidth = '100%';
this.canvas.style.backgroundColor = '#606060'; this.canvas.style.backgroundColor = '#606060';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import { createCanvas } from "./utils/CommonUtils.js";
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
import type { Canvas } from './Canvas'; import type { Canvas } from './Canvas';
import type { Layer } from './types'; import type { Layer } from './types';
@@ -28,18 +31,100 @@ export class CanvasLayersPanel {
this.handleDragEnd = this.handleDragEnd.bind(this); this.handleDragEnd = this.handleDragEnd.bind(this);
this.handleDrop = this.handleDrop.bind(this); this.handleDrop = this.handleDrop.bind(this);
// Preload icons
this.initializeIcons();
// Load CSS for layers panel
addStylesheet(getUrl('./css/layers_panel.css'));
log.info('CanvasLayersPanel initialized'); log.info('CanvasLayersPanel initialized');
} }
private async initializeIcons(): Promise<void> {
try {
await iconLoader.preloadToolIcons();
log.debug('Icons preloaded successfully');
} catch (error) {
log.warn('Failed to preload icons, using fallbacks:', error);
}
}
private createIconElement(toolName: string, size: number = 16): HTMLElement {
const iconContainer = document.createElement('div');
iconContainer.className = 'icon-container';
iconContainer.style.width = `${size}px`;
iconContainer.style.height = `${size}px`;
const icon = iconLoader.getIcon(toolName);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode() as HTMLImageElement;
img.style.width = `${size}px`;
img.style.height = `${size}px`;
iconContainer.appendChild(img);
} else if (icon instanceof HTMLCanvasElement) {
const { canvas, ctx } = createCanvas(size, size);
if (ctx) {
ctx.drawImage(icon, 0, 0, size, size);
}
iconContainer.appendChild(canvas);
}
} else {
// Fallback text
iconContainer.classList.add('fallback-text');
iconContainer.textContent = toolName.charAt(0).toUpperCase();
iconContainer.style.fontSize = `${size * 0.6}px`;
}
return iconContainer;
}
private createVisibilityIcon(isVisible: boolean): HTMLElement {
if (isVisible) {
return this.createIconElement(LAYERFORGE_TOOLS.VISIBILITY, 16);
} else {
// Create a "hidden" version of the visibility icon
const iconContainer = document.createElement('div');
iconContainer.className = 'icon-container visibility-hidden';
iconContainer.style.width = '16px';
iconContainer.style.height = '16px';
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.VISIBILITY);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode() as HTMLImageElement;
img.style.width = '16px';
img.style.height = '16px';
iconContainer.appendChild(img);
} else if (icon instanceof HTMLCanvasElement) {
const { canvas, ctx } = createCanvas(16, 16);
if (ctx) {
ctx.globalAlpha = 0.3;
ctx.drawImage(icon, 0, 0, 16, 16);
}
iconContainer.appendChild(canvas);
}
} else {
// Fallback
iconContainer.classList.add('fallback-text');
iconContainer.textContent = 'H';
iconContainer.style.fontSize = '10px';
}
return iconContainer;
}
}
createPanelStructure(): HTMLElement { createPanelStructure(): HTMLElement {
this.container = document.createElement('div'); this.container = document.createElement('div');
this.container.className = 'layers-panel'; this.container.className = 'layers-panel';
this.container.tabIndex = 0; // Umożliwia fokus na panelu this.container.tabIndex = 0; // Umożliwia fokus na panelu
this.container.innerHTML = ` this.container.innerHTML = `
<div class="layers-panel-header"> <div class="layers-panel-header">
<div class="master-visibility-toggle" title="Toggle all layers visibility"></div>
<span class="layers-panel-title">Layers</span> <span class="layers-panel-title">Layers</span>
<div class="layers-panel-controls"> <div class="layers-panel-controls">
<button class="layers-btn" id="delete-layer-btn" title="Delete layer">🗑</button> <button class="layers-btn" id="delete-layer-btn" title="Delete layer"></button>
</div> </div>
</div> </div>
<div class="layers-container" id="layers-container"> <div class="layers-container" id="layers-container">
@@ -49,10 +134,9 @@ export class CanvasLayersPanel {
this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container'); this.layersContainer = this.container.querySelector<HTMLElement>('#layers-container');
this.injectStyles();
// Setup event listeners dla przycisków // Setup event listeners dla przycisków
this.setupControlButtons(); this.setupControlButtons();
this.setupMasterVisibilityToggle();
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu // Dodaj listener dla klawiatury, aby usuwanie działało z panelu
this.container.addEventListener('keydown', (e: KeyboardEvent) => { this.container.addEventListener('keydown', (e: KeyboardEvent) => {
@@ -60,6 +144,26 @@ export class CanvasLayersPanel {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.deleteSelectedLayers(); this.deleteSelectedLayers();
return;
}
// Handle Ctrl+C/V for layer copy/paste when panel has focus
if (e.ctrlKey || e.metaKey) {
if (e.key.toLowerCase() === 'c') {
if (this.canvas.canvasSelection.selectedLayers.length > 0) {
e.preventDefault();
e.stopPropagation();
this.canvas.canvasLayers.copySelectedLayers();
log.info('Layers copied from panel');
}
} else if (e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
this.canvas.canvasLayers.pasteLayers();
log.info('Layers pasted from panel');
}
}
} }
}); });
@@ -67,208 +171,94 @@ export class CanvasLayersPanel {
return this.container; return this.container;
} }
injectStyles(): void {
const styleId = 'layers-panel-styles';
if (document.getElementById(styleId)) {
return; // Style już istnieją
}
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.layers-panel {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 8px;
height: 100%;
overflow: hidden;
font-family: Arial, sans-serif;
font-size: 12px;
color: #ffffff;
user-select: none;
display: flex;
flex-direction: column;
}
.layers-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid #3a3a3a;
margin-bottom: 8px;
}
.layers-panel-title {
font-weight: bold;
color: #ffffff;
}
.layers-panel-controls {
display: flex;
gap: 4px;
}
.layers-btn {
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #ffffff;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.layers-btn:hover {
background: #4a4a4a;
}
.layers-btn:active {
background: #5a5a5a;
}
.layers-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.layer-row {
display: flex;
align-items: center;
padding: 6px 4px;
margin-bottom: 2px;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.15s ease;
position: relative;
gap: 6px;
}
.layer-row:hover {
background: rgba(255, 255, 255, 0.05);
}
.layer-row.selected {
background: #2d5aa0 !important;
box-shadow: inset 0 0 0 1px #4a7bc8;
}
.layer-row.dragging {
opacity: 0.6;
}
.layer-thumbnail {
width: 48px;
height: 48px;
border: 1px solid #4a4a4a;
border-radius: 2px;
background: transparent;
position: relative;
flex-shrink: 0;
overflow: hidden;
}
.layer-thumbnail canvas {
width: 100%;
height: 100%;
display: block;
}
.layer-thumbnail::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(45deg, #555 25%, transparent 25%),
linear-gradient(-45deg, #555 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #555 75%),
linear-gradient(-45deg, transparent 75%, #555 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
z-index: 1;
}
.layer-thumbnail canvas {
position: relative;
z-index: 2;
}
.layer-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 4px;
border-radius: 2px;
color: #ffffff;
}
.layer-name.editing {
background: #4a4a4a;
border: 1px solid #6a6a6a;
outline: none;
color: #ffffff;
}
.layer-name input {
background: transparent;
border: none;
color: #ffffff;
font-size: 12px;
width: 100%;
outline: none;
}
.drag-insertion-line {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: #4a7bc8;
border-radius: 1px;
z-index: 1000;
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
}
.layers-container::-webkit-scrollbar {
width: 6px;
}
.layers-container::-webkit-scrollbar-track {
background: #2a2a2a;
}
.layers-container::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 3px;
}
.layers-container::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}
`;
document.head.appendChild(style);
log.debug('Styles injected');
}
setupControlButtons(): void { setupControlButtons(): void {
if (!this.container) return; if (!this.container) return;
const deleteBtn = this.container.querySelector('#delete-layer-btn'); const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
// Add delete icon to button
if (deleteBtn) {
const deleteIcon = this.createIconElement(LAYERFORGE_TOOLS.DELETE, 16);
deleteBtn.appendChild(deleteIcon);
}
deleteBtn?.addEventListener('click', () => { deleteBtn?.addEventListener('click', () => {
log.info('Delete layer button clicked'); log.info('Delete layer button clicked');
this.deleteSelectedLayers(); this.deleteSelectedLayers();
}); });
// Initial button state update
this.updateButtonStates();
} }
setupMasterVisibilityToggle(): void {
if (!this.container) return;
const toggleContainer = this.container.querySelector('.master-visibility-toggle') as HTMLElement;
if (!toggleContainer) return;
const updateToggleState = () => {
const total = this.canvas.layers.length;
const visibleCount = this.canvas.layers.filter(l => l.visible).length;
toggleContainer.innerHTML = '';
const checkboxContainer = document.createElement('div');
checkboxContainer.className = 'checkbox-container';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'master-visibility-checkbox';
const customCheckbox = document.createElement('span');
customCheckbox.className = 'custom-checkbox';
checkboxContainer.appendChild(checkbox);
checkboxContainer.appendChild(customCheckbox);
if (visibleCount === 0) {
checkbox.checked = false;
checkbox.indeterminate = false;
customCheckbox.classList.remove('checked', 'indeterminate');
} else if (visibleCount === total) {
checkbox.checked = true;
checkbox.indeterminate = false;
customCheckbox.classList.add('checked');
customCheckbox.classList.remove('indeterminate');
} else {
checkbox.checked = false;
checkbox.indeterminate = true;
customCheckbox.classList.add('indeterminate');
customCheckbox.classList.remove('checked');
}
checkboxContainer.addEventListener('click', (e) => {
e.stopPropagation();
let newVisible: boolean;
if (checkbox.indeterminate) {
newVisible = false; // hide all when mixed
} else if (checkbox.checked) {
newVisible = false; // toggle to hide all
} else {
newVisible = true; // toggle to show all
}
this.canvas.layers.forEach(layer => {
layer.visible = newVisible;
});
this.canvas.render();
this.canvas.requestSaveState();
updateToggleState();
this.renderLayers();
});
toggleContainer.appendChild(checkboxContainer);
};
updateToggleState();
this._updateMasterVisibilityToggle = updateToggleState;
}
private _updateMasterVisibilityToggle?: () => void;
renderLayers(): void { renderLayers(): void {
if (!this.layersContainer) { if (!this.layersContainer) {
log.warn('Layers container not initialized'); log.warn('Layers container not initialized');
@@ -286,10 +276,11 @@ export class CanvasLayersPanel {
sortedLayers.forEach((layer: Layer, index: number) => { sortedLayers.forEach((layer: Layer, index: number) => {
const layerElement = this.createLayerElement(layer, index); const layerElement = this.createLayerElement(layer, index);
if(this.layersContainer) if (this.layersContainer)
this.layersContainer.appendChild(layerElement); this.layersContainer.appendChild(layerElement);
}); });
if (this._updateMasterVisibilityToggle) this._updateMasterVisibilityToggle();
log.debug(`Rendered ${sortedLayers.length} layers`); log.debug(`Rendered ${sortedLayers.length} layers`);
} }
@@ -298,7 +289,7 @@ export class CanvasLayersPanel {
layerRow.className = 'layer-row'; layerRow.className = 'layer-row';
layerRow.draggable = true; layerRow.draggable = true;
layerRow.dataset.layerIndex = String(index); layerRow.dataset.layerIndex = String(index);
const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer); const isSelected = this.canvas.canvasSelection.selectedLayers.includes(layer);
if (isSelected) { if (isSelected) {
layerRow.classList.add('selected'); layerRow.classList.add('selected');
@@ -313,10 +304,18 @@ export class CanvasLayersPanel {
} }
layerRow.innerHTML = ` layerRow.innerHTML = `
<div class="layer-visibility-toggle" data-layer-index="${index}" title="Toggle layer visibility"></div>
<div class="layer-thumbnail" data-layer-index="${index}"></div> <div class="layer-thumbnail" data-layer-index="${index}"></div>
<span class="layer-name" data-layer-index="${index}">${layer.name}</span> <span class="layer-name" data-layer-index="${index}">${layer.name}</span>
`; `;
// Add visibility icon
const visibilityToggle = layerRow.querySelector<HTMLElement>('.layer-visibility-toggle');
if (visibilityToggle) {
const visibilityIcon = this.createVisibilityIcon(layer.visible);
visibilityToggle.appendChild(visibilityIcon);
}
const thumbnailContainer = layerRow.querySelector<HTMLElement>('.layer-thumbnail'); const thumbnailContainer = layerRow.querySelector<HTMLElement>('.layer-thumbnail');
if (thumbnailContainer) { if (thumbnailContainer) {
this.generateThumbnail(layer, thumbnailContainer); this.generateThumbnail(layer, thumbnailContainer);
@@ -333,16 +332,13 @@ export class CanvasLayersPanel {
return; return;
} }
const canvas = document.createElement('canvas'); const { canvas, ctx } = createCanvas(48, 48, '2d', { willReadFrequently: true });
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return; if (!ctx) return;
canvas.width = 48;
canvas.height = 48;
const scale = Math.min(48 / layer.image.width, 48 / layer.image.height); const scale = Math.min(48 / layer.image.width, 48 / layer.image.height);
const scaledWidth = layer.image.width * scale; const scaledWidth = layer.image.width * scale;
const scaledHeight = layer.image.height * scale; const scaledHeight = layer.image.height * scale;
// Wycentruj obraz // Wycentruj obraz
const x = (48 - scaledWidth) / 2; const x = (48 - scaledWidth) / 2;
const y = (48 - scaledHeight) / 2; const y = (48 - scaledHeight) / 2;
@@ -363,6 +359,18 @@ export class CanvasLayersPanel {
this.handleLayerClick(e, layer, index); this.handleLayerClick(e, layer, index);
}); });
// --- PRAWY PRZYCISK: ODJAZNACZ LAYER ---
layerRow.addEventListener('contextmenu', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (this.canvas.canvasSelection.selectedLayers.includes(layer)) {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
this.canvas.updateSelection(newSelection);
this.updateSelectionAppearance();
this.updateButtonStates();
}
});
layerRow.addEventListener('dblclick', (e: MouseEvent) => { layerRow.addEventListener('dblclick', (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -372,6 +380,16 @@ export class CanvasLayersPanel {
} }
}); });
// Add visibility toggle event listener
const visibilityToggle = layerRow.querySelector<HTMLElement>('.layer-visibility-toggle');
if (visibilityToggle) {
visibilityToggle.addEventListener('click', (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
this.toggleLayerVisibility(layer);
});
}
layerRow.addEventListener('dragstart', (e: DragEvent) => this.handleDragStart(e, layer, index)); layerRow.addEventListener('dragstart', (e: DragEvent) => this.handleDragStart(e, layer, index));
layerRow.addEventListener('dragover', this.handleDragOver.bind(this)); layerRow.addEventListener('dragover', this.handleDragOver.bind(this));
layerRow.addEventListener('dragend', this.handleDragEnd.bind(this)); layerRow.addEventListener('dragend', this.handleDragEnd.bind(this));
@@ -385,26 +403,29 @@ export class CanvasLayersPanel {
// Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas // Aktualizuj wewnętrzny stan zaznaczenia w obiekcie canvas
// Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu. // Ta funkcja NIE powinna już wywoływać onSelectionChanged w panelu.
this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index); this.canvas.updateSelectionLogic(layer, isCtrlPressed, isShiftPressed, index);
// Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM // Aktualizuj tylko wygląd (klasy CSS), bez niszczenia DOM
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates();
// Focus the canvas so keyboard shortcuts (like Ctrl+C/V) work for layer operations
this.canvas.canvas.focus();
log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`); log.debug(`Layer clicked: ${layer.name}, selection count: ${this.canvas.canvasSelection.selectedLayers.length}`);
} }
startEditingLayerName(nameElement: HTMLElement, layer: Layer): void { startEditingLayerName(nameElement: HTMLElement, layer: Layer): void {
const currentName = layer.name; const currentName = layer.name;
nameElement.classList.add('editing'); nameElement.classList.add('editing');
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'text'; input.type = 'text';
input.value = currentName; input.value = currentName;
input.style.width = '100%'; input.style.width = '100%';
nameElement.innerHTML = ''; nameElement.innerHTML = '';
nameElement.appendChild(input); nameElement.appendChild(input);
input.focus(); input.focus();
input.select(); input.select();
@@ -414,7 +435,7 @@ export class CanvasLayersPanel {
layer.name = newName; layer.name = newName;
nameElement.classList.remove('editing'); nameElement.classList.remove('editing');
nameElement.textContent = newName; nameElement.textContent = newName;
this.canvas.saveState(); this.canvas.saveState();
log.info(`Layer renamed to: ${newName}`); log.info(`Layer renamed to: ${newName}`);
}; };
@@ -430,7 +451,6 @@ export class CanvasLayersPanel {
}); });
} }
ensureUniqueName(proposedName: string, currentLayer: Layer): string { ensureUniqueName(proposedName: string, currentLayer: Layer): string {
const existingNames = this.canvas.layers const existingNames = this.canvas.layers
.filter((layer: Layer) => layer !== currentLayer) .filter((layer: Layer) => layer !== currentLayer)
@@ -439,11 +459,11 @@ export class CanvasLayersPanel {
if (!existingNames.includes(proposedName)) { if (!existingNames.includes(proposedName)) {
return proposedName; return proposedName;
} }
// Sprawdź czy nazwa już ma numerację w nawiasach // Sprawdź czy nazwa już ma numerację w nawiasach
const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/); const match = proposedName.match(/^(.+?)\s*\((\d+)\)$/);
let baseName, startNumber; let baseName, startNumber;
if (match) { if (match) {
baseName = match[1].trim(); baseName = match[1].trim();
startNumber = parseInt(match[2]) + 1; startNumber = parseInt(match[2]) + 1;
@@ -451,19 +471,37 @@ export class CanvasLayersPanel {
baseName = proposedName; baseName = proposedName;
startNumber = 1; startNumber = 1;
} }
// Znajdź pierwszą dostępną numerację // Znajdź pierwszą dostępną numerację
let counter = startNumber; let counter = startNumber;
let uniqueName; let uniqueName;
do { do {
uniqueName = `${baseName} (${counter})`; uniqueName = `${baseName} (${counter})`;
counter++; counter++;
} while (existingNames.includes(uniqueName)); } while (existingNames.includes(uniqueName));
return uniqueName; return uniqueName;
} }
toggleLayerVisibility(layer: Layer): void {
layer.visible = !layer.visible;
// If layer became invisible and is selected, deselect it
if (!layer.visible && this.canvas.canvasSelection.selectedLayers.includes(layer)) {
const newSelection = this.canvas.canvasSelection.selectedLayers.filter((l: Layer) => l !== layer);
this.canvas.updateSelection(newSelection);
}
this.canvas.render();
this.canvas.requestSaveState();
// Update the eye icon in the panel
this.renderLayers();
log.info(`Layer "${layer.name}" visibility toggled to: ${layer.visible}`);
}
deleteSelectedLayers(): void { deleteSelectedLayers(): void {
if (this.canvas.canvasSelection.selectedLayers.length === 0) { if (this.canvas.canvasSelection.selectedLayers.length === 0) {
log.debug('No layers selected for deletion'); log.debug('No layers selected for deletion');
@@ -520,7 +558,7 @@ export class CanvasLayersPanel {
const line = document.createElement('div'); const line = document.createElement('div');
line.className = 'drag-insertion-line'; line.className = 'drag-insertion-line';
if (isUpperHalf) { if (isUpperHalf) {
line.style.top = '-1px'; line.style.top = '-1px';
} else { } else {
@@ -548,7 +586,7 @@ export class CanvasLayersPanel {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const midpoint = rect.top + rect.height / 2; const midpoint = rect.top + rect.height / 2;
const isUpperHalf = e.clientY < midpoint; const isUpperHalf = e.clientY < midpoint;
// Oblicz docelowy indeks // Oblicz docelowy indeks
let insertIndex = targetIndex; let insertIndex = targetIndex;
if (!isUpperHalf) { if (!isUpperHalf) {
@@ -557,7 +595,7 @@ export class CanvasLayersPanel {
// Użyj nowej, centralnej funkcji do przesuwania warstw // Użyj nowej, centralnej funkcji do przesuwania warstw
this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex }); this.canvas.canvasLayers.moveLayers(this.draggedElements, { toIndex: insertIndex });
log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`); log.info(`Dropped ${this.draggedElements.length} layers at position ${insertIndex}`);
} }
@@ -571,7 +609,6 @@ export class CanvasLayersPanel {
this.draggedElements = []; this.draggedElements = [];
} }
onLayersChanged(): void { onLayersChanged(): void {
this.renderLayers(); this.renderLayers();
} }
@@ -591,12 +628,32 @@ export class CanvasLayersPanel {
}); });
} }
/**
* Aktualizuje stan przycisków w zależności od zaznaczenia warstw
*/
updateButtonStates(): void {
if (!this.container) return;
const deleteBtn = this.container.querySelector('#delete-layer-btn') as HTMLButtonElement;
const hasSelectedLayers = this.canvas.canvasSelection.selectedLayers.length > 0;
if (deleteBtn) {
deleteBtn.disabled = !hasSelectedLayers;
deleteBtn.title = hasSelectedLayers
? `Delete ${this.canvas.canvasSelection.selectedLayers.length} selected layer(s)`
: 'No layers selected';
}
log.debug(`Button states updated - delete button ${hasSelectedLayers ? 'enabled' : 'disabled'}`);
}
/** /**
* Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz). * Aktualizuje panel gdy zmieni się zaznaczenie (wywoływane z zewnątrz).
* Zamiast pełnego renderowania, tylko aktualizujemy wygląd. * Zamiast pełnego renderowania, tylko aktualizujemy wygląd.
*/ */
onSelectionChanged(): void { onSelectionChanged(): void {
this.updateSelectionAppearance(); this.updateSelectionAppearance();
this.updateButtonStates();
} }
destroy(): void { destroy(): void {
@@ -607,7 +664,7 @@ export class CanvasLayersPanel {
this.layersContainer = null; this.layersContainer = null;
this.draggedElements = []; this.draggedElements = [];
this.removeDragInsertionLine(); this.removeDragInsertionLine();
log.info('CanvasLayersPanel destroyed'); log.info('CanvasLayersPanel destroyed');
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { generateUUID } from "./utils/CommonUtils.js";
const log = createModuleLogger('CanvasSelection'); const log = createModuleLogger('CanvasSelection');
@@ -26,7 +27,7 @@ export class CanvasSelection {
sortedLayers.forEach(layer => { sortedLayers.forEach(layer => {
const newLayer = { const newLayer = {
...layer, ...layer,
id: `layer_${+new Date()}_${Math.random().toString(36).substr(2, 9)}`, id: generateUUID(),
zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu zIndex: this.canvas.layers.length, // Nowa warstwa zawsze na wierzchu
}; };
this.canvas.layers.push(newLayer); this.canvas.layers.push(newLayer);
@@ -52,7 +53,8 @@ export class CanvasSelection {
*/ */
updateSelection(newSelection: any) { updateSelection(newSelection: any) {
const previousSelection = this.selectedLayers.length; const previousSelection = this.selectedLayers.length;
this.selectedLayers = newSelection || []; // Filter out invisible layers from selection
this.selectedLayers = (newSelection || []).filter((layer: any) => layer.visible !== false);
this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null; this.selectedLayer = this.selectedLayers.length > 0 ? this.selectedLayers[this.selectedLayers.length - 1] : null;
// Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli // Sprawdź, czy zaznaczenie faktycznie się zmieniło, aby uniknąć pętli

View File

@@ -1,6 +1,7 @@
import {getCanvasState, setCanvasState, saveImage, getImage} from "./db.js"; import {getCanvasState, setCanvasState, saveImage, getImage} from "./db.js";
import {createModuleLogger} from "./utils/LoggerUtils.js"; import {createModuleLogger} from "./utils/LoggerUtils.js";
import {generateUUID, cloneLayers, getStateSignature, debounce} from "./utils/CommonUtils.js"; import {showAlertNotification, showAllNotificationTypes} from "./utils/NotificationUtils.js";
import {generateUUID, cloneLayers, getStateSignature, debounce, createCanvas} from "./utils/CommonUtils.js";
import {withErrorHandling} from "./ErrorHandler.js"; import {withErrorHandling} from "./ErrorHandler.js";
import type { Canvas } from './Canvas'; import type { Canvas } from './Canvas';
import type { Layer, ComfyNode } from './types'; import type { Layer, ComfyNode } from './types';
@@ -98,15 +99,30 @@ export class CanvasState {
zoom: 0.8 zoom: 0.8
}; };
// Restore outputAreaBounds if saved, otherwise use default
if (savedState.outputAreaBounds) {
this.canvas.outputAreaBounds = savedState.outputAreaBounds;
log.debug(`Output Area bounds restored: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${this.canvas.outputAreaBounds.width}, h=${this.canvas.outputAreaBounds.height}`);
} else {
// Fallback to default positioning for legacy saves
this.canvas.outputAreaBounds = {
x: -(this.canvas.width / 4),
y: -(this.canvas.height / 4),
width: this.canvas.width,
height: this.canvas.height
};
log.debug(`Output Area bounds set to default: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${this.canvas.outputAreaBounds.width}, h=${this.canvas.outputAreaBounds.height}`);
}
this.canvas.canvasLayers.updateOutputAreaSize(this.canvas.width, this.canvas.height, false); this.canvas.canvasLayers.updateOutputAreaSize(this.canvas.width, this.canvas.height, false);
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`); log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
const loadedLayers = await this._loadLayers(savedState.layers); const loadedLayers = await this._loadLayers(savedState.layers);
this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null); this.canvas.layers = loadedLayers.filter((l): l is Layer => l !== null);
log.info(`Loaded ${this.canvas.layers.length} layers.`); log.info(`Loaded ${this.canvas.layers.length} layers from ${savedState.layers.length} saved layers.`);
if (this.canvas.layers.length === 0) { if (this.canvas.layers.length === 0 && savedState.layers.length > 0) {
log.warn("No valid layers loaded, state may be corrupted."); log.warn(`Failed to load any layers. Saved state had ${savedState.layers.length} layers but all failed to load. This may indicate corrupted IndexedDB data.`);
return false; // Don't return false - allow empty canvas to be valid
} }
this.canvas.updateSelectionAfterHistory(); this.canvas.updateSelectionAfterHistory();
@@ -219,6 +235,7 @@ export class CanvasState {
_createLayerFromSrc(layerData: Layer, imageSrc: string | ImageBitmap, index: number, resolve: (value: Layer | null) => void): void { _createLayerFromSrc(layerData: Layer, imageSrc: string | ImageBitmap, index: number, resolve: (value: Layer | null) => void): void {
if (typeof imageSrc === 'string') { if (typeof imageSrc === 'string') {
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { img.onload = () => {
log.debug(`Layer ${index}: Image loaded successfully.`); log.debug(`Layer ${index}: Image loaded successfully.`);
const newLayer: Layer = {...layerData, image: img}; const newLayer: Layer = {...layerData, image: img};
@@ -230,13 +247,11 @@ export class CanvasState {
}; };
img.src = imageSrc; img.src = imageSrc;
} else { } else {
const canvas = document.createElement('canvas'); const { canvas, ctx } = createCanvas(imageSrc.width, imageSrc.height);
canvas.width = imageSrc.width;
canvas.height = imageSrc.height;
const ctx = canvas.getContext('2d');
if (ctx) { if (ctx) {
ctx.drawImage(imageSrc, 0, 0); ctx.drawImage(imageSrc, 0, 0);
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { img.onload = () => {
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`); log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
const newLayer: Layer = {...layerData, image: img}; const newLayer: Layer = {...layerData, image: img};
@@ -260,6 +275,23 @@ export class CanvasState {
return; return;
} }
// Auto-correct node_id widget if needed before saving state
if (this.canvas.node && this.canvas.node.widgets) {
const nodeIdWidget = this.canvas.node.widgets.find((w: any) => w.name === "node_id");
if (nodeIdWidget) {
const correctId = String(this.canvas.node.id);
if (nodeIdWidget.value !== correctId) {
const prevValue = nodeIdWidget.value;
nodeIdWidget.value = correctId;
log.warn(`[CanvasState] node_id widget value (${prevValue}) did not match node.id (${correctId}) - auto-corrected (saveStateToDB).`);
showAlertNotification(
`The value of node_id (${prevValue}) did not match the node number (${correctId}) and was automatically corrected.
If you see dark images or masks in the output, make sure node_id is set to ${correctId}.`
);
}
}
}
log.info("Preparing state to be sent to worker..."); log.info("Preparing state to be sent to worker...");
const layers = await this._prepareLayers(); const layers = await this._prepareLayers();
const state = { const state = {
@@ -267,6 +299,7 @@ export class CanvasState {
viewport: this.canvas.viewport, viewport: this.canvas.viewport,
width: this.canvas.width, width: this.canvas.width,
height: this.canvas.height, height: this.canvas.height,
outputAreaBounds: this.canvas.outputAreaBounds,
}; };
if (state.layers.length === 0) { if (state.layers.length === 0) {
@@ -358,10 +391,7 @@ export class CanvasState {
this.maskUndoStack.pop(); this.maskUndoStack.pop();
} }
const maskCanvas = this.canvas.maskTool.getMask(); const maskCanvas = this.canvas.maskTool.getMask();
const clonedCanvas = document.createElement('canvas'); const { canvas: clonedCanvas, ctx: clonedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', { willReadFrequently: true });
clonedCanvas.width = maskCanvas.width;
clonedCanvas.height = maskCanvas.height;
const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
if (clonedCtx) { if (clonedCtx) {
clonedCtx.drawImage(maskCanvas, 0, 0); clonedCtx.drawImage(maskCanvas, 0, 0);
} }
@@ -428,12 +458,13 @@ 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 maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); // Use the new restoreMaskFromSavedState method that properly clears chunks first
if (maskCtx) { this.canvas.maskTool.restoreMaskFromSavedState(prevState);
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(prevState, 0, 0); // Clear stroke overlay to prevent old drawing previews from persisting
} this.canvas.canvasRenderer.clearMaskStrokeOverlay();
this.canvas.render(); this.canvas.render();
} }
@@ -446,12 +477,13 @@ export class CanvasState {
const nextState = this.maskRedoStack.pop(); const nextState = this.maskRedoStack.pop();
if (nextState) { if (nextState) {
this.maskUndoStack.push(nextState); this.maskUndoStack.push(nextState);
const maskCanvas = this.canvas.maskTool.getMask();
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true }); // Use the new restoreMaskFromSavedState method that properly clears chunks first
if (maskCtx) { this.canvas.maskTool.restoreMaskFromSavedState(nextState);
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
maskCtx.drawImage(nextState, 0, 0); // Clear stroke overlay to prevent old drawing previews from persisting
} this.canvas.canvasRenderer.clearMaskStrokeOverlay();
this.canvas.render(); this.canvas.render();
} }
this.canvas.updateHistoryButtons(); this.canvas.updateHistoryButtons();

File diff suppressed because it is too large Load Diff

729
src/CustomShapeMenu.ts Normal file
View File

@@ -0,0 +1,729 @@
import {createModuleLogger} from "./utils/LoggerUtils.js";
import { addStylesheet, getUrl } from "./utils/ResourceManager.js";
import type { Canvas } from './Canvas';
const log = createModuleLogger('CustomShapeMenu');
export class CustomShapeMenu {
private canvas: Canvas;
private element: HTMLDivElement | null;
private worldX: number;
private worldY: number;
private uiInitialized: boolean;
private tooltip: HTMLDivElement | null;
private isMinimized: boolean = false;
constructor(canvas: Canvas) {
this.canvas = canvas;
this.element = null;
this.worldX = 0;
this.worldY = 0;
this.uiInitialized = false;
this.tooltip = null;
}
show(): void {
if (!this.canvas.outputAreaShape) {
return;
}
this._createUI();
if (this.element) {
this.element.style.display = 'block';
this._updateMinimizedState();
}
// Position in top-left corner of viewport (closer to edge)
const viewLeft = this.canvas.viewport.x;
const viewTop = this.canvas.viewport.y;
this.worldX = viewLeft + (8 / this.canvas.viewport.zoom);
this.worldY = viewTop + (8 / this.canvas.viewport.zoom);
this.updateScreenPosition();
}
hide(): void {
if (this.element) {
this.element.remove();
this.element = null;
this.uiInitialized = false;
}
this.hideTooltip();
}
updateScreenPosition(): void {
if (!this.element) return;
const screenX = (this.worldX - this.canvas.viewport.x) * this.canvas.viewport.zoom;
const screenY = (this.worldY - this.canvas.viewport.y) * this.canvas.viewport.zoom;
this.element.style.transform = `translate(${screenX}px, ${screenY}px)`;
}
private _createUI(): void {
if (this.uiInitialized) return;
addStylesheet(getUrl('./css/custom_shape_menu.css'));
this.element = document.createElement('div');
this.element.id = 'layerforge-custom-shape-menu';
// --- MINIMIZED BAR ---
const minimizedBar = document.createElement('div');
minimizedBar.className = 'custom-shape-minimized-bar';
minimizedBar.textContent = "Custom Output Area Active";
minimizedBar.style.display = 'none';
minimizedBar.style.cursor = 'pointer';
minimizedBar.onclick = () => {
this.isMinimized = false;
this._updateMinimizedState();
};
this.element.appendChild(minimizedBar);
// --- FULL MENU ---
const fullMenu = document.createElement('div');
fullMenu.className = 'custom-shape-full-menu';
// Minimize button (top right)
const minimizeBtn = document.createElement('button');
minimizeBtn.innerHTML = "";
minimizeBtn.title = "Minimize menu";
minimizeBtn.className = 'custom-shape-minimize-btn';
minimizeBtn.style.position = 'absolute';
minimizeBtn.style.top = '4px';
minimizeBtn.style.right = '4px';
minimizeBtn.style.width = '24px';
minimizeBtn.style.height = '24px';
minimizeBtn.style.border = 'none';
minimizeBtn.style.background = 'transparent';
minimizeBtn.style.color = '#888';
minimizeBtn.style.fontSize = '20px';
minimizeBtn.style.cursor = 'pointer';
minimizeBtn.onclick = (e) => {
e.stopPropagation();
this.isMinimized = true;
this._updateMinimizedState();
};
fullMenu.appendChild(minimizeBtn);
// Create menu content
const lines = [
"Custom Output Area Active"
];
lines.forEach(line => {
const lineElement = document.createElement('div');
lineElement.textContent = line;
lineElement.className = 'menu-line';
fullMenu.appendChild(lineElement);
});
// Create a container for the entire shape mask feature set
const featureContainer = document.createElement('div');
featureContainer.id = 'shape-mask-feature-container';
featureContainer.className = 'feature-container';
// Add main auto-apply checkbox to the new container
const checkboxContainer = this._createCheckbox(
'auto-apply-checkbox',
() => this.canvas.autoApplyShapeMask,
'Auto-apply shape mask',
(e) => {
this.canvas.autoApplyShapeMask = (e.target as HTMLInputElement).checked;
if (this.canvas.autoApplyShapeMask) {
this.canvas.maskTool.applyShapeMask();
log.info("Auto-apply shape mask enabled - mask applied automatically");
} else {
this.canvas.maskTool.removeShapeMask();
this.canvas.shapeMaskExpansion = false;
this.canvas.shapeMaskFeather = false;
log.info("Auto-apply shape mask disabled - mask area removed and sub-options reset.");
}
this._updateUI();
this.canvas.render();
},
"Automatically applies a mask based on the custom output area shape. When enabled, the mask will be applied to all layers within the shape boundary."
);
featureContainer.appendChild(checkboxContainer);
// Add expansion checkbox
const expansionContainer = this._createCheckbox(
'expansion-checkbox',
() => this.canvas.shapeMaskExpansion,
'Expand/Contract mask',
(e) => {
this.canvas.shapeMaskExpansion = (e.target as HTMLInputElement).checked;
this._updateUI();
if (this.canvas.autoApplyShapeMask) {
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask();
this.canvas.render();
}
},
"Dilate (expand) or erode (contract) the shape mask. Positive values expand the mask outward, negative values shrink it inward."
);
featureContainer.appendChild(expansionContainer);
// Add expansion slider container
const expansionSliderContainer = document.createElement('div');
expansionSliderContainer.id = 'expansion-slider-container';
expansionSliderContainer.className = 'slider-container';
const expansionSliderLabel = document.createElement('div');
expansionSliderLabel.textContent = 'Expansion amount:';
expansionSliderLabel.className = 'slider-label';
const expansionSlider = document.createElement('input');
expansionSlider.type = 'range';
expansionSlider.min = '-300';
expansionSlider.max = '300';
expansionSlider.value = String(this.canvas.shapeMaskExpansionValue);
const expansionValueDisplay = document.createElement('div');
expansionValueDisplay.className = 'slider-value-display';
let expansionValueBeforeDrag = this.canvas.shapeMaskExpansionValue;
const updateExpansionSliderDisplay = () => {
const value = parseInt(expansionSlider.value);
this.canvas.shapeMaskExpansionValue = value;
expansionValueDisplay.textContent = value > 0 ? `+${value}px` : `${value}px`;
};
let isExpansionDragging = false;
expansionSlider.onmousedown = () => {
isExpansionDragging = true;
expansionValueBeforeDrag = this.canvas.shapeMaskExpansionValue; // Store value before dragging
};
expansionSlider.oninput = () => {
updateExpansionSliderDisplay();
if (this.canvas.autoApplyShapeMask) {
if (isExpansionDragging) {
const featherValue = this.canvas.shapeMaskFeather ? this.canvas.shapeMaskFeatherValue : 0;
this.canvas.maskTool.showShapePreview(this.canvas.shapeMaskExpansionValue, featherValue);
} else {
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask(false);
this.canvas.render();
}
}
};
expansionSlider.onmouseup = () => {
isExpansionDragging = false;
if (this.canvas.autoApplyShapeMask) {
const finalValue = parseInt(expansionSlider.value);
// If value changed during drag, remove old mask with previous expansion value
if (expansionValueBeforeDrag !== finalValue) {
// Temporarily set the previous value to remove the old mask properly
const tempValue = this.canvas.shapeMaskExpansionValue;
this.canvas.shapeMaskExpansionValue = expansionValueBeforeDrag;
this.canvas.maskTool.removeShapeMask();
this.canvas.shapeMaskExpansionValue = tempValue; // Restore current value
log.info(`Removed old shape mask with expansion: ${expansionValueBeforeDrag}px before applying new value: ${finalValue}px`);
}
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask(true);
this.canvas.render();
}
};
updateExpansionSliderDisplay();
expansionSliderContainer.appendChild(expansionSliderLabel);
expansionSliderContainer.appendChild(expansionSlider);
expansionSliderContainer.appendChild(expansionValueDisplay);
featureContainer.appendChild(expansionSliderContainer);
// Add feather checkbox
const featherContainer = this._createCheckbox(
'feather-checkbox',
() => this.canvas.shapeMaskFeather,
'Feather edges',
(e) => {
this.canvas.shapeMaskFeather = (e.target as HTMLInputElement).checked;
this._updateUI();
if (this.canvas.autoApplyShapeMask) {
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask();
this.canvas.render();
}
},
"Softens the edges of the shape mask by creating a gradual transition from opaque to transparent."
);
featureContainer.appendChild(featherContainer);
// Add feather slider container
const featherSliderContainer = document.createElement('div');
featherSliderContainer.id = 'feather-slider-container';
featherSliderContainer.className = 'slider-container';
const featherSliderLabel = document.createElement('div');
featherSliderLabel.textContent = 'Feather amount:';
featherSliderLabel.className = 'slider-label';
const featherSlider = document.createElement('input');
featherSlider.type = 'range';
featherSlider.min = '0';
featherSlider.max = '300';
featherSlider.value = String(this.canvas.shapeMaskFeatherValue);
const featherValueDisplay = document.createElement('div');
featherValueDisplay.className = 'slider-value-display';
const updateFeatherSliderDisplay = () => {
const value = parseInt(featherSlider.value);
this.canvas.shapeMaskFeatherValue = value;
featherValueDisplay.textContent = `${value}px`;
};
let isFeatherDragging = false;
featherSlider.onmousedown = () => { isFeatherDragging = true; };
featherSlider.oninput = () => {
updateFeatherSliderDisplay();
if (this.canvas.autoApplyShapeMask) {
if (isFeatherDragging) {
const expansionValue = this.canvas.shapeMaskExpansion ? this.canvas.shapeMaskExpansionValue : 0;
this.canvas.maskTool.showShapePreview(expansionValue, this.canvas.shapeMaskFeatherValue);
} else {
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask(false);
this.canvas.render();
}
}
};
featherSlider.onmouseup = () => {
isFeatherDragging = false;
if (this.canvas.autoApplyShapeMask) {
this.canvas.maskTool.hideShapePreview();
this.canvas.maskTool.applyShapeMask(true); // true = save state
this.canvas.render();
}
};
updateFeatherSliderDisplay();
featherSliderContainer.appendChild(featherSliderLabel);
featherSliderContainer.appendChild(featherSlider);
featherSliderContainer.appendChild(featherValueDisplay);
featureContainer.appendChild(featherSliderContainer);
fullMenu.appendChild(featureContainer);
// Create output area extension container
const extensionContainer = document.createElement('div');
extensionContainer.id = 'output-area-extension-container';
extensionContainer.className = 'feature-container';
// Add main extension checkbox
const extensionCheckboxContainer = this._createCheckbox(
'extension-checkbox',
() => this.canvas.outputAreaExtensionEnabled,
'Extend output area',
(e) => {
this.canvas.outputAreaExtensionEnabled = (e.target as HTMLInputElement).checked;
if (this.canvas.outputAreaExtensionEnabled) {
this.canvas.originalCanvasSize = { width: this.canvas.width, height: this.canvas.height };
this.canvas.outputAreaExtensions = { ...this.canvas.lastOutputAreaExtensions };
} else {
this.canvas.lastOutputAreaExtensions = { ...this.canvas.outputAreaExtensions };
this.canvas.outputAreaExtensions = { top: 0, bottom: 0, left: 0, right: 0 };
}
this._updateExtensionUI();
this._updateCanvasSize();
this.canvas.render();
},
"Allows extending the output area boundaries in all directions without changing the custom shape."
);
extensionContainer.appendChild(extensionCheckboxContainer);
// Create sliders container
const slidersContainer = document.createElement('div');
slidersContainer.id = 'extension-sliders-container';
slidersContainer.className = 'slider-container';
// Helper function to create a slider with preview system
const createExtensionSlider = (label: string, direction: 'top' | 'bottom' | 'left' | 'right') => {
const sliderContainer = document.createElement('div');
sliderContainer.className = 'extension-slider-container';
const sliderLabel = document.createElement('div');
sliderLabel.textContent = label;
sliderLabel.className = 'slider-label';
const slider = document.createElement('input');
slider.type = 'range';
slider.min = '0';
slider.max = '500';
slider.value = String(this.canvas.outputAreaExtensions[direction]);
const valueDisplay = document.createElement('div');
valueDisplay.className = 'slider-value-display';
const updateDisplay = () => {
const value = parseInt(slider.value);
valueDisplay.textContent = `${value}px`;
};
let isDragging = false;
slider.onmousedown = () => {
isDragging = true;
};
slider.oninput = () => {
updateDisplay();
if (isDragging) {
// During dragging, show preview
const previewExtensions = { ...this.canvas.outputAreaExtensions };
previewExtensions[direction] = parseInt(slider.value);
this.canvas.outputAreaExtensionPreview = previewExtensions;
this.canvas.render();
} else {
// Not dragging, apply immediately (for keyboard navigation)
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
this._updateCanvasSize();
this.canvas.render();
}
};
slider.onmouseup = () => {
if (isDragging) {
isDragging = false;
// Apply the final value and clear preview
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
this.canvas.outputAreaExtensionPreview = null;
this._updateCanvasSize();
this.canvas.render();
}
};
// Handle mouse leave (in case user drags outside)
slider.onmouseleave = () => {
if (isDragging) {
isDragging = false;
// Apply the final value and clear preview
this.canvas.outputAreaExtensions[direction] = parseInt(slider.value);
this.canvas.outputAreaExtensionPreview = null;
this._updateCanvasSize();
this.canvas.render();
}
};
updateDisplay();
sliderContainer.appendChild(sliderLabel);
sliderContainer.appendChild(slider);
sliderContainer.appendChild(valueDisplay);
return sliderContainer;
};
// Add all four sliders
slidersContainer.appendChild(createExtensionSlider('Top extension:', 'top'));
slidersContainer.appendChild(createExtensionSlider('Bottom extension:', 'bottom'));
slidersContainer.appendChild(createExtensionSlider('Left extension:', 'left'));
slidersContainer.appendChild(createExtensionSlider('Right extension:', 'right'));
extensionContainer.appendChild(slidersContainer);
fullMenu.appendChild(extensionContainer);
this.element.appendChild(fullMenu);
// Add to DOM
if (this.canvas.canvas.parentElement) {
this.canvas.canvas.parentElement.appendChild(this.element);
} else {
log.error("Could not find parent node to attach custom shape menu.");
}
this.uiInitialized = true;
this._updateUI();
this._updateMinimizedState();
// Add viewport change listener to update shape preview when zooming/panning
this._addViewportChangeListener();
}
private _createCheckbox(
id: string,
getChecked: () => boolean,
text: string,
clickHandler: (e: Event) => void,
tooltipText?: string
): HTMLLabelElement {
const container = document.createElement('label');
container.className = 'checkbox-container';
container.htmlFor = id;
const input = document.createElement('input');
input.type = 'checkbox';
input.id = id;
input.checked = getChecked();
const customCheckbox = document.createElement('div');
customCheckbox.className = 'custom-checkbox';
const labelText = document.createElement('span');
labelText.textContent = text;
container.appendChild(input);
container.appendChild(customCheckbox);
container.appendChild(labelText);
// Stop propagation to prevent menu from closing, but allow default checkbox behavior
container.onclick = (e: MouseEvent) => {
e.stopPropagation();
};
input.onchange = (e: Event) => {
clickHandler(e);
};
if (tooltipText) {
this._addTooltip(container, tooltipText);
}
return container;
}
private _updateUI(): void {
if (!this.element) return;
// Always update only the full menu part
const fullMenu = this.element.querySelector('.custom-shape-full-menu') as HTMLElement;
if (!fullMenu) return;
const setChecked = (id: string, checked: boolean) => {
const input = fullMenu.querySelector(`#${id}`) as HTMLInputElement;
if (input) input.checked = checked;
};
setChecked('auto-apply-checkbox', this.canvas.autoApplyShapeMask);
setChecked('expansion-checkbox', this.canvas.shapeMaskExpansion);
setChecked('feather-checkbox', this.canvas.shapeMaskFeather);
setChecked('extension-checkbox', this.canvas.outputAreaExtensionEnabled);
const expansionCheckbox = fullMenu.querySelector('#expansion-checkbox')?.parentElement as HTMLElement;
if (expansionCheckbox) {
expansionCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'flex' : 'none';
}
const featherCheckbox = fullMenu.querySelector('#feather-checkbox')?.parentElement as HTMLElement;
if (featherCheckbox) {
featherCheckbox.style.display = this.canvas.autoApplyShapeMask ? 'flex' : 'none';
}
const expansionSliderContainer = fullMenu.querySelector('#expansion-slider-container') as HTMLElement;
if (expansionSliderContainer) {
expansionSliderContainer.style.display = (this.canvas.autoApplyShapeMask && this.canvas.shapeMaskExpansion) ? 'block' : 'none';
}
const featherSliderContainer = fullMenu.querySelector('#feather-slider-container') as HTMLElement;
if (featherSliderContainer) {
featherSliderContainer.style.display = (this.canvas.autoApplyShapeMask && this.canvas.shapeMaskFeather) ? 'block' : 'none';
}
}
private _updateMinimizedState(): void {
if (!this.element) return;
const minimizedBar = this.element.querySelector('.custom-shape-minimized-bar') as HTMLElement;
const fullMenu = this.element.querySelector('.custom-shape-full-menu') as HTMLElement;
if (this.isMinimized) {
minimizedBar.style.display = 'block';
fullMenu.style.display = 'none';
} else {
minimizedBar.style.display = 'none';
fullMenu.style.display = 'block';
}
}
private _updateExtensionUI(): void {
if (!this.element) return;
// Toggle visibility of extension sliders based on the extension checkbox state
const extensionSlidersContainer = this.element.querySelector('#extension-sliders-container') as HTMLElement;
if (extensionSlidersContainer) {
extensionSlidersContainer.style.display = this.canvas.outputAreaExtensionEnabled ? 'block' : 'none';
}
// Update slider values if they exist
if (this.canvas.outputAreaExtensionEnabled) {
const sliders = extensionSlidersContainer?.querySelectorAll('input[type="range"]');
const directions: ('top' | 'bottom' | 'left' | 'right')[] = ['top', 'bottom', 'left', 'right'];
sliders?.forEach((slider, index) => {
const direction = directions[index];
if (direction) {
(slider as HTMLInputElement).value = String(this.canvas.outputAreaExtensions[direction]);
// Update the corresponding value display
const valueDisplay = slider.parentElement?.querySelector('div:last-child');
if (valueDisplay) {
valueDisplay.textContent = `${this.canvas.outputAreaExtensions[direction]}px`;
}
}
});
}
}
/**
* Add viewport change listener to update shape preview when zooming/panning
*/
private _addViewportChangeListener(): void {
// Store previous viewport state to detect changes
let previousViewport = {
x: this.canvas.viewport.x,
y: this.canvas.viewport.y,
zoom: this.canvas.viewport.zoom
};
// Check for viewport changes in render loop
const checkViewportChange = () => {
if (this.canvas.maskTool.shapePreviewVisible) {
const current = this.canvas.viewport;
// Check if viewport has changed
if (current.x !== previousViewport.x ||
current.y !== previousViewport.y ||
current.zoom !== previousViewport.zoom) {
// Update shape preview with current expansion/feather values
const expansionValue = this.canvas.shapeMaskExpansionValue || 0;
const featherValue = this.canvas.shapeMaskFeather ? (this.canvas.shapeMaskFeatherValue || 0) : 0;
this.canvas.maskTool.showShapePreview(expansionValue, featherValue);
// Update previous viewport state
previousViewport = {
x: current.x,
y: current.y,
zoom: current.zoom
};
}
}
// Continue checking if UI is still active
if (this.uiInitialized) {
requestAnimationFrame(checkViewportChange);
}
};
// Start the viewport change detection
requestAnimationFrame(checkViewportChange);
}
private _addTooltip(element: HTMLElement, text: string): void {
element.addEventListener('mouseenter', (e) => {
this.showTooltip(text, e);
});
element.addEventListener('mouseleave', () => {
this.hideTooltip();
});
element.addEventListener('mousemove', (e) => {
if (this.tooltip && this.tooltip.style.display === 'block') {
this.updateTooltipPosition(e);
}
});
}
private showTooltip(text: string, event: MouseEvent): void {
this.hideTooltip(); // Hide any existing tooltip
this.tooltip = document.createElement('div');
this.tooltip.textContent = text;
this.tooltip.className = 'layerforge-tooltip';
document.body.appendChild(this.tooltip);
this.updateTooltipPosition(event);
// Fade in the tooltip
requestAnimationFrame(() => {
if (this.tooltip) {
this.tooltip.style.opacity = '1';
}
});
}
private updateTooltipPosition(event: MouseEvent): void {
if (!this.tooltip) return;
const tooltipRect = this.tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = event.clientX + 10;
let y = event.clientY - 10;
// Adjust if tooltip would go off the right edge
if (x + tooltipRect.width > viewportWidth) {
x = event.clientX - tooltipRect.width - 10;
}
// Adjust if tooltip would go off the bottom edge
if (y + tooltipRect.height > viewportHeight) {
y = event.clientY - tooltipRect.height - 10;
}
// Ensure tooltip doesn't go off the left or top edges
x = Math.max(5, x);
y = Math.max(5, y);
this.tooltip.style.left = `${x}px`;
this.tooltip.style.top = `${y}px`;
}
private hideTooltip(): void {
if (this.tooltip) {
this.tooltip.remove();
this.tooltip = null;
}
}
public _updateCanvasSize(): void {
if (!this.canvas.outputAreaExtensionEnabled) {
// When extensions are disabled, return to original custom shape position
// Use originalOutputAreaPosition instead of current bounds position
const originalPos = this.canvas.originalOutputAreaPosition;
this.canvas.outputAreaBounds = {
x: originalPos.x, // ✅ Return to original custom shape position
y: originalPos.y, // ✅ Return to original custom shape position
width: this.canvas.originalCanvasSize.width,
height: this.canvas.originalCanvasSize.height
};
this.canvas.updateOutputAreaSize(
this.canvas.originalCanvasSize.width,
this.canvas.originalCanvasSize.height,
false
);
return;
}
const ext = this.canvas.outputAreaExtensions;
const newWidth = this.canvas.originalCanvasSize.width + ext.left + ext.right;
const newHeight = this.canvas.originalCanvasSize.height + ext.top + ext.bottom;
// When extensions are enabled, calculate new bounds relative to original custom shape position
const originalPos = this.canvas.originalOutputAreaPosition;
this.canvas.outputAreaBounds = {
x: originalPos.x - ext.left, // Adjust position by left extension from original position
y: originalPos.y - ext.top, // Adjust position by top extension from original position
width: newWidth,
height: newHeight
};
// Zmień rozmiar canvas (fizyczny rozmiar dla renderowania)
this.canvas.updateOutputAreaSize(newWidth, newHeight, false);
log.info(`Output area bounds updated: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${newWidth}, h=${newHeight}`);
log.info(`Extensions: top=${ext.top}, bottom=${ext.bottom}, left=${ext.left}, right=${ext.right}`);
}
}

View File

@@ -5,11 +5,17 @@ import {ComfyApp} from "../../scripts/app.js";
// @ts-ignore // @ts-ignore
import {api} from "../../scripts/api.js"; import {api} from "../../scripts/api.js";
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { showErrorNotification } from "./utils/NotificationUtils.js";
import { uploadCanvasAsImage, uploadCanvasWithMaskAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.js";
import { processImageToMask, processMaskForViewport } from "./utils/MaskProcessingUtils.js";
import { convertToImage } from "./utils/ImageUtils.js";
import { updateNodePreview } from "./utils/PreviewUtils.js";
import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js"; import { mask_editor_showing, mask_editor_listen_for_cancel } from "./utils/mask_utils.js";
import { createCanvas } from "./utils/CommonUtils.js";
const log = createModuleLogger('CanvasMask'); const log = createModuleLogger('MaskEditorIntegration');
export class CanvasMask { export class MaskEditorIntegration {
canvas: any; canvas: any;
editorWasShowing: any; editorWasShowing: any;
maskEditorCancelled: any; maskEditorCancelled: any;
@@ -61,7 +67,7 @@ export class CanvasMask {
blob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob(); blob = await this.canvas.canvasLayers.getFlattenedCanvasAsBlob();
} else { } else {
log.debug('Getting flattened canvas for mask editor (with mask)'); log.debug('Getting flattened canvas for mask editor (with mask)');
blob = await this.canvas.canvasLayers.getFlattenedCanvasForMaskEditor(); blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
} }
if (!blob) { if (!blob) {
@@ -72,34 +78,12 @@ export class CanvasMask {
log.debug('Canvas blob created successfully, size:', blob.size); log.debug('Canvas blob created successfully, size:', blob.size);
try { try {
const formData = new FormData(); // Use ImageUploadUtils to upload the blob
const filename = `layerforge-mask-edit-${+new Date()}.png`; const uploadResult = await uploadImageBlob(blob, {
formData.append("image", blob, filename); filenamePrefix: 'layerforge-mask-edit'
formData.append("overwrite", "true");
formData.append("type", "temp");
log.debug('Uploading image to server:', filename);
const response = await api.fetchApi("/upload/image", {
method: "POST",
body: formData,
}); });
if (!response.ok) { this.node.imgs = [uploadResult.imageElement];
throw new Error(`Failed to upload image: ${response.statusText}`);
}
const data = await response.json();
log.debug('Image uploaded successfully:', data);
const img = new Image();
img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
await new Promise((res, rej) => {
img.onload = res;
img.onerror = rej;
});
this.node.imgs = [img];
log.info('Opening ComfyUI mask editor'); log.info('Opening ComfyUI mask editor');
ComfyApp.copyToClipspace(this.node); ComfyApp.copyToClipspace(this.node);
@@ -118,11 +102,58 @@ export class CanvasMask {
} 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 as Error).message}`); showErrorNotification(`Error: ${(error as Error).message}`);
} }
} }
/**
* Oblicza dynamiczny czas oczekiwania na podstawie rozmiaru obrazu
* @returns {number} Czas oczekiwania w milisekundach
*/
calculateDynamicWaitTime(): number {
try {
// Get canvas dimensions from output area bounds
const bounds = this.canvas.outputAreaBounds;
const width = bounds.width;
const height = bounds.height;
// Calculate total pixels
const totalPixels = width * height;
// Define wait time based on image size
let waitTime = 500; // Base wait time for small images
if (totalPixels <= 1000 * 1000) {
// Below 1MP (1000x1000) - 500ms
waitTime = 500;
} else if (totalPixels <= 2000 * 2000) {
// 1MP to 4MP (2000x2000) - 1000ms
waitTime = 1000;
} else if (totalPixels <= 4000 * 4000) {
// 4MP to 16MP (4000x4000) - 2000ms
waitTime = 2000;
} else if (totalPixels <= 6000 * 6000) {
// 16MP to 36MP (6000x6000) - 4000ms
waitTime = 4000;
} else {
// Above 36MP - 6000ms
waitTime = 6000;
}
log.debug("Calculated dynamic wait time", {
imageSize: `${width}x${height}`,
totalPixels: totalPixels,
waitTime: waitTime
});
return waitTime;
} catch (error) {
log.warn("Error calculating dynamic wait time, using default 1000ms", error);
return 1000; // Fallback to 1 second
}
}
/** /**
* Czeka na otwarcie mask editora i automatycznie nakłada predefiniowaną maskę * Czeka na otwarcie mask editora i automatycznie nakłada predefiniowaną maskę
*/ */
@@ -178,12 +209,13 @@ export class CanvasMask {
} }
if (editorReady) { if (editorReady) {
// Calculate dynamic wait time based on image size
log.info("Applying mask to editor after", attempts * 100, "ms wait"); const waitTime = this.calculateDynamicWaitTime();
log.info("Applying mask to editor after", waitTime, "ms wait (dynamic based on image size)");
setTimeout(() => { setTimeout(() => {
this.applyMaskToEditor(this.pendingMask); this.applyMaskToEditor(this.pendingMask);
this.pendingMask = null; this.pendingMask = null;
}, 300); }, waitTime);
} else if (attempts < maxAttempts) { } else if (attempts < maxAttempts) {
if (attempts % 10 === 0) { if (attempts % 10 === 0) {
@@ -305,62 +337,24 @@ export class CanvasMask {
* @param {number} targetHeight - Docelowa wysokość * @param {number} targetHeight - Docelowa wysokość
* @param {Object} maskColor - Kolor maski {r, g, b} * @param {Object} maskColor - Kolor maski {r, g, b}
* @returns {HTMLCanvasElement} Przetworzona maska * @returns {HTMLCanvasElement} Przetworzona maska
*/async processMaskForEditor(maskData: any, targetWidth: any, targetHeight: any, maskColor: any) { */
// Współrzędne przesunięcia (pan) widoku edytora async processMaskForEditor(maskData: any, targetWidth: any, targetHeight: any, maskColor: any) {
const panX = this.maskTool.x; // Pozycja maski w świecie względem output bounds
const panY = this.maskTool.y; const bounds = this.canvas.outputAreaBounds;
const maskWorldX = this.maskTool.x;
const maskWorldY = this.maskTool.y;
const panX = maskWorldX - bounds.x;
const panY = maskWorldY - bounds.y;
log.info("Processing mask for editor:", { // Use MaskProcessingUtils for viewport processing
sourceSize: {width: maskData.width, height: maskData.height}, return await processMaskForViewport(
targetSize: {width: targetWidth, height: targetHeight}, maskData,
viewportPan: {x: panX, y: panY} targetWidth,
}); targetHeight,
{ x: panX, y: panY },
const tempCanvas = document.createElement('canvas'); maskColor
tempCanvas.width = targetWidth; );
tempCanvas.height = targetHeight; }
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true});
const sourceX = -panX;
const sourceY = -panY;
if (tempCtx) {
tempCtx.drawImage(
maskData, // Źródło: pełna maska z "output area"
sourceX, // sx: Prawdziwa współrzędna X na dużej masce (np. 1000)
sourceY, // sy: Prawdziwa współrzędna Y na dużej masce (np. 1000)
targetWidth, // sWidth: Szerokość wycinanego fragmentu
targetHeight, // sHeight: Wysokość wycinanego fragmentu
0, // dx: Gdzie wkleić w płótnie docelowym (zawsze 0)
0, // dy: Gdzie wkleić w płótnie docelowym (zawsze 0)
targetWidth, // dWidth: Szerokość wklejanego obrazu
targetHeight // dHeight: Wysokość wklejanego obrazu
);
}
log.info("Mask viewport cropped correctly.", {
source: "maskData",
cropArea: {x: sourceX, y: sourceY, width: targetWidth, height: targetHeight}
});
// Reszta kodu (zmiana koloru) pozostaje bez zmian
if (tempCtx) {
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0) {
data[i] = maskColor.r;
data[i + 1] = maskColor.g;
data[i + 2] = maskColor.b;
}
}
tempCtx.putImageData(imageData, 0, 0);
}
log.info("Mask processing completed - color applied.");
return tempCanvas;
}
/** /**
* Tworzy obiekt Image z obecnej maski canvas * Tworzy obiekt Image z obecnej maski canvas
@@ -402,10 +396,7 @@ export class CanvasMask {
} }
const maskCanvas = this.maskTool.maskCanvas; const maskCanvas = this.maskTool.maskCanvas;
const savedCanvas = document.createElement('canvas'); const { canvas: savedCanvas, ctx: savedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', {willReadFrequently: true});
savedCanvas.width = maskCanvas.width;
savedCanvas.height = maskCanvas.height;
const savedCtx = savedCanvas.getContext('2d', {willReadFrequently: true});
if (savedCtx) { if (savedCtx) {
savedCtx.drawImage(maskCanvas, 0, 0); savedCtx.drawImage(maskCanvas, 0, 0);
} }
@@ -499,64 +490,27 @@ export class CanvasMask {
return; return;
} }
log.debug("Creating temporary canvas for mask processing"); // Process image to mask using MaskProcessingUtils
const tempCanvas = document.createElement('canvas'); log.debug("Processing image to mask using utils");
tempCanvas.width = this.canvas.width; const bounds = this.canvas.outputAreaBounds;
tempCanvas.height = this.canvas.height; const processedMask = await processImageToMask(resultImage, {
const tempCtx = tempCanvas.getContext('2d', {willReadFrequently: true}); targetWidth: bounds.width,
targetHeight: bounds.height,
invertAlpha: true
});
if (tempCtx) { // Convert processed mask to image
tempCtx.drawImage(resultImage, 0, 0, this.canvas.width, this.canvas.height); const maskAsImage = await convertToImage(processedMask);
log.debug("Processing image data to create mask"); log.debug("Applying mask using chunk system", {
const imageData = tempCtx.getImageData(0, 0, this.canvas.width, this.canvas.height); boundsPos: {x: bounds.x, y: bounds.y},
const data = imageData.data; maskSize: {width: bounds.width, height: bounds.height}
});
for (let i = 0; i < data.length; i += 4) { this.maskTool.setMask(maskAsImage);
const originalAlpha = data[i + 3];
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = 255 - originalAlpha;
}
tempCtx.putImageData(imageData, 0, 0); // Update node preview using PreviewUtils
} await updateNodePreview(this.canvas, this.node, true);
log.debug("Converting processed mask to image");
const maskAsImage = new Image();
maskAsImage.src = tempCanvas.toDataURL();
await new Promise(resolve => maskAsImage.onload = resolve);
const maskCtx = this.maskTool.maskCtx;
const destX = -this.maskTool.x;
const destY = -this.maskTool.y;
log.debug("Applying mask to canvas", {destX, destY});
maskCtx.globalCompositeOperation = 'source-over';
maskCtx.clearRect(destX, destY, this.canvas.width, this.canvas.height);
maskCtx.drawImage(maskAsImage, destX, destY);
this.canvas.render();
this.canvas.saveState();
log.debug("Creating new preview image");
const new_preview = new Image();
const blob = await this.canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (blob) {
new_preview.src = URL.createObjectURL(blob);
await new Promise(r => new_preview.onload = r);
this.node.imgs = [new_preview];
log.debug("New preview image created successfully");
} else {
this.node.imgs = [];
log.warn("Failed to create preview blob");
}
this.canvas.render();
this.savedMaskState = null; this.savedMaskState = null;
log.info("Mask editor result processed successfully"); log.info("Mask editor result processed successfully");

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,14 @@
// @ts-ignore
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
// @ts-ignore // @ts-ignore
import { ComfyApp } from "../../scripts/app.js"; import { ComfyApp } from "../../scripts/app.js";
import { createModuleLogger } from "./utils/LoggerUtils.js"; import { createModuleLogger } from "./utils/LoggerUtils.js";
import { showInfoNotification, showSuccessNotification, showErrorNotification } from "./utils/NotificationUtils.js";
import { uploadCanvasAsImage, uploadImageBlob } from "./utils/ImageUploadUtils.js";
import { processImageToMask } from "./utils/MaskProcessingUtils.js";
import { convertToImage } from "./utils/ImageUtils.js";
import { updateNodePreview } from "./utils/PreviewUtils.js";
import { validateAndFixClipspace } from "./utils/ClipspaceUtils.js";
import type { ComfyNode } from './types'; import type { ComfyNode } from './types';
const log = createModuleLogger('SAMDetectorIntegration'); const log = createModuleLogger('SAMDetectorIntegration');
@@ -14,38 +21,18 @@ const log = createModuleLogger('SAMDetectorIntegration');
// Function to register image in clipspace for Impact Pack compatibility // Function to register image in clipspace for Impact Pack compatibility
export const registerImageInClipspace = async (node: ComfyNode, blob: Blob): Promise<HTMLImageElement | null> => { export const registerImageInClipspace = async (node: ComfyNode, blob: Blob): Promise<HTMLImageElement | null> => {
try { try {
// Upload the image to ComfyUI's temp storage for clipspace access // Use ImageUploadUtils to upload the blob
const formData = new FormData(); const uploadResult = await uploadImageBlob(blob, {
const filename = `layerforge-sam-${node.id}-${Date.now()}.png`; // Use timestamp for SAM Detector filenamePrefix: 'layerforge-sam',
formData.append("image", blob, filename); nodeId: node.id
formData.append("overwrite", "true");
formData.append("type", "temp");
const response = await api.fetchApi("/upload/image", {
method: "POST",
body: formData,
}); });
if (response.ok) { log.debug(`Image registered in clipspace for node ${node.id}: ${uploadResult.filename}`);
const data = await response.json(); return uploadResult.imageElement;
// Create a proper image element with the server URL
const clipspaceImg = new Image();
clipspaceImg.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
// Wait for image to load
await new Promise((resolve, reject) => {
clipspaceImg.onload = resolve;
clipspaceImg.onerror = reject;
});
log.debug(`Image registered in clipspace for node ${node.id}: ${filename}`);
return clipspaceImg;
}
} catch (error) { } catch (error) {
log.debug("Failed to register image in clipspace:", error); log.debug("Failed to register image in clipspace:", error);
return null;
} }
return null;
}; };
// Function to monitor for SAM Detector modal closure and apply masks to LayerForge // Function to monitor for SAM Detector modal closure and apply masks to LayerForge
@@ -218,7 +205,7 @@ function handleSAMDetectorModalClosed(node: ComfyNode) {
log.info("No new image detected after SAM Detector modal closure"); log.info("No new image detected after SAM Detector modal closure");
// Show info notification // Show info notification
showNotification("SAM Detector closed. No mask was applied.", "#4a6cd4", 3000); showInfoNotification("SAM Detector closed. No mask was applied.");
} }
} else { } else {
log.info("No image available after SAM Detector modal closure"); log.info("No image available after SAM Detector modal closure");
@@ -269,7 +256,7 @@ function monitorSAMDetectorChanges(node: ComfyNode) {
setTimeout(checkForChanges, 500); setTimeout(checkForChanges, 500);
} }
// Function to handle SAM Detector result (using same logic as CanvasMask.handleMaskEditorClose) // Function to handle SAM Detector result (using same logic as MaskEditorIntegration.handleMaskEditorClose)
async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageElement) { async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageElement) {
try { try {
log.info("Handling SAM Detector result for node", node.id); log.info("Handling SAM Detector result for node", node.id);
@@ -283,7 +270,7 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
const canvas = canvasWidget; // canvasWidget is the Canvas object, not canvasWidget.canvas const canvas = canvasWidget; // canvasWidget is the Canvas object, not canvasWidget.canvas
// Wait for the result image to load (same as CanvasMask) // Wait for the result image to load (same as MaskEditorIntegration)
try { try {
// First check if the image is already loaded // First check if the image is already loaded
if (resultImage.complete && resultImage.naturalWidth > 0) { if (resultImage.complete && resultImage.naturalWidth > 0) {
@@ -296,157 +283,139 @@ async function handleSAMDetectorResult(node: ComfyNode, resultImage: HTMLImageEl
log.debug("Attempting to reload SAM result image"); log.debug("Attempting to reload SAM result image");
const originalSrc = resultImage.src; const originalSrc = resultImage.src;
// Add cache-busting parameter to force fresh load // Check if it's a data URL (base64) - don't add parameters to data URLs
const url = new URL(originalSrc); if (originalSrc.startsWith('data:')) {
url.searchParams.set('_t', Date.now().toString()); log.debug("Image is a data URL, skipping reload with parameters");
// For data URLs, just ensure the image is loaded
await new Promise((resolve, reject) => { if (!resultImage.complete || resultImage.naturalWidth === 0) {
const img = new Image(); await new Promise((resolve, reject) => {
img.crossOrigin = "anonymous"; const img = new Image();
img.onload = () => { img.onload = () => {
// Copy the loaded image data to the original image resultImage.width = img.width;
resultImage.src = img.src; resultImage.height = img.height;
resultImage.width = img.width; log.debug("Data URL image loaded successfully", {
resultImage.height = img.height; width: img.width,
log.debug("SAM result image reloaded successfully", { height: img.height
width: img.width, });
height: img.height, resolve(img);
originalSrc: originalSrc, };
newSrc: img.src img.onerror = (error) => {
log.error("Failed to load data URL image", error);
reject(error);
};
img.src = originalSrc; // Use original src without modifications
}); });
resolve(img); }
}; } else {
img.onerror = (error) => { // For regular URLs, add cache-busting parameter
log.error("Failed to reload SAM result image", { const url = new URL(originalSrc);
originalSrc: originalSrc, url.searchParams.set('_t', Date.now().toString());
newSrc: url.toString(),
error: error await new Promise((resolve, reject) => {
}); const img = new Image();
reject(error); img.crossOrigin = "anonymous";
}; img.onload = () => {
img.src = url.toString(); // Copy the loaded image data to the original image
}); resultImage.src = img.src;
resultImage.width = img.width;
resultImage.height = img.height;
log.debug("SAM result image reloaded successfully", {
width: img.width,
height: img.height,
originalSrc: originalSrc,
newSrc: img.src
});
resolve(img);
};
img.onerror = (error) => {
log.error("Failed to reload SAM result image", {
originalSrc: originalSrc,
newSrc: url.toString(),
error: error
});
reject(error);
};
img.src = url.toString();
});
}
} }
} catch (error) { } catch (error) {
log.error("Failed to load image from SAM Detector.", error); log.error("Failed to load image from SAM Detector.", error);
showNotification("Failed to load SAM Detector result. The mask file may not be available.", "#c54747", 5000); showErrorNotification("Failed to load SAM Detector result. The mask file may not be available.");
return; return;
} }
// Create temporary canvas for mask processing (same as CanvasMask) // Process image to mask using MaskProcessingUtils
log.debug("Creating temporary canvas for mask processing"); log.debug("Processing image to mask using utils");
const tempCanvas = document.createElement('canvas'); const processedMask = await processImageToMask(resultImage, {
tempCanvas.width = canvas.width; targetWidth: resultImage.width,
tempCanvas.height = canvas.height; targetHeight: resultImage.height,
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true }); invertAlpha: true
});
if (tempCtx) { // Convert processed mask to image
tempCtx.drawImage(resultImage, 0, 0, canvas.width, canvas.height); const maskAsImage = await convertToImage(processedMask);
log.debug("Processing image data to create mask");
const imageData = tempCtx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
// Convert to mask format (same as CanvasMask)
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
data[i] = 255;
data[i + 1] = 255;
data[i + 2] = 255;
data[i + 3] = 255 - originalAlpha;
}
tempCtx.putImageData(imageData, 0, 0);
}
// Convert processed mask to image (same as CanvasMask)
log.debug("Converting processed mask to image");
const maskAsImage = new Image();
maskAsImage.src = tempCanvas.toDataURL();
await new Promise(resolve => maskAsImage.onload = resolve);
// Apply mask to LayerForge canvas using MaskTool.setMask method // Apply mask to LayerForge canvas using MaskTool.setMask method
log.debug("Checking canvas and maskTool availability", { log.debug("Checking canvas and maskTool availability", {
hasCanvas: !!canvas, hasCanvas: !!canvas,
hasCanvasProperty: !!canvas.canvas,
canvasCanvasKeys: canvas.canvas ? Object.keys(canvas.canvas) : [],
hasMaskTool: !!canvas.maskTool, hasMaskTool: !!canvas.maskTool,
hasCanvasMaskTool: !!(canvas.canvas && canvas.canvas.maskTool),
maskToolType: typeof canvas.maskTool, maskToolType: typeof canvas.maskTool,
canvasMaskToolType: canvas.canvas ? typeof canvas.canvas.maskTool : 'undefined',
canvasKeys: Object.keys(canvas) canvasKeys: Object.keys(canvas)
}); });
if (!canvas.maskTool) { // Get the actual Canvas object and its maskTool
const actualCanvas = canvas.canvas || canvas;
const maskTool = actualCanvas.maskTool;
if (!maskTool) {
log.error("MaskTool is not available. Canvas state:", { log.error("MaskTool is not available. Canvas state:", {
hasCanvas: !!canvas, hasCanvas: !!canvas,
hasActualCanvas: !!actualCanvas,
canvasConstructor: canvas.constructor.name, canvasConstructor: canvas.constructor.name,
actualCanvasConstructor: actualCanvas ? actualCanvas.constructor.name : 'undefined',
canvasKeys: Object.keys(canvas), canvasKeys: Object.keys(canvas),
maskToolValue: canvas.maskTool actualCanvasKeys: actualCanvas ? Object.keys(actualCanvas) : [],
maskToolValue: maskTool
}); });
throw new Error("Mask tool not available or not initialized"); throw new Error("Mask tool not available or not initialized");
} }
log.debug("Applying SAM mask to canvas using addMask method"); log.debug("Applying SAM mask to canvas using setMask method");
// Use the addMask method which overlays on existing mask without clearing it // Use the setMask method which clears existing mask and sets new one
canvas.maskTool.addMask(maskAsImage); maskTool.setMask(maskAsImage);
// Update canvas and save state (same as CanvasMask) // Update canvas and save state (same as MaskEditorIntegration)
canvas.render(); actualCanvas.render();
canvas.saveState(); actualCanvas.saveState();
// Create new preview image (same as CanvasMask) // Update node preview using PreviewUtils
log.debug("Creating new preview image"); await updateNodePreview(actualCanvas, node, true);
const new_preview = new Image();
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (blob) {
new_preview.src = URL.createObjectURL(blob);
await new Promise(r => new_preview.onload = r);
node.imgs = [new_preview];
log.debug("New preview image created successfully");
} else {
log.warn("Failed to create preview blob");
}
canvas.render();
log.info("SAM Detector mask applied successfully to LayerForge canvas"); log.info("SAM Detector mask applied successfully to LayerForge canvas");
// Show success notification // Show success notification
showNotification("SAM Detector mask applied to LayerForge!", "#4a7c59", 3000); showSuccessNotification("SAM Detector mask applied to LayerForge!");
} catch (error: any) { } catch (error: any) {
log.error("Error processing SAM Detector result:", error); log.error("Error processing SAM Detector result:", error);
// Show error notification // Show error notification
showNotification(`Failed to apply SAM mask: ${error.message}`, "#c54747", 5000); showErrorNotification(`Failed to apply SAM mask: ${error.message}`);
} finally { } finally {
(node as any).samMonitoringActive = false; (node as any).samMonitoringActive = false;
(node as any).samOriginalImgSrc = null; (node as any).samOriginalImgSrc = null;
} }
} }
// Helper function to show notifications
function showNotification(message: string, backgroundColor: string, duration: number) { // Store original onClipspaceEditorSave function to restore later
const notification = document.createElement('div'); let originalOnClipspaceEditorSave: (() => void) | null = null;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${backgroundColor};
color: white;
padding: 12px 16px;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
z-index: 10001;
font-size: 14px;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, duration);
}
// Function to setup SAM Detector hook in menu options // Function to setup SAM Detector hook in menu options
export function setupSAMDetectorHook(node: ComfyNode, options: any[]) { export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
@@ -467,67 +436,67 @@ export function setupSAMDetectorHook(node: ComfyNode, options: any[]) {
log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring"); log.info("Intercepted 'Open in SAM Detector' - automatically sending to clipspace and starting monitoring");
// Automatically send canvas to clipspace and start monitoring // Automatically send canvas to clipspace and start monitoring
if ((node as any).canvasWidget && (node as any).canvasWidget.canvas) { if ((node as any).canvasWidget) {
const canvas = (node as any).canvasWidget; // canvasWidget IS the Canvas object const canvasWidget = (node as any).canvasWidget;
const canvas = canvasWidget.canvas || canvasWidget; // Get actual Canvas object
// Get the flattened canvas as blob // Use ImageUploadUtils to upload canvas and get server URL (Impact Pack compatibility)
const blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob(); const uploadResult = await uploadCanvasAsImage(canvas, {
if (!blob) { filenamePrefix: 'layerforge-sam',
throw new Error("Failed to generate canvas blob"); nodeId: node.id
}
// Upload the image to ComfyUI's temp storage
const formData = new FormData();
const filename = `layerforge-sam-${node.id}-${Date.now()}.png`; // Unique filename with timestamp
formData.append("image", blob, filename);
formData.append("overwrite", "true");
formData.append("type", "temp");
const response = await api.fetchApi("/upload/image", {
method: "POST",
body: formData,
}); });
if (!response.ok) { log.debug("Uploaded canvas for SAM Detector", {
throw new Error(`Failed to upload image: ${response.statusText}`); filename: uploadResult.filename,
} imageUrl: uploadResult.imageUrl,
width: uploadResult.imageElement.width,
const data = await response.json(); height: uploadResult.imageElement.height
log.debug('Image uploaded for SAM Detector:', data);
// Create image element with proper URL
const img = new Image();
img.crossOrigin = "anonymous"; // Add CORS support
// Wait for image to load before setting src
const imageLoadPromise = new Promise((resolve, reject) => {
img.onload = () => {
log.debug("SAM Detector image loaded successfully", {
width: img.width,
height: img.height,
src: img.src.substring(0, 100) + '...'
});
resolve(img);
};
img.onerror = (error) => {
log.error("Failed to load SAM Detector image", error);
reject(new Error("Failed to load uploaded image"));
};
}); });
// Set src after setting up event handlers
img.src = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
// Wait for image to load
await imageLoadPromise;
// Set the image to the node for clipspace // Set the image to the node for clipspace
node.imgs = [img]; node.imgs = [uploadResult.imageElement];
(node as any).clipspaceImg = img; (node as any).clipspaceImg = uploadResult.imageElement;
// Ensure proper clipspace structure for updated ComfyUI
if (!ComfyApp.clipspace) {
ComfyApp.clipspace = {};
}
// Set up clipspace with proper indices
ComfyApp.clipspace.imgs = [uploadResult.imageElement];
ComfyApp.clipspace.selectedIndex = 0;
ComfyApp.clipspace.combinedIndex = 0;
ComfyApp.clipspace.img_paste_mode = 'selected';
// Copy to ComfyUI clipspace // Copy to ComfyUI clipspace
ComfyApp.copyToClipspace(node); ComfyApp.copyToClipspace(node);
// Override onClipspaceEditorSave to fix clipspace structure before pasteFromClipspace
if (!originalOnClipspaceEditorSave) {
originalOnClipspaceEditorSave = ComfyApp.onClipspaceEditorSave;
ComfyApp.onClipspaceEditorSave = function() {
log.debug("SAM Detector onClipspaceEditorSave called, using unified clipspace validation");
// Use the unified clipspace validation function
const isValid = validateAndFixClipspace();
if (!isValid) {
log.error("Clipspace validation failed, cannot proceed with paste");
return;
}
// Call the original function
if (originalOnClipspaceEditorSave) {
originalOnClipspaceEditorSave.call(ComfyApp);
}
// Restore the original function after use
if (originalOnClipspaceEditorSave) {
ComfyApp.onClipspaceEditorSave = originalOnClipspaceEditorSave;
originalOnClipspaceEditorSave = null;
}
};
}
// Start monitoring for SAM Detector results // Start monitoring for SAM Detector results
startSAMDetectorMonitoring(node); startSAMDetectorMonitoring(node);

172
src/ShapeTool.ts Normal file
View File

@@ -0,0 +1,172 @@
import { createModuleLogger } from "./utils/LoggerUtils.js";
import type { Canvas } from './Canvas.js';
import type { Point, Layer } from './types.js';
const log = createModuleLogger('ShapeTool');
interface Shape {
points: Point[];
isClosed: boolean;
}
export class ShapeTool {
private canvas: Canvas;
public shape: Shape;
public isActive: boolean = false;
constructor(canvas: Canvas) {
this.canvas = canvas;
this.shape = {
points: [],
isClosed: false,
};
}
toggle() {
this.isActive = !this.isActive;
if (this.isActive) {
log.info('ShapeTool activated. Press "S" to exit.');
this.reset();
} else {
log.info('ShapeTool deactivated.');
this.reset();
}
this.canvas.render();
}
activate() {
if (!this.isActive) {
this.isActive = true;
log.info('ShapeTool activated. Hold Shift+S to draw.');
this.reset();
this.canvas.render();
}
}
deactivate() {
if (this.isActive) {
this.isActive = false;
log.info('ShapeTool deactivated.');
this.reset();
this.canvas.render();
}
}
addPoint(point: Point) {
if (this.shape.isClosed) {
this.reset();
}
// Check if the new point is close to the start point to close the shape
if (this.shape.points.length > 2) {
const firstPoint = this.shape.points[0];
const dx = point.x - firstPoint.x;
const dy = point.y - firstPoint.y;
if (Math.sqrt(dx * dx + dy * dy) < 10 / this.canvas.viewport.zoom) {
this.closeShape();
return;
}
}
this.shape.points.push(point);
this.canvas.render();
}
closeShape() {
if (this.shape.points.length > 2) {
this.shape.isClosed = true;
log.info('Shape closed with', this.shape.points.length, 'points.');
this.canvas.defineOutputAreaWithShape(this.shape);
this.reset();
}
this.canvas.render();
}
getBoundingBox(): { x: number, y: number, width: number, height: number } | null {
if (this.shape.points.length === 0) {
return null;
}
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
this.shape.points.forEach(p => {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
});
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
reset() {
this.shape = {
points: [],
isClosed: false,
};
log.info('ShapeTool reset.');
this.canvas.render();
}
render(ctx: CanvasRenderingContext2D) {
if (this.shape.points.length === 0) {
return;
}
ctx.save();
ctx.strokeStyle = 'rgba(0, 255, 255, 0.9)';
ctx.lineWidth = 2 / this.canvas.viewport.zoom;
ctx.setLineDash([8 / this.canvas.viewport.zoom, 4 / this.canvas.viewport.zoom]);
ctx.beginPath();
const startPoint = this.shape.points[0];
ctx.moveTo(startPoint.x, startPoint.y);
for (let i = 1; i < this.shape.points.length; i++) {
ctx.lineTo(this.shape.points[i].x, this.shape.points[i].y);
}
if (this.shape.isClosed) {
ctx.closePath();
ctx.fillStyle = 'rgba(0, 255, 255, 0.2)';
ctx.fill();
} else if (this.isActive) {
// Draw a line to the current mouse position
ctx.lineTo(this.canvas.lastMousePosition.x, this.canvas.lastMousePosition.y);
}
ctx.stroke();
// Draw vertices
const mouse = this.canvas.lastMousePosition;
const firstPoint = this.shape.points[0];
let highlightFirst = false;
if (!this.shape.isClosed && this.shape.points.length > 2 && mouse) {
const dx = mouse.x - firstPoint.x;
const dy = mouse.y - firstPoint.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 10 / this.canvas.viewport.zoom) {
highlightFirst = true;
}
}
this.shape.points.forEach((point, index) => {
ctx.beginPath();
if (index === 0 && highlightFirst) {
ctx.arc(point.x, point.y, 8 / this.canvas.viewport.zoom, 0, 2 * Math.PI);
ctx.fillStyle = 'yellow';
} else {
ctx.arc(point.x, point.y, 4 / this.canvas.viewport.zoom, 0, 2 * Math.PI);
ctx.fillStyle = 'rgba(0, 255, 255, 1)';
}
ctx.fill();
});
ctx.restore();
}
}

170
src/css/blend_mode_menu.css Normal file
View File

@@ -0,0 +1,170 @@
/* Blend Mode Menu Styles */
#blend-mode-menu {
position: absolute;
top: 0;
left: 0;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
z-index: 10000;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
min-width: 200px;
}
#blend-mode-menu .blend-menu-title-bar {
background: #3a3a3a;
color: white;
padding: 8px 10px;
cursor: move;
user-select: none;
border-radius: 3px 3px 0 0;
font-size: 12px;
font-weight: bold;
border-bottom: 1px solid #4a4a4a;
display: flex;
justify-content: space-between;
align-items: center;
}
#blend-mode-menu .blend-menu-title-text {
flex: 1;
cursor: move;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#blend-mode-menu .blend-menu-close-button {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
padding: 0;
margin: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
transition: background-color 0.2s;
}
#blend-mode-menu .blend-menu-close-button:hover {
background-color: #4a4a4a;
}
#blend-mode-menu .blend-menu-close-button:focus {
background-color: transparent;
}
#blend-mode-menu .blend-menu-content {
padding: 5px;
}
#blend-mode-menu .blend-area-container {
padding: 5px 10px;
border-bottom: 1px solid #4a4a4a;
}
#blend-mode-menu .blend-area-label {
color: white;
display: block;
margin-bottom: 5px;
font-size: 12px;
}
#blend-mode-menu .blend-area-slider {
width: 100%;
margin: 5px 0;
-webkit-appearance: none;
height: 4px;
background: #555;
border-radius: 2px;
outline: none;
}
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
transition: background 0.2s;
}
#blend-mode-menu .blend-area-slider::-webkit-slider-thumb:hover {
background: #fff;
}
#blend-mode-menu .blend-area-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
}
#blend-mode-menu .blend-mode-container {
margin-bottom: 5px;
}
#blend-mode-menu .blend-mode-option {
padding: 5px 10px;
color: white;
cursor: pointer;
transition: background-color 0.2s;
}
#blend-mode-menu .blend-mode-option:hover {
background-color: #3a3a3a;
}
#blend-mode-menu .blend-mode-option.active {
background-color: #3a3a3a;
}
#blend-mode-menu .blend-opacity-slider {
width: 100%;
margin: 5px 0;
display: none;
-webkit-appearance: none;
height: 4px;
background: #555;
border-radius: 2px;
outline: none;
}
#blend-mode-menu .blend-mode-container.active .blend-opacity-slider {
display: block;
}
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
transition: background 0.2s;
}
#blend-mode-menu .blend-opacity-slider::-webkit-slider-thumb:hover {
background: #fff;
}
#blend-mode-menu .blend-opacity-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
}

View File

@@ -1,54 +1,125 @@
.painter-button { .painter-button {
background: linear-gradient(to bottom, #4a4a4a, #3a3a3a); background-color: #444;
border: 1px solid #2a2a2a; border: 1px solid #555;
border-radius: 4px; border-radius: 5px;
color: #ffffff; color: #fff;
padding: 6px 12px; padding: 6px 14px;
font-size: 12px; font-size: 12px;
font-weight: 550;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
min-width: 80px; min-width: 80px;
text-align: center; text-align: center;
margin: 2px; margin: 2px;
text-shadow: 0 1px 1px rgba(0,0,0,0.2); box-shadow: 0 1px 2px rgba(0,0,0,0.1);
text-shadow: 0 1px 2px rgb(0,0,0);
} }
.painter-button:hover { .painter-button:hover {
background: linear-gradient(to bottom, #5a5a5a, #4a4a4a); background-color: #555;
box-shadow: 0 1px 3px rgba(0,0,0,0.2); border-color: #666;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
transform: translateY(-1px);
} }
.painter-button:active { .painter-button:active {
background: linear-gradient(to bottom, #3a3a3a, #4a4a4a); background-color: #3a3a3a;
transform: translateY(1px); transform: translateY(0);
box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);
} }
.painter-button:disabled, .painter-button:disabled,
.painter-button:disabled:hover { .painter-button:disabled:hover {
background: #555; background-color: #3a3a3a;
color: #888; color: #777;
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
box-shadow: none; box-shadow: none;
border-color: #444; border-color: #4a4a4a;
opacity: 0.6;
} }
.painter-button.primary { .painter-button.primary {
background: linear-gradient(to bottom, #4a6cd4, #3a5cc4); background-color: #3a76d6;
border-color: #2a4cb4; border-color: #2a6ac4;
color: #fff;
text-shadow: 0 1px 2px rgb(0,0,0);
} }
.painter-button.primary:hover { .painter-button.primary:hover {
background: linear-gradient(to bottom, #5a7ce4, #4a6cd4); background-color: #4a86e4;
border-color: #3a76d6;
}
/* Crop mode button styling */
.painter-button#crop-mode-btn {
background-color: #444;
border-color: #555;
color: #fff;
transition: all 0.2s ease-in-out;
}
.painter-button#crop-mode-btn.primary {
background-color: #0080ff;
border-color: #0070e0;
color: #fff;
box-shadow: 0 0 8px rgba(0, 128, 255, 0.3);
}
.painter-button#crop-mode-btn.primary:hover {
background-color: #1090ff;
border-color: #0080ff;
box-shadow: 0 0 12px rgba(0, 128, 255, 0.4);
}
.painter-button#crop-mode-btn:hover {
background-color: #555;
border-color: #666;
}
.painter-button.success {
border-color: #4ae27a;
background-color: #444;
color: #fff;
box-shadow: 0 0 0 1.5px #4ae27a88;
}
.painter-button.success:hover {
border-color: #6aff9a;
box-shadow: 0 0 0 2.5px #6aff9a88;
background-color: #555;
}
.painter-button.danger {
border-color: #e24a4a;
background-color: #444;
color: #fff;
box-shadow: 0 0 0 1.5px #e24a4a88;
}
.painter-button.danger:hover {
border-color: #ff6a6a;
box-shadow: 0 0 0 2.5px #ff6a6a88;
background-color: #555;
}
.painter-button.icon-button {
width: 30px;
height: 30px;
min-width: 30px;
padding: 0;
font-size: 16px;
line-height: 30px; /* Match height */
display: flex;
align-items: center;
justify-content: center;
} }
.painter-controls { .painter-controls {
background: linear-gradient(to bottom, #404040, #383838); background-color: #2f2f2f;
border-bottom: 1px solid #2a2a2a; border-bottom: 1px solid #202020;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 1px 3px rgba(0,0,0,0.2);
padding: 8px; padding: 10px;
display: flex; display: flex;
gap: 6px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
@@ -56,57 +127,235 @@
.painter-slider-container { .painter-slider-container {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 4px;
color: #fff; color: #fff;
font-size: 12px; font-size: 12px;
min-width: 100px;
} }
.painter-slider-container input[type="range"] { .painter-slider-container input[type="range"] {
-webkit-appearance: none;
width: 80px; width: 80px;
height: 4px;
background: #555;
border-radius: 2px;
outline: none;
padding: 0;
margin: 0;
} }
.painter-slider-container input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
transition: background 0.2s;
}
.painter-slider-container input[type="range"]::-webkit-slider-thumb:hover {
background: #fff;
}
.painter-slider-container input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
}
.slider-value {
font-size: 11px;
color: #bbb;
margin-top: 2px;
min-height: 14px;
text-align: center;
}
.painter-button-group { .painter-button-group {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 4px;
background-color: rgba(0,0,0,0.2); background-color: transparent;
padding: 4px; padding: 0;
border-radius: 6px; border-radius: 6px;
} }
.painter-clipboard-group { .painter-clipboard-group {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 4px;
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 { .painter-clipboard-group .painter-button {
margin: 1px; margin: 1px;
height: 30px; /* Match switch height */
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
} }
/* --- Clipboard Switch Modern --- */
.clipboard-switch {
position: relative;
width: 90px;
height: 30px;
box-sizing: border-box;
background: linear-gradient(to right, #5a5a5a 30%, #3a76d6);
border-radius: 5px;
border: 1px solid #555;
cursor: pointer;
transition: background 0.3s ease-in-out, border-color 0.3s ease-in-out, opacity 0.3s ease-in-out;
user-select: none;
padding: 0;
font-family: inherit;
font-size: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.clipboard-switch:hover {
background: linear-gradient(to right, #6a6a6a 30%, #4a86e4);
border-color: #666;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
transform: translateY(-1px);
}
/* Mask switch: szaro-czarny gradient tylko dla maski */
.clipboard-switch.mask-switch {
background: linear-gradient(to right, #5a5a5a 30%, #e53935);
}
.clipboard-switch.mask-switch:hover {
background: linear-gradient(to right, #6a6a6a 30%, #ff5252);
}
.clipboard-switch:active {
background: linear-gradient(135deg, #3a76d6, #3a3a3a);
}
.clipboard-switch input[type="checkbox"] {
display: none;
}
.clipboard-switch .switch-track {
display: none;
}
.clipboard-switch .switch-knob {
position: absolute;
top: 2px;
left: 2px;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background-color: #5a5a5a;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: 2;
}
.clipboard-switch:hover .switch-knob {
background-color: #6a6a6a;
}
.clipboard-switch:hover .switch-knob {
background-color: #6a6a6a;
}
.clipboard-switch .switch-labels {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 550;
color: #ffffff;
pointer-events: none;
z-index: 1;
transition: opacity 0.3s ease-in-out;
text-shadow: 0 1px 2px rgb(0, 0, 0);
}
.clipboard-switch .switch-labels .text-clipspace,
.clipboard-switch .switch-labels .text-system {
position: absolute;
transition: opacity 0.2s ease-in-out;
}
.clipboard-switch .switch-labels .text-clipspace { opacity: 0; }
.clipboard-switch .switch-labels .text-system { opacity: 1; padding-left: 20px; }
.clipboard-switch .switch-knob .switch-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.clipboard-switch .switch-knob .switch-icon img {
width: 100%;
height: 100%;
}
/* Checked state */
.clipboard-switch:has(input:checked) {
background: linear-gradient(to right, #3a76d6, #5a5a5a 70%);
border-color: #2a6ac4;
}
.clipboard-switch:has(input:checked):hover {
background: linear-gradient(to right, #4a86e4, #6a6a6a 70%);
border-color: #3a76d6;
}
.clipboard-switch input:checked ~ .switch-knob {
left: calc(100% - 26px);
}
.clipboard-switch input:checked ~ .switch-knob .switch-icon img {
filter: none;
}
.clipboard-switch input:checked ~ .switch-labels .text-clipspace {
opacity: 1;
color: #fff;
padding-right: 20px;
}
.clipboard-switch input:checked ~ .switch-labels .text-system {
opacity: 0;
}
/* Disabled state for switch */
.clipboard-switch.disabled {
cursor: not-allowed;
opacity: 0.6;
background: #3a3a3a !important; /* Override gradient */
border-color: #4a4a4a !important;
transform: none !important;
box-shadow: none !important;
}
.clipboard-switch.disabled .switch-knob {
background-color: #4a4a4a !important;
}
.clipboard-switch.disabled .switch-labels {
color: #777 !important;
}
.painter-separator { .painter-separator {
width: 1px; width: 1px;
height: 28px; height: 24px;
background-color: #2a2a2a; background-color: #444;
margin: 0 8px; margin: 0 8px;
} }
@@ -182,17 +431,18 @@
.painter-tooltip { .painter-tooltip {
position: fixed; position: fixed;
display: none; display: none;
background: #3a3a3a; background: #2B2B2B;
color: #f0f0f0; color: #f0f0f0;
border: 1px solid #555; border: 1px solid #444;
border-radius: 8px; border-top: 2px solid #4a90e2;
border-radius: 6px;
padding: 12px 18px; padding: 12px 18px;
z-index: 9999; z-index: 9999;
font-size: 13px; font-size: 12px;
line-height: 1.7; line-height: 1.5;
width: auto; width: auto;
max-width: min(500px, calc(100vw - 40px)); max-width: min(450px, calc(100vw - 30px));
box-shadow: 0 4px 12px rgba(0,0,0,0.3); box-shadow: 0 4px 15px rgba(0,0,0,0.3);
pointer-events: none; pointer-events: none;
transform-origin: top left; transform-origin: top left;
transition: transform 0.2s ease; transition: transform 0.2s ease;
@@ -216,8 +466,9 @@
} }
.painter-tooltip table td { .painter-tooltip table td {
padding: 2px 8px; padding: 4px 8px;
vertical-align: middle; vertical-align: middle;
transition: background-color 0.2s;
} }
.painter-tooltip table td:first-child { .painter-tooltip table td:first-child {
@@ -231,7 +482,10 @@
} }
.painter-tooltip table tr:nth-child(odd) td { .painter-tooltip table tr:nth-child(odd) td {
background-color: rgba(0,0,0,0.1); background-color: rgba(255, 255, 255, 0.02);
}
.painter-tooltip table tr:hover td {
background-color: rgba(74, 144, 226, 0.15);
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -304,10 +558,15 @@
.painter-tooltip h4 { .painter-tooltip h4 {
margin-top: 10px; margin-top: 10px;
margin-bottom: 5px; margin-bottom: 6px;
color: #4a90e2; /* Jasnoniebieski akcent */ color: #4a90e2;
border-bottom: 1px solid #555; border-bottom: 1px solid #4a90e2;
padding-bottom: 4px; padding-bottom: 4px;
font-size: 14px;
font-weight: 600;
}
.painter-tooltip h4:first-child {
margin-top: 0;
} }
.painter-tooltip ul { .painter-tooltip ul {
@@ -317,13 +576,18 @@
} }
.painter-tooltip kbd { .painter-tooltip kbd {
background-color: #2a2a2a; background-color: #444;
border: 1px solid #1a1a1a; border: 1px solid #555;
border-radius: 3px; border-bottom-width: 2px;
border-radius: 4px;
padding: 2px 6px; padding: 2px 6px;
font-family: monospace; font-family: monospace;
font-size: 12px; font-size: 11px;
color: #d0d0d0; color: #e0e0e0;
box-shadow: 0 1px 1px rgba(0,0,0,0.15);
margin: 0 1px;
display: inline-block;
vertical-align: middle;
} }
.painter-container.has-focus { .painter-container.has-focus {
@@ -374,7 +638,7 @@
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: 111; z-index: 999999;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@@ -0,0 +1,204 @@
#layerforge-custom-shape-menu {
position: absolute;
top: 0;
left: 0;
background-color: #2f2f2f;
color: #e0e0e0;
padding: 12px;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
display: none;
flex-direction: column;
gap: 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
z-index: 1001;
border: 1px solid #202020;
user-select: none;
min-width: 220px;
}
#layerforge-custom-shape-menu .menu-line {
font-weight: 600;
color: #4a90e2;
padding-bottom: 5px;
border-bottom: 1px solid #444;
margin-bottom: 5px;
display: flex;
align-items: center;
gap: 8px;
}
/* --- MINIMIZED BAR INTERACTIVE STYLE --- */
.custom-shape-minimized-bar {
font-size: 13px;
font-weight: 600;
padding: 6px 12px;
border-radius: 6px;
background: #222;
color: #4a90e2;
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
margin: 0 0 8px 0;
user-select: none;
cursor: pointer;
border: 1px solid #444;
transition: background 0.18s, color 0.18s, box-shadow 0.18s, border 0.18s;
outline: none;
text-shadow: none;
display: flex;
align-items: center;
gap: 8px;
}
.custom-shape-minimized-bar:hover, .custom-shape-minimized-bar:focus {
background: #2a2a2a;
color: #4a90e2;
border: 1.5px solid #4a90e2;
box-shadow: 0 4px 16px #4a90e244;
}
#layerforge-custom-shape-menu .feature-container {
background-color: #3a3a3a;
border-radius: 6px;
padding: 10px 12px;
border: 1px solid #4a4a4a;
margin-bottom: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
#layerforge-custom-shape-menu .feature-container:last-child {
margin-bottom: 0;
}
#layerforge-custom-shape-menu .slider-container {
margin-top: 6px;
margin-bottom: 0;
display: none;
gap: 6px;
}
#layerforge-custom-shape-menu .slider-label {
font-size: 12px;
margin-bottom: 6px;
color: #e0e0e0;
}
#layerforge-custom-shape-menu input[type="range"] {
-webkit-appearance: none;
width: 100%;
height: 4px;
background: #555;
border-radius: 2px;
outline: none;
padding: 0;
margin: 0;
}
#layerforge-custom-shape-menu input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
transition: background 0.2s;
}
#layerforge-custom-shape-menu input[type="range"]::-webkit-slider-thumb:hover {
background: #fff;
}
#layerforge-custom-shape-menu input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
background: #e0e0e0;
border-radius: 50%;
cursor: pointer;
border: 2px solid #555;
}
#layerforge-custom-shape-menu .slider-value-display {
font-size: 11px;
text-align: center;
margin-top: 4px;
color: #bbb;
min-height: 14px;
}
#layerforge-custom-shape-menu .extension-slider-container {
margin: 10px 0;
}
#layerforge-custom-shape-menu .checkbox-container {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s;
position: relative;
}
#layerforge-custom-shape-menu .checkbox-container:hover {
background-color: #4a4a4a;
}
#layerforge-custom-shape-menu .checkbox-container input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
#layerforge-custom-shape-menu .checkbox-container .custom-checkbox {
height: 16px;
width: 16px;
background-color: #2a2a2a;
border: 1px solid #666;
border-radius: 3px;
transition: all 0.2s;
position: relative;
flex-shrink: 0;
}
#layerforge-custom-shape-menu .checkbox-container input:checked ~ .custom-checkbox {
background-color: #3a76d6;
border-color: #3a76d6;
}
#layerforge-custom-shape-menu .checkbox-container .custom-checkbox::after {
content: "";
position: absolute;
display: none;
left: 5px;
top: 1px;
width: 4px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
#layerforge-custom-shape-menu .checkbox-container input:checked ~ .custom-checkbox::after {
display: block;
}
.layerforge-tooltip {
position: fixed;
background-color: #2f2f2f;
color: #e0e0e0;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.4;
max-width: 250px;
word-wrap: break-word;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
border: 1px solid #202020;
z-index: 10000;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}

309
src/css/layers_panel.css Normal file
View File

@@ -0,0 +1,309 @@
/* Layers Panel Styles */
.layers-panel {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 8px;
height: 100%;
overflow: hidden;
font-family: Arial, sans-serif;
font-size: 12px;
color: #ffffff;
user-select: none;
display: flex;
flex-direction: column;
}
.layers-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid #3a3a3a;
margin-bottom: 8px;
}
.checkbox-container {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s;
position: relative;
}
.checkbox-container:hover {
background-color: #4a4a4a;
}
.checkbox-container input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
.checkbox-container .custom-checkbox {
height: 16px;
width: 16px;
background-color: #2a2a2a;
border: 1px solid #666;
border-radius: 3px;
transition: all 0.2s;
position: relative;
flex-shrink: 0;
}
.checkbox-container input:checked ~ .custom-checkbox {
background-color: #3a76d6;
border-color: #3a76d6;
}
.checkbox-container .custom-checkbox::after {
content: "";
position: absolute;
display: none;
left: 5px;
top: 1px;
width: 4px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.checkbox-container input:checked ~ .custom-checkbox::after {
display: block;
}
.checkbox-container input:indeterminate ~ .custom-checkbox {
background-color: #3a76d6;
border-color: #3a76d6;
}
.checkbox-container input:indeterminate ~ .custom-checkbox::after {
display: block;
content: "";
position: absolute;
top: 7px;
left: 3px;
width: 8px;
height: 2px;
background-color: white;
border: none;
transform: none;
box-shadow: none;
}
.checkbox-container:hover {
background-color: #4a4a4a;
}
.layers-panel-title {
font-weight: bold;
color: #ffffff;
}
.layers-panel-controls {
display: flex;
gap: 4px;
}
.layers-btn {
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #ffffff;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.layers-btn:hover {
background: #4a4a4a;
}
.layers-btn:active {
background: #5a5a5a;
}
.layers-btn:disabled {
background: #2a2a2a;
color: #666666;
cursor: not-allowed;
opacity: 0.5;
}
.layers-btn:disabled:hover {
background: #2a2a2a;
}
.layers-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.layer-row {
display: flex;
align-items: center;
padding: 6px 4px;
margin-bottom: 2px;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.15s ease;
position: relative;
gap: 6px;
}
.layer-row:hover {
background: rgba(255, 255, 255, 0.05);
}
.layer-row.selected {
background: #2d5aa0 !important;
box-shadow: inset 0 0 0 1px #4a7bc8;
}
.layer-row.dragging {
opacity: 0.6;
}
.layer-thumbnail {
width: 48px;
height: 48px;
border: 1px solid #4a4a4a;
border-radius: 2px;
background: transparent;
position: relative;
flex-shrink: 0;
overflow: hidden;
}
.layer-thumbnail canvas {
width: 100%;
height: 100%;
display: block;
}
.layer-thumbnail::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(45deg, #555 25%, transparent 25%),
linear-gradient(-45deg, #555 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #555 75%),
linear-gradient(-45deg, transparent 75%, #555 75%);
background-size: 8px 8px;
background-position: 0 0, 0 4px, 4px -4px, -4px 0px;
z-index: 1;
}
.layer-thumbnail canvas {
position: relative;
z-index: 2;
}
.layer-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 2px 4px;
border-radius: 2px;
color: #ffffff;
}
.layer-name.editing {
background: #4a4a4a;
border: 1px solid #6a6a6a;
outline: none;
color: #ffffff;
}
.layer-name input {
background: transparent;
border: none;
color: #ffffff;
font-size: 12px;
width: 100%;
outline: none;
}
.drag-insertion-line {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: #4a7bc8;
border-radius: 1px;
z-index: 1000;
box-shadow: 0 0 4px rgba(74, 123, 200, 0.6);
}
.layers-container::-webkit-scrollbar {
width: 6px;
}
.layers-container::-webkit-scrollbar-track {
background: #2a2a2a;
}
.layers-container::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 3px;
}
.layers-container::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}
.layer-visibility-toggle {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 2px;
font-size: 14px;
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.layer-visibility-toggle:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Icon container styles */
.layers-panel .icon-container {
display: flex;
align-items: center;
justify-content: center;
}
.layers-panel .icon-container img {
filter: brightness(0) invert(1);
}
.layers-panel .icon-container.visibility-hidden {
opacity: 0.5;
}
.layers-panel .icon-container.visibility-hidden img {
filter: brightness(0) invert(1);
opacity: 0.3;
}
.layers-panel .icon-container.fallback-text {
font-size: 10px;
color: #888888;
}

View File

@@ -4,7 +4,9 @@
<tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr> <tr><td><kbd>Mouse Wheel</kbd></td><td>Zoom view in/out</td></tr>
<tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr> <tr><td><kbd>Shift + Click (background)</kbd></td><td>Start resizing canvas area</td></tr>
<tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr> <tr><td><kbd>Shift + Ctrl + Click</kbd></td><td>Start moving entire canvas</td></tr>
<tr><td><kbd>Shift + S + Left Click</kbd></td><td>Draw custom shape for output area</td></tr>
<tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr> <tr><td><kbd>Single Click (background)</kbd></td><td>Deselect all layers</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Close fullscreen editor mode</td></tr>
</table> </table>
<h4>Clipboard & I/O</h4> <h4>Clipboard & I/O</h4>

View File

@@ -1,6 +1,14 @@
import type { Canvas as CanvasClass } from './Canvas'; import type { Canvas as CanvasClass } from './Canvas';
import type { CanvasLayers } from './CanvasLayers'; import type { CanvasLayers } from './CanvasLayers';
export interface ComfyWidget {
name: string;
type: string;
value: any;
callback?: (value: any) => void;
options?: any;
}
export interface Layer { export interface Layer {
id: string; id: string;
image: HTMLImageElement; image: HTMLImageElement;
@@ -16,22 +24,32 @@ export interface Layer {
zIndex: number; zIndex: number;
blendMode: string; blendMode: string;
opacity: number; opacity: number;
visible: boolean;
mask?: Float32Array; mask?: Float32Array;
flipH?: boolean; flipH?: boolean;
flipV?: boolean; flipV?: boolean;
blendArea?: number;
cropMode?: boolean; // czy warstwa jest w trybie crop
cropBounds?: { // granice przycinania
x: number; // offset od lewej krawędzi obrazu
y: number; // offset od górnej krawędzi obrazu
width: number; // szerokość widocznego obszaru
height: number; // wysokość widocznego obszaru
};
} }
export interface ComfyNode { export interface ComfyNode {
id: number; id: number;
type: string;
widgets: ComfyWidget[];
imgs?: HTMLImageElement[]; imgs?: HTMLImageElement[];
widgets: any[]; size?: [number, number];
size: [number, number];
graph: any;
canvasWidget?: any;
onResize?: () => void; onResize?: () => void;
addDOMWidget: (name: string, type: string, element: HTMLElement, options?: any) => any; setDirtyCanvas?: (dirty: boolean, propagate: boolean) => void;
addWidget: (type: string, name: string, value: any, callback?: (value: any) => void, options?: any) => any; graph?: any;
setDirtyCanvas: (force: boolean, dirty: boolean) => void; onRemoved?: () => void;
addDOMWidget?: (name: string, type: string, element: HTMLElement) => void;
inputs?: Array<{ link: any }>;
} }
declare global { declare global {
@@ -70,8 +88,14 @@ export interface Canvas {
imageCache: any; imageCache: any;
dataInitialized: boolean; dataInitialized: boolean;
pendingDataCheck: number | null; pendingDataCheck: number | null;
pendingInputDataCheck: number | null;
pendingBatchContext: any; pendingBatchContext: any;
canvasLayers: any; canvasLayers: any;
inputDataLoaded: boolean;
lastLoadedLinkId: any;
lastLoadedMaskLinkId: any;
lastLoadedImageSrc?: string;
outputAreaBounds: OutputAreaBounds;
saveState: () => void; saveState: () => void;
render: () => void; render: () => void;
updateSelection: (layers: Layer[]) => void; updateSelection: (layers: Layer[]) => void;
@@ -129,6 +153,18 @@ export interface Point {
y: number; y: number;
} }
export interface Shape {
points: Point[];
isClosed: boolean;
}
export interface OutputAreaBounds {
x: number; // Pozycja w świecie (może być ujemna)
y: number; // Pozycja w świecie (może być ujemna)
width: number; // Szerokość output area
height: number; // Wysokość output area
}
export interface Viewport { export interface Viewport {
x: number; x: number;
y: number; y: number;

View File

@@ -1,4 +1,7 @@
import {createModuleLogger} from "./LoggerUtils.js"; import {createModuleLogger} from "./LoggerUtils.js";
import { showNotification, showInfoNotification, showErrorNotification, showWarningNotification } from "./NotificationUtils.js";
import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js";
import { safeClipspacePaste } from "./ClipspaceUtils.js";
// @ts-ignore // @ts-ignore
import {api} from "../../../scripts/api.js"; import {api} from "../../../scripts/api.js";
@@ -25,62 +28,71 @@ export class ClipboardManager {
* @param {ClipboardPreference} preference - Clipboard preference ('system' or 'clipspace') * @param {ClipboardPreference} preference - Clipboard preference ('system' or 'clipspace')
* @returns {Promise<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async handlePaste(addMode: AddMode = 'mouse', preference: ClipboardPreference = 'system'): Promise<boolean> { handlePaste = withErrorHandling(async (addMode: AddMode = 'mouse', preference: ClipboardPreference = 'system'): Promise<boolean> => {
try { log.info(`ClipboardManager handling paste with preference: ${preference}`);
log.info(`ClipboardManager handling paste with preference: ${preference}`);
if (this.canvas.canvasLayers.internalClipboard.length > 0) { if (this.canvas.canvasLayers.internalClipboard.length > 0) {
log.info("Found layers in internal clipboard, pasting layers"); log.info("Found layers in internal clipboard, pasting layers");
this.canvas.canvasLayers.pasteLayers(); this.canvas.canvasLayers.pasteLayers();
showInfoNotification("Layers pasted from internal clipboard");
return true;
}
if (preference === 'clipspace') {
log.info("Attempting paste from ComfyUI Clipspace");
const success = await this.tryClipspacePaste(addMode);
if (success) {
return true; return true;
} }
log.info("No image found in ComfyUI Clipspace");
if (preference === 'clipspace') { // Don't show error here, will try system clipboard next
log.info("Attempting paste from ComfyUI Clipspace");
const success = await this.tryClipspacePaste(addMode);
if (success) {
return true;
}
log.info("No image found in ComfyUI Clipspace");
}
log.info("Attempting paste from system clipboard");
return await this.trySystemClipboardPaste(addMode);
} catch (err) {
log.error("ClipboardManager paste operation failed:", err);
return false;
} }
}
log.info("Attempting paste from system clipboard");
const systemSuccess = await this.trySystemClipboardPaste(addMode);
if (!systemSuccess) {
// No valid image found in any clipboard
if (preference === 'clipspace') {
showWarningNotification("No valid image found in Clipspace or system clipboard");
} else {
showWarningNotification("No valid image found in clipboard");
}
}
return systemSuccess;
}, 'ClipboardManager.handlePaste');
/** /**
* Attempts to paste from ComfyUI Clipspace * Attempts to paste from ComfyUI Clipspace
* @param {AddMode} addMode - The mode for adding the layer * @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async tryClipspacePaste(addMode: AddMode): Promise<boolean> { tryClipspacePaste = withErrorHandling(async (addMode: AddMode): Promise<boolean> => {
try { log.info("Attempting to paste from ComfyUI Clipspace");
log.info("Attempting to paste from ComfyUI Clipspace");
ComfyApp.pasteFromClipspace(this.canvas.node); // Use the unified clipspace validation and paste function
const pasteSuccess = safeClipspacePaste(this.canvas.node);
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) { if (!pasteSuccess) {
const clipspaceImage = this.canvas.node.imgs[0]; log.debug("Safe clipspace paste failed");
if (clipspaceImage && clipspaceImage.src) {
log.info("Successfully got image from ComfyUI Clipspace");
const img = new Image();
img.onload = async () => {
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
};
img.src = clipspaceImage.src;
return true;
}
}
return false;
} catch (clipspaceError) {
log.warn("ComfyUI Clipspace paste failed:", clipspaceError);
return false; return false;
} }
}
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);
showInfoNotification("Image pasted from Clipspace");
};
img.src = clipspaceImage.src;
return true;
}
}
return false;
}, 'ClipboardManager.tryClipspacePaste');
/** /**
* System clipboard paste - handles both image data and text paths * System clipboard paste - handles both image data and text paths
@@ -107,6 +119,7 @@ export class ClipboardManager {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from system clipboard"); log.info("Successfully loaded image from system clipboard");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image pasted from system clipboard");
}; };
if (event.target?.result) { if (event.target?.result) {
img.src = event.target.result as string; img.src = event.target.result as string;
@@ -150,11 +163,22 @@ export class ClipboardManager {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
log.debug("Found text in clipboard:", text); log.debug("Found text in clipboard:", text);
if (text && this.isValidImagePath(text)) { if (text) {
log.info("Found valid image path in clipboard:", text); // Check if it's a data URI (base64 encoded image)
const success = await this.loadImageFromPath(text, addMode); if (this.isDataURI(text)) {
if (success) { log.info("Found data URI in clipboard");
return true; const success = await this.loadImageFromDataURI(text, addMode);
if (success) {
return true;
}
}
// Check if it's a regular file path or URL
else if (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) { } catch (error) {
@@ -167,6 +191,50 @@ export class ClipboardManager {
} }
/**
* Checks if a text string is a data URI (base64 encoded image)
* @param {string} text - The text to check
* @returns {boolean} - True if the text is a data URI
*/
isDataURI(text: string): boolean {
if (!text || typeof text !== 'string') {
return false;
}
// Check if it starts with data:image
return text.trim().startsWith('data:image/');
}
/**
* Loads an image from a data URI (base64 encoded image)
* @param {string} dataURI - The data URI to load
* @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise
*/
async loadImageFromDataURI(dataURI: string, addMode: AddMode): Promise<boolean> {
return new Promise((resolve) => {
try {
const img = new Image();
img.onload = async () => {
log.info("Successfully loaded image from data URI");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image pasted from clipboard (base64)");
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load image from data URI");
showErrorNotification("Failed to load base64 image from clipboard", 5000, true);
resolve(false);
};
img.src = dataURI;
} catch (error) {
log.error("Error loading data URI:", error);
showErrorNotification("Error processing base64 image from clipboard", 5000, true);
resolve(false);
}
});
}
/** /**
* Validates if a text string is a valid image file path or URL * Validates if a text string is a valid image file path or URL
* @param {string} text - The text to validate * @param {string} text - The text to validate
@@ -242,15 +310,17 @@ export class ClipboardManager {
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous'; img.crossOrigin = 'anonymous';
return new Promise((resolve) => { return new Promise((resolve) => {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from URL"); log.info("Successfully loaded image from URL");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true); showInfoNotification("Image loaded from URL");
}; resolve(true);
img.onerror = () => { };
log.warn("Failed to load image from URL:", filePath); img.onerror = () => {
resolve(false); log.warn("Failed to load image from URL:", filePath);
}; showErrorNotification(`Failed to load image from URL\nThe link might be incorrect or may not point to an image file.: ${filePath}`, 5000, true);
resolve(false);
};
img.src = filePath; img.src = filePath;
}); });
} catch (error) { } catch (error) {
@@ -289,57 +359,58 @@ export class ClipboardManager {
* @param {AddMode} addMode - The mode for adding the layer * @param {AddMode} addMode - The mode for adding the layer
* @returns {Promise<boolean>} - True if successful, false otherwise * @returns {Promise<boolean>} - True if successful, false otherwise
*/ */
async loadFileViaBackend(filePath: string, addMode: AddMode): Promise<boolean> { loadFileViaBackend = withErrorHandling(async (filePath: string, addMode: AddMode): Promise<boolean> => {
try { if (!filePath) {
log.info("Loading file via ComfyUI backend:", filePath); throw createValidationError("File path is required", { filePath });
const response = await api.fetchApi("/ycnode/load_image_from_path", {
method: "POST",
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath
})
});
if (!response.ok) {
const errorData = await response.json();
log.debug("Backend failed to load image:", errorData.error);
return false;
}
const data = await response.json();
if (!data.success) {
log.debug("Backend returned error:", data.error);
return false;
}
log.info("Successfully loaded image via ComfyUI backend:", filePath);
const img = new Image();
const success: boolean = await new Promise((resolve) => {
img.onload = async () => {
log.info("Successfully loaded image from backend response");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load image from backend response");
resolve(false);
};
img.src = data.image_data;
});
return success;
} catch (error) {
log.debug("Error loading file via ComfyUI backend:", error);
return false;
} }
}
log.info("Loading file via ComfyUI backend:", filePath);
const response = await api.fetchApi("/ycnode/load_image_from_path", {
method: "POST",
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
file_path: filePath
})
});
if (!response.ok) {
const errorData = await response.json();
throw createNetworkError(`Backend failed to load image: ${errorData.error}`, {
filePath,
status: response.status,
statusText: response.statusText
});
}
const data = await response.json();
if (!data.success) {
throw createFileError(`Backend returned error: ${data.error}`, { filePath, backendError: data.error });
}
log.info("Successfully loaded image via ComfyUI backend:", filePath);
const img = new Image();
const success: boolean = await new Promise((resolve) => {
img.onload = async () => {
log.info("Successfully loaded image from backend response");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image loaded from file path");
resolve(true);
};
img.onerror = () => {
log.warn("Failed to load image from backend response");
resolve(false);
};
img.src = data.image_data;
});
return success;
}, 'ClipboardManager.loadFileViaBackend');
/** /**
* Prompts the user to select a file when a local path is detected * Prompts the user to select a file when a local path is detected
@@ -368,6 +439,7 @@ export class ClipboardManager {
img.onload = async () => { img.onload = async () => {
log.info("Successfully loaded image from file picker"); log.info("Successfully loaded image from file picker");
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
showInfoNotification("Image loaded from selected file");
resolve(true); resolve(true);
}; };
img.onerror = () => { img.onerror = () => {
@@ -401,7 +473,7 @@ export class ClipboardManager {
resolve(false); resolve(false);
}; };
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000); showInfoNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
document.body.appendChild(fileInput); document.body.appendChild(fileInput);
fileInput.click(); fileInput.click();
@@ -415,7 +487,7 @@ export class ClipboardManager {
showFilePathMessage(filePath: string): void { showFilePathMessage(filePath: string): void {
const fileName = filePath.split(/[\\\/]/).pop(); const fileName = filePath.split(/[\\\/]/).pop();
const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`; const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`;
this.showNotification(message, 5000); showNotification(message, "#c54747", 5000);
log.info("Showed file path limitation message to user"); log.info("Showed file path limitation message to user");
} }
@@ -489,36 +561,4 @@ export class ClipboardManager {
log.info("Showed enhanced empty clipboard message with file picker option"); log.info("Showed enhanced empty clipboard message with file picker option");
} }
/**
* Shows a temporary notification to the user
* @param {string} message - The message to show
* @param {number} duration - Duration in milliseconds
*/
showNotification(message: string, duration = 3000): void {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #333;
color: white;
padding: 12px 16px;
border-radius: 4px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
z-index: 10001;
max-width: 300px;
font-size: 14px;
line-height: 1.4;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, duration);
}
} }

114
src/utils/ClipspaceUtils.ts Normal file
View File

@@ -0,0 +1,114 @@
import { createModuleLogger } from "./LoggerUtils.js";
// @ts-ignore
import { ComfyApp } from "../../../scripts/app.js";
const log = createModuleLogger('ClipspaceUtils');
/**
* Validates and fixes ComfyUI clipspace structure to prevent 'Cannot read properties of undefined' errors
* @returns {boolean} - True if clipspace is valid and ready to use, false otherwise
*/
export function validateAndFixClipspace(): boolean {
log.debug("Validating and fixing clipspace structure");
// Check if clipspace exists
if (!ComfyApp.clipspace) {
log.debug("ComfyUI clipspace is not available");
return false;
}
// Validate clipspace structure
if (!ComfyApp.clipspace.imgs || ComfyApp.clipspace.imgs.length === 0) {
log.debug("ComfyUI clipspace has no images");
return false;
}
log.debug("Current clipspace state:", {
hasImgs: !!ComfyApp.clipspace.imgs,
imgsLength: ComfyApp.clipspace.imgs?.length,
selectedIndex: ComfyApp.clipspace.selectedIndex,
combinedIndex: ComfyApp.clipspace.combinedIndex,
img_paste_mode: ComfyApp.clipspace.img_paste_mode
});
// Ensure required indices are set
if (ComfyApp.clipspace.selectedIndex === undefined || ComfyApp.clipspace.selectedIndex === null) {
ComfyApp.clipspace.selectedIndex = 0;
log.debug("Fixed clipspace selectedIndex to 0");
}
if (ComfyApp.clipspace.combinedIndex === undefined || ComfyApp.clipspace.combinedIndex === null) {
ComfyApp.clipspace.combinedIndex = 0;
log.debug("Fixed clipspace combinedIndex to 0");
}
if (!ComfyApp.clipspace.img_paste_mode) {
ComfyApp.clipspace.img_paste_mode = 'selected';
log.debug("Fixed clipspace img_paste_mode to 'selected'");
}
// Ensure indices are within bounds
const maxIndex = ComfyApp.clipspace.imgs.length - 1;
if (ComfyApp.clipspace.selectedIndex > maxIndex) {
ComfyApp.clipspace.selectedIndex = maxIndex;
log.debug(`Fixed clipspace selectedIndex to ${maxIndex} (max available)`);
}
if (ComfyApp.clipspace.combinedIndex > maxIndex) {
ComfyApp.clipspace.combinedIndex = maxIndex;
log.debug(`Fixed clipspace combinedIndex to ${maxIndex} (max available)`);
}
// Verify the image at combinedIndex exists and has src
const combinedImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
if (!combinedImg || !combinedImg.src) {
log.debug("Image at combinedIndex is missing or has no src, trying to find valid image");
// Try to use the first available image
for (let i = 0; i < ComfyApp.clipspace.imgs.length; i++) {
if (ComfyApp.clipspace.imgs[i] && ComfyApp.clipspace.imgs[i].src) {
ComfyApp.clipspace.combinedIndex = i;
log.debug(`Fixed combinedIndex to ${i} (first valid image)`);
break;
}
}
// Final check - if still no valid image found
const finalImg = ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex];
if (!finalImg || !finalImg.src) {
log.error("No valid images found in clipspace after attempting fixes");
return false;
}
}
log.debug("Final clipspace structure:", {
selectedIndex: ComfyApp.clipspace.selectedIndex,
combinedIndex: ComfyApp.clipspace.combinedIndex,
img_paste_mode: ComfyApp.clipspace.img_paste_mode,
imgsLength: ComfyApp.clipspace.imgs?.length,
combinedImgSrc: ComfyApp.clipspace.imgs[ComfyApp.clipspace.combinedIndex]?.src?.substring(0, 50) + '...'
});
return true;
}
/**
* Safely calls ComfyApp.pasteFromClipspace after validating clipspace structure
* @param {any} node - The ComfyUI node to paste to
* @returns {boolean} - True if paste was successful, false otherwise
*/
export function safeClipspacePaste(node: any): boolean {
log.debug("Attempting safe clipspace paste");
if (!validateAndFixClipspace()) {
log.debug("Clipspace validation failed, cannot paste");
return false;
}
try {
ComfyApp.pasteFromClipspace(node);
log.debug("Successfully called pasteFromClipspace");
return true;
} catch (error) {
log.error("Error calling pasteFromClipspace:", error);
return false;
}
}

240
src/utils/IconLoader.ts Normal file
View File

@@ -0,0 +1,240 @@
import { createModuleLogger } from "./LoggerUtils.js";
import { createCanvas } from "./CommonUtils.js";
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
const log = createModuleLogger('IconLoader');
// Define tool constants for LayerForge
export const LAYERFORGE_TOOLS = {
VISIBILITY: 'visibility',
MOVE: 'move',
ROTATE: 'rotate',
SCALE: 'scale',
DELETE: 'delete',
DUPLICATE: 'duplicate',
BLEND_MODE: 'blend_mode',
OPACITY: 'opacity',
MASK: 'mask',
BRUSH: 'brush',
ERASER: 'eraser',
SHAPE: 'shape',
SETTINGS: 'settings',
SYSTEM_CLIPBOARD: 'system_clipboard',
CLIPSPACE: 'clipspace',
CROP: 'crop',
TRANSFORM: 'transform',
} as const;
// SVG Icons for LayerForge tools
const SYSTEM_CLIPBOARD_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm5 15H7v-2h10v2zm0-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>`;
const CLIPSPACE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <defs> <mask id="cutout"> <rect width="100%" height="100%" fill="white"/> <path d="M5.485 23.76c-.568 0-1.026-.207-1.325-.598-.307-.402-.387-.964-.22-1.54l.672-2.315a.605.605 0 00-.1-.536.622.622 0 00-.494-.243H2.085c-.568 0-1.026-.207-1.325-.598-.307-.403-.387-.964-.22-1.54l2.31-7.917.255-.87c.343-1.18 1.592-2.14 2.786-2.14h2.313c.276 0 .519-.18.595-.442l.764-2.633C9.906 1.208 11.155.249 12.35.249l4.945-.008h3.62c.568 0 1.027.206 1.325.597.307.402.387.964.22 1.54l-1.035 3.566c-.343 1.178-1.593 2.137-2.787 2.137l-4.956.01H11.37a.618.618 0 00-.594.441l-1.928 6.604a.605.605 0 00.1.537c.118.153.3.243.495.243l3.275-.006h3.61c.568 0 1.026.206 1.325.598.307.402.387.964.22 1.54l-1.036 3.565c-.342 1.179-1.592 2.138-2.786 2.138l-4.957.01h-3.61z" fill="black" transform="translate(4.8 4.8) scale(0.6)" /> </mask> </defs> <path d="M19 2h-4.18C14.4.84 13.3 0 12 0S9.6.84 9.18 2H5c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1z" fill="#ffffff" mask="url(#cutout)" /></svg>`;
const CROP_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M17 15h3V7c0-1.1-.9-2-2-2H10v3h7v7zM7 18V1H4v4H0v3h4v10c0 2 1 3 3 3h10v4h3v-4h4v-3H24z"/></svg>';
const TRANSFORM_ICON_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M11.3 17.096c.092-.044.34-.052 1.028-.044l.912.008.124.124c.184.184.184.408.004.584l-.128.132-.896.012c-.72.008-.924 0-1.036-.048-.18-.072-.284-.264-.256-.452.028-.168.092-.248.248-.316Zm-3.164 0c.096-.044.328-.052 1.036-.044l.916.008.116.132c.16.18.16.396 0 .576l-.116.132-.876.012c-.552.008-.928-.004-1.02-.032-.388-.112-.428-.62-.056-.784Zm-4.6-1.168.112-.096 1.42.004 1.424.004.116.116.116.116V17.48v1.408l-.116.116-.116.116H5.068h-1.42l-.112-.096-.112-.096L3.42 17.48V16.032l.112-.096ZM4.78 12.336c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.964.964l-.116.128c-.1.112-.144.132-.304.132s-.204-.02-.304-.132L4.644 14.4l-.004-.964v-.964l.136-.136Zm8.868-.648c-.008-.024-.004-.048.008-.048s1.504.512 3.312 1.136c1.812.624 4.252 1.464 5.424 1.868 1.168.404 2.128.744 2.128.76 0 .012-.24.108-.528.212-.292.104-1.468.52-2.616.928l-2.08.74-.936 2.62c-.512 1.44-.944 2.616-.956 2.616-.016 0-.86-2.424-1.88-5.392-1.02-2.964-1.864-5.412-1.876-5.44ZM19.292 9.08c.216-.088.432-.02.548.168.076.124.08.188.072 1.06l-.012.928-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12-.012-.928c-.008-.872-.004-.936.072-1.06.044-.072.12-.148.172-.168Zm-14.516.096c.104-.104.168-.136.284-.136s.18.032.284.136l.136.136v.956c0 1.064-.004 1.088-.268 1.2-.18.072-.376.012-.492-.148-.076-.104-.08-.172-.08-1.06V9.312l.136-.136ZM19.192 6c.096-.088.168-.116.288-.116s.192.028.288.116l.132.116V7.1v.98l-.116.12c-.1.104-.148.124-.304.124s-.204-.02-.304-.124l-.116-.12V7.096 6.112l.132-.116ZM4.816 5.964c.048-.044.152-.072.256-.072.144 0 .196.02.292.124l.116.124v.98.968l-.116.116c-.092.092-.152.116-.284.116-.408 0-.44-.28-.44-1.22s.012-1.016.176-1.148Zm9.516-3.192.14-.136.968.004h.968l.112.116c.152.152.188.3.108.468-.124.252-.196.276-1.044.288-.42.008-.84.004-.936-.012-.24-.036-.38-.192-.436-.408-.02-.156-.008-.184.12-.312Zm-3.156-.268.136.136h.956c1.064 0 1.088.004 1.2.268.072.172.016.372-.136.492-.096.076-.16.08-1.06.08h-.96l-.136-.136c-.104-.104-.136-.168-.136-.284s.032-.18.136-.284Zm-3.16 0 .136.136h.96c.94 0 .964.004 1.068.088.2.176.196.508-.004.668-.1.08-.156.084-1.064.084h-.96l-.136-.136c-.188-.188-.188-.38 0-.568Zm10.04-1.14c.044-.02.712-.032 1.476-.028l1.396.008.096.112.096.112v1.424 1.5l-.116.116-.116.116L19.48 4.72H18.072l-.116-.116-.116-.116V3.072c0-1.524.004-1.544.216-1.632ZM3.62 1.456c.184-.08 2.74-.08 2.896 0 .196.104.204.164.204 1.604s-.008 1.5-.204 1.604c-.148.076-2.732.084-2.896.008-.212-.096-.22-.148-.22-1.608s.008-1.516.22-1.608Z"/></svg>';
const LAYERFORGE_TOOL_ICONS = {
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
[LAYERFORGE_TOOLS.CLIPSPACE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CLIPSPACE_ICON_SVG)}`,
[LAYERFORGE_TOOLS.CROP]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(CROP_ICON_SVG)}`,
[LAYERFORGE_TOOLS.TRANSFORM]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRANSFORM_ICON_SVG)}`,
[LAYERFORGE_TOOLS.VISIBILITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>')}`,
[LAYERFORGE_TOOLS.MOVE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M13,20H11V8L5.5,13.5L4.08,12.08L12,4.16L19.92,12.08L18.5,13.5L13,8V20Z"/></svg>')}`,
[LAYERFORGE_TOOLS.ROTATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,6V9L16,5L12,1V4A8,8 0 0,0 4,12C4,13.57 4.46,15.03 5.24,16.26L6.7,14.8C6.25,13.97 6,13 6,12A6,6 0 0,1 12,6M18.76,7.74L17.3,9.2C17.74,10.04 18,11 18,12A6,6 0 0,1 12,18V15L8,19L12,23V20A8,8 0 0,0 20,12C20,10.43 19.54,8.97 18.76,7.74Z"/></svg>')}`,
[LAYERFORGE_TOOLS.SCALE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M22,18V22H18V20H20V18H22M22,6V10H20V8H18V6H22M2,6V10H4V8H6V6H2M2,18V22H6V20H4V18H2M16,8V10H14V12H16V14H14V16H12V14H10V12H12V10H10V8H12V6H14V8H16Z"/></svg>')}`,
[LAYERFORGE_TOOLS.DELETE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M9,3V4H4V6H5V19A2,2 0 0,0 7,21H17A2,2 0 0,0 19,19V6H20V4H15V3H9M7,6H17V19H7V6M9,8V17H11V8H9M13,8V17H15V8H13Z"/></svg>')}`,
[LAYERFORGE_TOOLS.DUPLICATE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"/></svg>')}`,
[LAYERFORGE_TOOLS.BLEND_MODE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20V4Z"/></svg>')}`,
[LAYERFORGE_TOOLS.OPACITY]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,20A6,6 0 0,1 6,14C6,10 12,3.25 12,3.25S18,10 18,14A6,6 0 0,1 12,20Z"/></svg>')}`,
[LAYERFORGE_TOOLS.MASK]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="#ffffff" stroke-width="2"/><circle cx="12" cy="12" r="5" fill="#ffffff"/></svg>')}`,
[LAYERFORGE_TOOLS.BRUSH]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M15.4565 9.67503L15.3144 9.53297C14.6661 8.90796 13.8549 8.43369 12.9235 8.18412C10.0168 7.40527 7.22541 9.05273 6.43185 12.0143C6.38901 12.1742 6.36574 12.3537 6.3285 12.8051C6.17423 14.6752 5.73449 16.0697 4.5286 17.4842C6.78847 18.3727 9.46572 18.9986 11.5016 18.9986C13.9702 18.9986 16.1644 17.3394 16.8126 14.9202C17.3306 12.9869 16.7513 11.0181 15.4565 9.67503ZM13.2886 6.21301L18.2278 2.37142C18.6259 2.0618 19.1922 2.09706 19.5488 2.45367L22.543 5.44787C22.8997 5.80448 22.9349 6.37082 22.6253 6.76891L18.7847 11.7068C19.0778 12.8951 19.0836 14.1721 18.7444 15.4379C17.8463 18.7897 14.8142 20.9986 11.5016 20.9986C8 20.9986 3.5 19.4967 1 17.9967C4.97978 14.9967 4.04722 13.1865 4.5 11.4967C5.55843 7.54658 9.34224 5.23935 13.2886 6.21301ZM16.7015 8.09161C16.7673 8.15506 16.8319 8.21964 16.8952 8.28533L18.0297 9.41984L20.5046 6.23786L18.7589 4.4921L15.5769 6.96698L16.7015 8.09161Z"/></svg>')}`,
[LAYERFORGE_TOOLS.ERASER]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M8.58564 8.85449L3.63589 13.8042L8.83021 18.9985L9.99985 18.9978V18.9966H11.1714L14.9496 15.2184L8.58564 8.85449ZM9.99985 7.44027L16.3638 13.8042L19.1922 10.9758L12.8283 4.61185L9.99985 7.44027ZM13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966Z"/></svg>')}`,
[LAYERFORGE_TOOLS.SHAPE]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M3 4H21C21.5523 4 22 4.44772 22 5V19C22 19.5523 21.5523 20 21 20H3C2.44772 20 2 19.5523 2 19V5C2 4.44772 2.44772 4 3 4ZM4 6V18H20V6H4Z"/></svg>')}`,
[LAYERFORGE_TOOLS.SETTINGS]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff"><path d="M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.22,8.95 2.27,9.22 2.46,9.37L4.5,11L5.13,18.93C5.17,19.18 5.38,19.36 5.63,19.36H18.37C18.62,19.36 18.83,19.18 18.87,18.93L19.5,11L21.54,9.37Z"/></svg>')}`
};
// Tool colors for LayerForge
const LAYERFORGE_TOOL_COLORS = {
[LAYERFORGE_TOOLS.VISIBILITY]: '#4285F4',
[LAYERFORGE_TOOLS.MOVE]: '#34A853',
[LAYERFORGE_TOOLS.ROTATE]: '#FBBC05',
[LAYERFORGE_TOOLS.SCALE]: '#EA4335',
[LAYERFORGE_TOOLS.DELETE]: '#FF6D01',
[LAYERFORGE_TOOLS.DUPLICATE]: '#46BDC6',
[LAYERFORGE_TOOLS.BLEND_MODE]: '#9C27B0',
[LAYERFORGE_TOOLS.OPACITY]: '#8BC34A',
[LAYERFORGE_TOOLS.MASK]: '#607D8B',
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292',
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
};
export interface IconCache {
[key: string]: HTMLCanvasElement | HTMLImageElement;
}
export class IconLoader {
private _iconCache: IconCache = {};
private _loadingPromises: Map<string, Promise<HTMLImageElement>> = new Map();
constructor() {
log.info('IconLoader initialized');
}
/**
* Preload all LayerForge tool icons
*/
preloadToolIcons = withErrorHandling(async (): Promise<void> => {
log.info('Starting to preload LayerForge tool icons');
const loadPromises = Object.keys(LAYERFORGE_TOOL_ICONS).map(tool => {
return this.loadIcon(tool);
});
await Promise.all(loadPromises);
log.info(`Successfully preloaded ${loadPromises.length} tool icons`);
}, 'IconLoader.preloadToolIcons');
/**
* Load a specific icon by tool name
*/
loadIcon = withErrorHandling(async (tool: string): Promise<HTMLImageElement> => {
if (!tool) {
throw createValidationError("Tool name is required", { tool });
}
// Check if already cached
if (this._iconCache[tool] && this._iconCache[tool] instanceof HTMLImageElement) {
return this._iconCache[tool] as HTMLImageElement;
}
// Check if already loading
if (this._loadingPromises.has(tool)) {
return this._loadingPromises.get(tool)!;
}
// Create fallback canvas first
const fallbackCanvas = this.createFallbackIcon(tool);
this._iconCache[tool] = fallbackCanvas;
// Start loading the SVG icon
const loadPromise = new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => {
this._iconCache[tool] = img;
this._loadingPromises.delete(tool);
log.debug(`Successfully loaded icon for tool: ${tool}`);
resolve(img);
};
img.onerror = (error) => {
log.warn(`Failed to load SVG icon for tool: ${tool}, using fallback`);
this._loadingPromises.delete(tool);
// Keep the fallback canvas in cache
reject(error);
};
const iconData = LAYERFORGE_TOOL_ICONS[tool as keyof typeof LAYERFORGE_TOOL_ICONS];
if (iconData) {
img.src = iconData;
} else {
log.warn(`No icon data found for tool: ${tool}`);
reject(createValidationError(`No icon data for tool: ${tool}`, { tool, availableTools: Object.keys(LAYERFORGE_TOOL_ICONS) }));
}
});
this._loadingPromises.set(tool, loadPromise);
return loadPromise;
}, 'IconLoader.loadIcon');
/**
* Create a fallback canvas icon with colored background and text
*/
private createFallbackIcon(tool: string): HTMLCanvasElement {
const { canvas, ctx } = createCanvas(24, 24);
if (!ctx) {
log.error('Failed to get canvas context for fallback icon');
return canvas;
}
// Fill background with tool color
const color = LAYERFORGE_TOOL_COLORS[tool as keyof typeof LAYERFORGE_TOOL_COLORS] || '#888888';
ctx.fillStyle = color;
ctx.fillRect(0, 0, 24, 24);
// Add border
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, 23, 23);
// Add text
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const firstChar = tool.charAt(0).toUpperCase();
ctx.fillText(firstChar, 12, 12);
return canvas;
}
/**
* Get cached icon (canvas or image)
*/
getIcon(tool: string): HTMLCanvasElement | HTMLImageElement | null {
return this._iconCache[tool] || null;
}
/**
* Check if icon is loaded (as image, not fallback canvas)
*/
isIconLoaded(tool: string): boolean {
return this._iconCache[tool] instanceof HTMLImageElement;
}
/**
* Clear all cached icons
*/
clearCache(): void {
this._iconCache = {};
this._loadingPromises.clear();
log.info('Icon cache cleared');
}
/**
* Get all available tool names
*/
getAvailableTools(): string[] {
return Object.values(LAYERFORGE_TOOLS);
}
/**
* Get tool color
*/
getToolColor(tool: string): string {
return LAYERFORGE_TOOL_COLORS[tool as keyof typeof LAYERFORGE_TOOL_COLORS] || '#888888';
}
}
// Export singleton instance
export const iconLoader = new IconLoader();
// Export for external use
export { LAYERFORGE_TOOL_ICONS, LAYERFORGE_TOOL_COLORS };

269
src/utils/ImageAnalysis.ts Normal file
View File

@@ -0,0 +1,269 @@
import { createModuleLogger } from "./LoggerUtils.js";
import { createCanvas } from "./CommonUtils.js";
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
const log = createModuleLogger('ImageAnalysis');
/**
* Creates a distance field mask based on the alpha channel of an image.
* The mask will have gradients from the edges of visible pixels inward.
* @param image - The source image to analyze
* @param blendArea - The percentage (0-100) of the area to apply blending
* @returns HTMLCanvasElement containing the distance field mask
*/
/**
* Synchronous version of createDistanceFieldMask for use in synchronous rendering
*/
export function createDistanceFieldMaskSync(image: HTMLImageElement, blendArea: number): HTMLCanvasElement {
if (!image) {
log.error("Image is required for distance field mask");
return createCanvas(1, 1).canvas;
}
if (typeof blendArea !== 'number' || blendArea < 0 || blendArea > 100) {
log.error("Blend area must be a number between 0 and 100");
return createCanvas(1, 1).canvas;
}
const { canvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
if (!ctx) {
log.error('Failed to create canvas context for distance field mask');
return canvas;
}
// Draw the image to extract pixel data
ctx.drawImage(image, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const width = canvas.width;
const height = canvas.height;
// Check if image has transparency (any alpha < 255)
let hasTransparency = false;
for (let i = 0; i < width * height; i++) {
if (data[i * 4 + 3] < 255) {
hasTransparency = true;
break;
}
}
let distanceField: Float32Array;
let maxDistance: number;
if (hasTransparency) {
// For images with transparency, use alpha-based distance transform
const binaryMask = new Uint8Array(width * height);
for (let i = 0; i < width * height; i++) {
binaryMask[i] = data[i * 4 + 3] > 0 ? 1 : 0;
}
distanceField = calculateDistanceTransform(binaryMask, width, height);
} else {
// For opaque images, calculate distance from edges of the rectangle
distanceField = calculateDistanceFromEdges(width, height);
}
// Find the maximum distance to normalize
maxDistance = 0;
for (let i = 0; i < distanceField.length; i++) {
if (distanceField[i] > maxDistance) {
maxDistance = distanceField[i];
}
}
// Create the gradient mask based on blendArea
const maskData = ctx.createImageData(width, height);
const threshold = maxDistance * (blendArea / 100);
for (let i = 0; i < width * height; i++) {
const distance = distanceField[i];
const alpha = data[i * 4 + 3];
if (alpha === 0) {
// Transparent pixels remain transparent
maskData.data[i * 4] = 255;
maskData.data[i * 4 + 1] = 255;
maskData.data[i * 4 + 2] = 255;
maskData.data[i * 4 + 3] = 0;
} else if (distance <= threshold) {
// Edge area - apply gradient alpha
const gradientValue = distance / threshold;
const alphaValue = Math.floor(gradientValue * 255);
maskData.data[i * 4] = 255;
maskData.data[i * 4 + 1] = 255;
maskData.data[i * 4 + 2] = 255;
maskData.data[i * 4 + 3] = alphaValue;
} else {
// Inner area - full alpha (no blending effect)
maskData.data[i * 4] = 255;
maskData.data[i * 4 + 1] = 255;
maskData.data[i * 4 + 2] = 255;
maskData.data[i * 4 + 3] = 255;
}
}
// Clear canvas and put the mask data
ctx.clearRect(0, 0, width, height);
ctx.putImageData(maskData, 0, 0);
return canvas;
}
/**
* Async version with error handling for use in async contexts
*/
export const createDistanceFieldMask = withErrorHandling(function(image: HTMLImageElement, blendArea: number): HTMLCanvasElement {
return createDistanceFieldMaskSync(image, blendArea);
}, 'createDistanceFieldMask');
/**
* Calculates the Euclidean distance transform of a binary mask.
* Uses a two-pass algorithm for efficiency.
* @param binaryMask - Binary mask where 1 = inside, 0 = outside
* @param width - Width of the mask
* @param height - Height of the mask
* @returns Float32Array containing distance values
*/
function calculateDistanceTransform(binaryMask: Uint8Array, width: number, height: number): Float32Array {
const distances = new Float32Array(width * height);
const infinity = width + height; // A value larger than any possible distance
// Initialize distances
for (let i = 0; i < width * height; i++) {
distances[i] = binaryMask[i] === 1 ? infinity : 0;
}
// Forward pass (top-left to bottom-right)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
if (distances[idx] > 0) {
let minDist = distances[idx];
// Check top neighbor
if (y > 0) {
minDist = Math.min(minDist, distances[(y - 1) * width + x] + 1);
}
// Check left neighbor
if (x > 0) {
minDist = Math.min(minDist, distances[y * width + (x - 1)] + 1);
}
// Check top-left diagonal
if (x > 0 && y > 0) {
minDist = Math.min(minDist, distances[(y - 1) * width + (x - 1)] + Math.sqrt(2));
}
// Check top-right diagonal
if (x < width - 1 && y > 0) {
minDist = Math.min(minDist, distances[(y - 1) * width + (x + 1)] + Math.sqrt(2));
}
distances[idx] = minDist;
}
}
}
// Backward pass (bottom-right to top-left)
for (let y = height - 1; y >= 0; y--) {
for (let x = width - 1; x >= 0; x--) {
const idx = y * width + x;
if (distances[idx] > 0) {
let minDist = distances[idx];
// Check bottom neighbor
if (y < height - 1) {
minDist = Math.min(minDist, distances[(y + 1) * width + x] + 1);
}
// Check right neighbor
if (x < width - 1) {
minDist = Math.min(minDist, distances[y * width + (x + 1)] + 1);
}
// Check bottom-right diagonal
if (x < width - 1 && y < height - 1) {
minDist = Math.min(minDist, distances[(y + 1) * width + (x + 1)] + Math.sqrt(2));
}
// Check bottom-left diagonal
if (x > 0 && y < height - 1) {
minDist = Math.min(minDist, distances[(y + 1) * width + (x - 1)] + Math.sqrt(2));
}
distances[idx] = minDist;
}
}
}
return distances;
}
/**
* Calculates distance from edges of a rectangle for opaque images.
* @param width - Width of the rectangle
* @param height - Height of the rectangle
* @returns Float32Array containing distance values from edges
*/
function calculateDistanceFromEdges(width: number, height: number): Float32Array {
const distances = new Float32Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = y * width + x;
// Calculate distance to nearest edge
const distToLeft = x;
const distToRight = width - 1 - x;
const distToTop = y;
const distToBottom = height - 1 - y;
// Minimum distance to any edge
const minDistToEdge = Math.min(distToLeft, distToRight, distToTop, distToBottom);
distances[idx] = minDistToEdge;
}
}
return distances;
}
/**
* Creates a simple radial gradient mask (fallback for rectangular areas).
* @param width - Width of the mask
* @param height - Height of the mask
* @param blendArea - The percentage (0-100) of the area to apply blending
* @returns HTMLCanvasElement containing the radial gradient mask
*/
export const createRadialGradientMask = withErrorHandling(function(width: number, height: number, blendArea: number): HTMLCanvasElement {
if (typeof width !== 'number' || width <= 0) {
throw createValidationError("Width must be a positive number", { width });
}
if (typeof height !== 'number' || height <= 0) {
throw createValidationError("Height must be a positive number", { height });
}
if (typeof blendArea !== 'number' || blendArea < 0 || blendArea > 100) {
throw createValidationError("Blend area must be a number between 0 and 100", { blendArea });
}
const { canvas, ctx } = createCanvas(width, height);
if (!ctx) {
log.error('Failed to create canvas context for radial gradient mask');
return canvas;
}
const centerX = width / 2;
const centerY = height / 2;
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
const innerRadius = maxRadius * (1 - blendArea / 100);
// Create radial gradient
const gradient = ctx.createRadialGradient(centerX, centerY, innerRadius, centerX, centerY, maxRadius);
gradient.addColorStop(0, 'white');
gradient.addColorStop(1, 'black');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
return canvas;
}, 'createRadialGradientMask');

View File

@@ -0,0 +1,180 @@
// @ts-ignore
import { api } from "../../../scripts/api.js";
import { createModuleLogger } from "./LoggerUtils.js";
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
const log = createModuleLogger('ImageUploadUtils');
/**
* Utility functions for uploading images to ComfyUI server
*/
export interface UploadImageOptions {
/** Custom filename prefix (default: 'layerforge') */
filenamePrefix?: string;
/** Whether to overwrite existing files (default: true) */
overwrite?: boolean;
/** Upload type (default: 'temp') */
type?: string;
/** Node ID for unique filename generation */
nodeId?: string | number;
}
export interface UploadImageResult {
/** Server response data */
data: any;
/** Generated filename */
filename: string;
/** Full image URL */
imageUrl: string;
/** Created Image element */
imageElement: HTMLImageElement;
}
/**
* Uploads an image blob to ComfyUI server and returns image element
* @param blob - Image blob to upload
* @param options - Upload options
* @returns Promise with upload result
*/
export const uploadImageBlob = withErrorHandling(async function(blob: Blob, options: UploadImageOptions = {}): Promise<UploadImageResult> {
if (!blob) {
throw createValidationError("Blob is required", { blob });
}
if (blob.size === 0) {
throw createValidationError("Blob cannot be empty", { blobSize: blob.size });
}
const {
filenamePrefix = 'layerforge',
overwrite = true,
type = 'temp',
nodeId
} = options;
// Generate unique filename
const timestamp = Date.now();
const nodeIdSuffix = nodeId ? `-${nodeId}` : '';
const filename = `${filenamePrefix}${nodeIdSuffix}-${timestamp}.png`;
log.debug('Uploading image blob:', {
filename,
blobSize: blob.size,
type,
overwrite
});
// Create FormData
const formData = new FormData();
formData.append("image", blob, filename);
formData.append("overwrite", overwrite.toString());
formData.append("type", type);
// Upload to server
const response = await api.fetchApi("/upload/image", {
method: "POST",
body: formData,
});
if (!response.ok) {
throw createNetworkError(`Failed to upload image: ${response.statusText}`, {
status: response.status,
statusText: response.statusText,
filename,
blobSize: blob.size
});
}
const data = await response.json();
log.debug('Image uploaded successfully:', data);
// Create image element with proper URL
const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`);
const imageElement = new Image();
imageElement.crossOrigin = "anonymous";
// Wait for image to load
await new Promise<void>((resolve, reject) => {
imageElement.onload = () => {
log.debug("Uploaded image loaded successfully", {
width: imageElement.width,
height: imageElement.height,
src: imageElement.src.substring(0, 100) + '...'
});
resolve();
};
imageElement.onerror = (error) => {
log.error("Failed to load uploaded image", error);
reject(createNetworkError("Failed to load uploaded image", { error, imageUrl, filename }));
};
imageElement.src = imageUrl;
});
return {
data,
filename,
imageUrl,
imageElement
};
}, 'uploadImageBlob');
/**
* Uploads canvas content as image blob
* @param canvas - Canvas element or Canvas object with canvasLayers
* @param options - Upload options
* @returns Promise with upload result
*/
export const uploadCanvasAsImage = withErrorHandling(async function(canvas: any, options: UploadImageOptions = {}): Promise<UploadImageResult> {
if (!canvas) {
throw createValidationError("Canvas is required", { canvas });
}
let blob: Blob | null = null;
// Handle different canvas types
if (canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') {
// LayerForge Canvas object
blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob();
} else if (canvas instanceof HTMLCanvasElement) {
// Standard HTML Canvas
blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve));
} else {
throw createValidationError("Unsupported canvas type", {
canvas,
hasCanvasLayers: !!canvas.canvasLayers,
isHTMLCanvas: canvas instanceof HTMLCanvasElement
});
}
if (!blob) {
throw createValidationError("Failed to generate canvas blob", { canvas, options });
}
return uploadImageBlob(blob, options);
}, 'uploadCanvasAsImage');
/**
* Uploads canvas with mask as image blob
* @param canvas - Canvas object with canvasLayers
* @param options - Upload options
* @returns Promise with upload result
*/
export const uploadCanvasWithMaskAsImage = withErrorHandling(async function(canvas: any, options: UploadImageOptions = {}): Promise<UploadImageResult> {
if (!canvas) {
throw createValidationError("Canvas is required", { canvas });
}
if (!canvas.canvasLayers || typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob !== 'function') {
throw createValidationError("Canvas does not support mask operations", {
canvas,
hasCanvasLayers: !!canvas.canvasLayers,
hasMaskMethod: !!(canvas.canvasLayers && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function')
});
}
const blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
if (!blob) {
throw createValidationError("Failed to generate canvas with mask blob", { canvas, options });
}
return uploadImageBlob(blob, options);
}, 'uploadCanvasWithMaskAsImage');

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";
import { createCanvas } from "./CommonUtils.js";
import type { Tensor, ImageDataPixel } from '../types'; import type { Tensor, ImageDataPixel } from '../types';
const log = createModuleLogger('ImageUtils'); const log = createModuleLogger('ImageUtils');
@@ -163,11 +164,7 @@ export const imageToTensor = withErrorHandling(async function (image: HTMLImageE
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const { canvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true });
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = image.width;
canvas.height = image.height;
if (ctx) { if (ctx) {
ctx.drawImage(image, 0, 0); ctx.drawImage(image, 0, 0);
@@ -197,11 +194,7 @@ export const tensorToImage = withErrorHandling(async function (tensor: Tensor):
} }
const [, height, width, channels] = tensor.shape; const [, height, width, channels] = tensor.shape;
const canvas = document.createElement('canvas'); const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width;
canvas.height = height;
if (ctx) { if (ctx) {
const imageData = ctx.createImageData(width, height); const imageData = ctx.createImageData(width, height);
@@ -234,17 +227,13 @@ export const resizeImage = withErrorHandling(async function (image: HTMLImageEle
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const originalWidth = image.width; const originalWidth = image.width;
const originalHeight = image.height; const originalHeight = image.height;
const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight); const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
const newWidth = Math.round(originalWidth * scale); const newWidth = Math.round(originalWidth * scale);
const newHeight = Math.round(originalHeight * scale); const newHeight = Math.round(originalHeight * scale);
canvas.width = newWidth; const { canvas, ctx } = createCanvas(newWidth, newHeight, '2d', { willReadFrequently: true });
canvas.height = newHeight;
if (ctx) { if (ctx) {
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
@@ -270,11 +259,9 @@ export const imageToBase64 = withErrorHandling(function (image: HTMLImageElement
throw createValidationError("Image is required"); throw createValidationError("Image is required");
} }
const canvas = document.createElement('canvas'); const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
const ctx = canvas.getContext('2d', { willReadFrequently: true }); const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
canvas.width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
canvas.height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
if (ctx) { if (ctx) {
ctx.drawImage(image, 0, 0); ctx.drawImage(image, 0, 0);
@@ -330,11 +317,7 @@ export function createImageFromSource(source: string): Promise<HTMLImageElement>
} }
export const createEmptyImage = withErrorHandling(function (width: number, height: number, color = 'transparent'): Promise<HTMLImageElement> { export const createEmptyImage = withErrorHandling(function (width: number, height: number, color = 'transparent'): Promise<HTMLImageElement> {
const canvas = document.createElement('canvas'); const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true });
const ctx = canvas.getContext('2d', { willReadFrequently: true });
canvas.width = width;
canvas.height = height;
if (ctx) { if (ctx) {
if (color !== 'transparent') { if (color !== 'transparent') {
@@ -351,3 +334,163 @@ export const createEmptyImage = withErrorHandling(function (width: number, heigh
} }
throw new Error("Canvas context not available"); throw new Error("Canvas context not available");
}, 'createEmptyImage'); }, 'createEmptyImage');
/**
* Converts a canvas or image to an Image element
* Consolidated from MaskProcessingUtils.convertToImage()
* @param source - Source canvas or image
* @returns Promise with Image element
*/
export async function convertToImage(source: HTMLCanvasElement | HTMLImageElement): Promise<HTMLImageElement> {
if (source instanceof HTMLImageElement) {
return source; // Already an image
}
const image = new Image();
image.src = source.toDataURL();
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = reject;
});
return image;
}
/**
* Creates a mask from image source for use in mask editor
* Consolidated from mask_utils.create_mask_from_image_src()
* @param imageSrc - Image source (URL or data URL)
* @returns Promise returning Image object
*/
export function createMaskFromImageSrc(imageSrc: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = imageSrc;
});
}
/**
* Converts canvas to Image for use as mask
* Consolidated from mask_utils.canvas_to_mask_image()
* @param canvas - Canvas to convert
* @returns Promise returning Image object
*/
export function canvasToMaskImage(canvas: HTMLCanvasElement): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = canvas.toDataURL();
});
}
/**
* Scales an image to fit within specified bounds while maintaining aspect ratio
* @param image - Image to scale
* @param targetWidth - Target width to fit within
* @param targetHeight - Target height to fit within
* @returns Promise with scaled Image element
*/
export async function scaleImageToFit(image: HTMLImageElement, targetWidth: number, targetHeight: number): Promise<HTMLImageElement> {
const scale = Math.min(targetWidth / image.width, targetHeight / image.height);
const scaledWidth = Math.max(1, Math.round(image.width * scale));
const scaledHeight = Math.max(1, Math.round(image.height * scale));
const { canvas, ctx } = createCanvas(scaledWidth, scaledHeight, '2d', { willReadFrequently: true });
if (!ctx) throw new Error("Could not create scaled image context");
ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight);
return new Promise((resolve, reject) => {
const scaledImg = new Image();
scaledImg.onload = () => resolve(scaledImg);
scaledImg.onerror = reject;
scaledImg.src = canvas.toDataURL();
});
}
/**
* Unified tensor to image data conversion
* Handles both RGB images and grayscale masks
* @param tensor - Input tensor data
* @param mode - 'rgb' for images or 'grayscale' for masks
* @returns ImageData object
*/
export function tensorToImageData(tensor: any, mode: 'rgb' | 'grayscale' = 'rgb'): ImageData | null {
try {
const shape = tensor.shape;
const height = shape[1];
const width = shape[2];
const channels = shape[3] || 1; // Default to 1 for masks
log.debug("Converting tensor:", { shape, channels, mode });
const imageData = new ImageData(width, height);
const data = new Uint8ClampedArray(width * height * 4);
const flatData = tensor.data;
const pixelCount = width * height;
const min = tensor.min_val ?? 0;
const max = tensor.max_val ?? 1;
const denom = (max - min) || 1;
for (let i = 0; i < pixelCount; i++) {
const pixelIndex = i * 4;
const tensorIndex = i * channels;
let lum: number;
if (mode === 'grayscale' || channels === 1) {
lum = flatData[tensorIndex];
} else {
// Compute luminance for RGB
const r = flatData[tensorIndex + 0] ?? 0;
const g = flatData[tensorIndex + 1] ?? 0;
const b = flatData[tensorIndex + 2] ?? 0;
lum = 0.299 * r + 0.587 * g + 0.114 * b;
}
let norm = (lum - min) / denom;
if (!isFinite(norm)) norm = 0;
norm = Math.max(0, Math.min(1, norm));
const value = Math.round(norm * 255);
if (mode === 'grayscale') {
// For masks: RGB = value, A = 255 (MaskTool reads luminance)
data[pixelIndex] = value;
data[pixelIndex + 1] = value;
data[pixelIndex + 2] = value;
data[pixelIndex + 3] = 255;
} else {
// For images: RGB from channels, A = 255
for (let c = 0; c < Math.min(3, channels); c++) {
const channelValue = flatData[tensorIndex + c];
const channelNorm = (channelValue - min) / denom;
data[pixelIndex + c] = Math.round(channelNorm * 255);
}
data[pixelIndex + 3] = 255;
}
}
imageData.data.set(data);
return imageData;
} catch (error) {
log.error("Error converting tensor:", error);
return null;
}
}
/**
* Creates an HTMLImageElement from ImageData
* @param imageData - Input ImageData
* @returns Promise with HTMLImageElement
*/
export async function createImageFromImageData(imageData: ImageData): Promise<HTMLImageElement> {
const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true });
if (!ctx) throw new Error("Could not create canvas context");
ctx.putImageData(imageData, 0, 0);
return await createImageFromSource(canvas.toDataURL());
}

View File

@@ -0,0 +1,250 @@
import { createModuleLogger } from "./LoggerUtils.js";
import { createCanvas } from "./CommonUtils.js";
import { convertToImage } from "./ImageUtils.js";
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
const log = createModuleLogger('MaskProcessingUtils');
/**
* Utility functions for processing masks and image data
*/
export interface MaskProcessingOptions {
/** Target width for the processed mask */
targetWidth?: number;
/** Target height for the processed mask */
targetHeight?: number;
/** Whether to invert the alpha channel (default: true) */
invertAlpha?: boolean;
/** Mask color RGB values (default: {r: 255, g: 255, b: 255}) */
maskColor?: { r: number; g: number; b: number };
}
/**
* Processes an image to create a mask with inverted alpha channel
* @param sourceImage - Source image or canvas element
* @param options - Processing options
* @returns Promise with processed mask as HTMLCanvasElement
*/
export const processImageToMask = withErrorHandling(async function(
sourceImage: HTMLImageElement | HTMLCanvasElement,
options: MaskProcessingOptions = {}
): Promise<HTMLCanvasElement> {
if (!sourceImage) {
throw createValidationError("Source image is required", { sourceImage });
}
const {
targetWidth = sourceImage.width,
targetHeight = sourceImage.height,
invertAlpha = true,
maskColor = { r: 255, g: 255, b: 255 }
} = options;
log.debug('Processing image to mask:', {
sourceSize: { width: sourceImage.width, height: sourceImage.height },
targetSize: { width: targetWidth, height: targetHeight },
invertAlpha,
maskColor
});
// Create temporary canvas for processing
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true });
if (!tempCtx) {
throw createValidationError("Failed to get 2D context for mask processing");
}
// Draw the source image
tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight);
// Get image data for processing
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
const data = imageData.data;
// Process pixels to create mask
for (let i = 0; i < data.length; i += 4) {
const originalAlpha = data[i + 3];
// Set RGB to mask color
data[i] = maskColor.r; // Red
data[i + 1] = maskColor.g; // Green
data[i + 2] = maskColor.b; // Blue
// Handle alpha channel
if (invertAlpha) {
data[i + 3] = 255 - originalAlpha; // Invert alpha
} else {
data[i + 3] = originalAlpha; // Keep original alpha
}
}
// Put processed data back to canvas
tempCtx.putImageData(imageData, 0, 0);
log.debug('Mask processing completed');
return tempCanvas;
}, 'processImageToMask');
/**
* Processes image data with custom pixel transformation
* @param sourceImage - Source image or canvas element
* @param pixelTransform - Custom pixel transformation function
* @param options - Processing options
* @returns Promise with processed image as HTMLCanvasElement
*/
export const processImageWithTransform = withErrorHandling(async function(
sourceImage: HTMLImageElement | HTMLCanvasElement,
pixelTransform: (r: number, g: number, b: number, a: number, index: number) => [number, number, number, number],
options: MaskProcessingOptions = {}
): Promise<HTMLCanvasElement> {
if (!sourceImage) {
throw createValidationError("Source image is required", { sourceImage });
}
if (!pixelTransform || typeof pixelTransform !== 'function') {
throw createValidationError("Pixel transform function is required", { pixelTransform });
}
const {
targetWidth = sourceImage.width,
targetHeight = sourceImage.height
} = options;
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true });
if (!tempCtx) {
throw createValidationError("Failed to get 2D context for image processing");
}
tempCtx.drawImage(sourceImage, 0, 0, targetWidth, targetHeight);
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const [r, g, b, a] = pixelTransform(data[i], data[i + 1], data[i + 2], data[i + 3], i / 4);
data[i] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = a;
}
tempCtx.putImageData(imageData, 0, 0);
return tempCanvas;
}, 'processImageWithTransform');
/**
* Crops an image to a specific region
* @param sourceImage - Source image or canvas
* @param cropArea - Crop area {x, y, width, height}
* @returns Promise with cropped image as HTMLCanvasElement
*/
export const cropImage = withErrorHandling(async function(
sourceImage: HTMLImageElement | HTMLCanvasElement,
cropArea: { x: number; y: number; width: number; height: number }
): Promise<HTMLCanvasElement> {
if (!sourceImage) {
throw createValidationError("Source image is required", { sourceImage });
}
if (!cropArea || typeof cropArea !== 'object') {
throw createValidationError("Crop area is required", { cropArea });
}
const { x, y, width, height } = cropArea;
if (width <= 0 || height <= 0) {
throw createValidationError("Crop area must have positive width and height", { cropArea });
}
log.debug('Cropping image:', {
sourceSize: { width: sourceImage.width, height: sourceImage.height },
cropArea
});
const { canvas, ctx } = createCanvas(width, height);
if (!ctx) {
throw createValidationError("Failed to get 2D context for image cropping");
}
ctx.drawImage(
sourceImage,
x, y, width, height, // Source rectangle
0, 0, width, height // Destination rectangle
);
return canvas;
}, 'cropImage');
/**
* Applies a mask to an image using viewport positioning
* @param maskImage - Mask image or canvas
* @param targetWidth - Target viewport width
* @param targetHeight - Target viewport height
* @param viewportOffset - Viewport offset {x, y}
* @param maskColor - Mask color (default: white)
* @returns Promise with processed mask for viewport
*/
export const processMaskForViewport = withErrorHandling(async function(
maskImage: HTMLImageElement | HTMLCanvasElement,
targetWidth: number,
targetHeight: number,
viewportOffset: { x: number; y: number },
maskColor: { r: number; g: number; b: number } = { r: 255, g: 255, b: 255 }
): Promise<HTMLCanvasElement> {
if (!maskImage) {
throw createValidationError("Mask image is required", { maskImage });
}
if (!viewportOffset || typeof viewportOffset !== 'object') {
throw createValidationError("Viewport offset is required", { viewportOffset });
}
if (targetWidth <= 0 || targetHeight <= 0) {
throw createValidationError("Target dimensions must be positive", { targetWidth, targetHeight });
}
log.debug("Processing mask for viewport:", {
sourceSize: { width: maskImage.width, height: maskImage.height },
targetSize: { width: targetWidth, height: targetHeight },
viewportOffset
});
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(targetWidth, targetHeight, '2d', { willReadFrequently: true });
if (!tempCtx) {
throw createValidationError("Failed to get 2D context for viewport mask processing");
}
// Calculate source coordinates based on viewport offset
const sourceX = -viewportOffset.x;
const sourceY = -viewportOffset.y;
// Draw the mask with viewport cropping
tempCtx.drawImage(
maskImage, // Source: full mask from "output area"
sourceX, // sx: Real X coordinate on large mask
sourceY, // sy: Real Y coordinate on large mask
targetWidth, // sWidth: Width of cropped fragment
targetHeight, // sHeight: Height of cropped fragment
0, // dx: Where to paste in target canvas (always 0)
0, // dy: Where to paste in target canvas (always 0)
targetWidth, // dWidth: Width of pasted image
targetHeight // dHeight: Height of pasted image
);
// Apply mask color
const imageData = tempCtx.getImageData(0, 0, targetWidth, targetHeight);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0) {
data[i] = maskColor.r;
data[i + 1] = maskColor.g;
data[i + 2] = maskColor.b;
}
}
tempCtx.putImageData(imageData, 0, 0);
log.debug("Viewport mask processing completed");
return tempCanvas;
}, 'processMaskForViewport');

View File

@@ -0,0 +1,348 @@
import { createModuleLogger } from "./LoggerUtils.js";
const log = createModuleLogger('NotificationUtils');
// Store active notifications for deduplication
const activeNotifications = new Map<string, { element: HTMLDivElement, timeout: number | null }>();
/**
* Utility functions for showing notifications to the user
*/
/**
* Shows a temporary notification to the user
* @param message - The message to show
* @param backgroundColor - Background color (default: #4a6cd4)
* @param duration - Duration in milliseconds (default: 3000)
* @param type - Type of notification
* @param deduplicate - If true, will not show duplicate messages and will refresh existing ones (default: false)
*/
export function showNotification(
message: string,
backgroundColor: string = "#4a6cd4",
duration: number = 3000,
type: "success" | "error" | "info" | "warning" | "alert" = "info",
deduplicate: boolean = false
): void {
// Remove any existing prefix to avoid double prefixing
message = message.replace(/^\[Layer Forge\]\s*/, "");
// If deduplication is enabled, check if this message already exists
if (deduplicate) {
const existingNotification = activeNotifications.get(message);
if (existingNotification) {
log.debug(`Notification already exists, refreshing timer: ${message}`);
// Clear existing timeout
if (existingNotification.timeout !== null) {
clearTimeout(existingNotification.timeout);
}
// Find the progress bar and restart its animation
const progressBar = existingNotification.element.querySelector('div[style*="animation"]') as HTMLDivElement;
if (progressBar) {
// Reset animation
progressBar.style.animation = 'none';
// Force reflow
void progressBar.offsetHeight;
// Restart animation
progressBar.style.animation = `lf-progress ${duration / 1000}s linear`;
}
// Set new timeout
const newTimeout = window.setTimeout(() => {
const notification = existingNotification.element;
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
notification.addEventListener('animationend', () => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
activeNotifications.delete(message);
const container = document.getElementById('lf-notification-container');
if (container && container.children.length === 0) {
container.remove();
}
}
});
}, duration);
existingNotification.timeout = newTimeout;
return; // Don't create a new notification
}
}
// Type-specific config
const config = {
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
error: { icon: "❌", title: "Error", bg: "#ff6f6f" },
info: { icon: "", title: "Info", bg: "#4a6cd4" },
warning: { icon: "⚠️", title: "Warning", bg: "#ffd43b" },
alert: { icon: "⚠️", title: "Alert", bg: "#fff7cc" }
}[type];
// --- Get or create the main notification container ---
let container = document.getElementById('lf-notification-container');
if (!container) {
container = document.createElement('div');
container.id = 'lf-notification-container';
container.style.cssText = `
position: fixed;
top: 24px;
right: 24px;
z-index: 10001;
display: flex;
flex-direction: row-reverse;
gap: 16px;
align-items: flex-start;
`;
document.body.appendChild(container);
}
// --- Dark, modern notification style ---
const notification = document.createElement('div');
notification.style.cssText = `
min-width: 380px;
max-width: 440px;
max-height: 80vh;
background: rgba(30, 32, 41, 0.9);
color: #fff;
border-radius: 12px;
box-shadow: 0 4px 32px rgba(0,0,0,0.25);
display: flex;
flex-direction: column;
padding: 0;
font-family: 'Segoe UI', 'Arial', sans-serif;
overflow: hidden;
border: 1px solid rgba(80, 80, 80, 0.5);
backdrop-filter: blur(8px);
animation: lf-fadein 0.3s ease-out;
`;
// --- Header (non-scrollable) ---
const header = document.createElement('div');
header.style.cssText = `display: flex; align-items: flex-start; padding: 16px 20px; position: relative; flex-shrink: 0;`;
const leftBar = document.createElement('div');
leftBar.style.cssText = `position: absolute; left: 0; top: 0; bottom: 0; width: 6px; background: ${config.bg}; box-shadow: 0 0 12px ${config.bg}; border-radius: 3px 0 0 3px;`;
const iconContainer = document.createElement('div');
iconContainer.style.cssText = `width: 48px; height: 48px; min-width: 48px; min-height: 48px; display: flex; align-items: center; justify-content: center; margin-left: 18px; margin-right: 18px;`;
iconContainer.innerHTML = {
success: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-succ"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 4 L44 14 L44 34 L24 44 L4 34 L4 14 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/><g filter="url(#f-succ)"><path d="M16 24 L22 30 L34 18" stroke="#fff" stroke-width="3" fill="none"/></g></svg>`,
error: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-err"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M14 14 L34 34 M34 14 L14 34" fill="none" stroke="#fff" stroke-width="3"/><g filter="url(#f-err)"><path d="M24,4 L42,12 L42,36 L24,44 L6,36 L6,12 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
info: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-info"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 16 M24 22 L24 34" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-info)"><path d="M12,4 L36,4 L44,12 L44,36 L36,44 L12,44 L4,36 L4,12 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
warning: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-warn"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 28 M24 34 L24 36" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-warn)"><path d="M24,4 L46,24 L24,44 L2,24 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
alert: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-alert"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 28 M24 34 L24 36" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-alert)"><path d="M24,4 L46,24 L24,44 L2,24 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`
}[type];
const headerTextContent = document.createElement('div');
headerTextContent.style.cssText = `display: flex; flex-direction: column; justify-content: center; flex: 1; min-width: 0;`;
const titleSpan = document.createElement('div');
titleSpan.style.cssText = `font-weight: 700; font-size: 16px; margin-bottom: 4px; color: #fff; text-transform: uppercase; letter-spacing: 0.5px;`;
titleSpan.textContent = config.title;
headerTextContent.appendChild(titleSpan);
const topRightContainer = document.createElement('div');
topRightContainer.style.cssText = `position: absolute; top: 14px; right: 18px; display: flex; align-items: center; gap: 12px;`;
const tag = document.createElement('span');
tag.style.cssText = `font-size: 11px; font-weight: 600; color: #fff; background: ${config.bg}; border-radius: 4px; padding: 2px 8px; box-shadow: 0 0 8px ${config.bg};`;
tag.innerHTML = '🎨 Layer Forge';
const getTextColorForBg = (hexColor: string): string => {
const r = parseInt(hexColor.slice(1, 3), 16), g = parseInt(hexColor.slice(3, 5), 16), b = parseInt(hexColor.slice(5, 7), 16);
return ((0.299 * r + 0.587 * g + 0.114 * b) / 255) > 0.5 ? '#000' : '#fff';
};
tag.style.color = getTextColorForBg(config.bg);
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '&times;';
closeBtn.setAttribute("aria-label", "Close notification");
closeBtn.style.cssText = `background: none; border: none; color: #ccc; font-size: 22px; font-weight: bold; cursor: pointer; padding: 0; opacity: 0.7; transition: opacity 0.15s; line-height: 1;`;
topRightContainer.appendChild(tag);
topRightContainer.appendChild(closeBtn);
header.appendChild(iconContainer);
header.appendChild(headerTextContent);
header.appendChild(topRightContainer);
// --- Scrollable Body ---
const body = document.createElement('div');
body.style.cssText = `padding: 0px 20px 16px 20px; overflow-y: auto; flex: 1;`;
const msgSpan = document.createElement('div');
msgSpan.style.cssText = `font-size: 14px; color: #ccc; line-height: 1.5; white-space: pre-wrap; word-break: break-word;`;
msgSpan.textContent = message;
body.appendChild(msgSpan);
// --- Progress Bar ---
const progressBar = document.createElement('div');
progressBar.style.cssText = `height: 4px; width: 100%; background: ${config.bg}; box-shadow: 0 0 12px ${config.bg}; transform-origin: left; animation: lf-progress ${duration / 1000}s linear; flex-shrink: 0;`;
// --- Assemble Notification ---
notification.appendChild(leftBar);
notification.appendChild(header);
notification.appendChild(body);
if (type === 'error') {
const footer = document.createElement('div');
footer.style.cssText = `padding: 0 20px 12px 86px; flex-shrink: 0;`;
const copyButton = document.createElement('button');
copyButton.textContent = 'Copy Error';
copyButton.style.cssText = `background: rgba(255, 111, 111, 0.2); border: 1px solid #ff6f6f; color: #ffafaf; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; transition: background 0.2s;`;
copyButton.onmouseenter = () => copyButton.style.background = 'rgba(255, 111, 111, 0.3)';
copyButton.onmouseleave = () => copyButton.style.background = 'rgba(255, 111, 111, 0.2)';
copyButton.onclick = () => {
navigator.clipboard.writeText(message)
.then(() => showSuccessNotification("Error message copied!", 2000))
.catch(err => console.error('Failed to copy error message: ', err));
};
footer.appendChild(copyButton);
notification.appendChild(footer);
}
notification.appendChild(progressBar);
// Add to DOM
container.appendChild(notification);
// --- Keyframes and Timer Logic ---
const styleSheet = document.getElementById('lf-notification-styles') as HTMLStyleElement;
if (!styleSheet) {
const newStyleSheet = document.createElement("style");
newStyleSheet.id = 'lf-notification-styles';
newStyleSheet.innerText = `
@keyframes lf-progress { from { transform: scaleX(1); } to { transform: scaleX(0); } }
@keyframes lf-progress-rewind { to { transform: scaleX(1); } }
@keyframes lf-fadein { from { opacity: 0; transform: scale(0.95) translateX(20px); } to { opacity: 1; transform: scale(1) translateX(0); } }
@keyframes lf-fadeout { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } }
.notification-scrollbar::-webkit-scrollbar { width: 8px; }
.notification-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); border-radius: 4px; }
.notification-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.25); border-radius: 4px; }
.notification-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.4); }
`;
document.head.appendChild(newStyleSheet);
}
body.classList.add('notification-scrollbar');
let dismissTimeout: number | null = null;
const closeNotification = () => {
// Remove from active notifications map if deduplicate is enabled
if (deduplicate) {
activeNotifications.delete(message);
}
notification.style.animation = 'lf-fadeout 0.3s ease-out forwards';
notification.addEventListener('animationend', () => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
if (container && container.children.length === 0) {
container.remove();
}
}
});
};
closeBtn.onclick = closeNotification;
const startDismissTimer = () => {
dismissTimeout = window.setTimeout(closeNotification, duration);
progressBar.style.animation = `lf-progress ${duration / 1000}s linear`;
};
const pauseAndRewindTimer = () => {
if (dismissTimeout !== null) clearTimeout(dismissTimeout);
dismissTimeout = null;
const computedStyle = window.getComputedStyle(progressBar);
progressBar.style.transform = computedStyle.transform;
progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards';
};
notification.addEventListener('mouseenter', () => {
pauseAndRewindTimer();
// Update stored timeout if deduplicate is enabled
if (deduplicate) {
const stored = activeNotifications.get(message);
if (stored) {
stored.timeout = null;
}
}
});
notification.addEventListener('mouseleave', () => {
startDismissTimer();
// Update stored timeout if deduplicate is enabled
if (deduplicate) {
const stored = activeNotifications.get(message);
if (stored) {
stored.timeout = dismissTimeout;
}
}
});
startDismissTimer();
// Store notification if deduplicate is enabled
if (deduplicate) {
activeNotifications.set(message, { element: notification, timeout: dismissTimeout });
}
log.debug(`Notification shown: [Layer Forge] ${message}`);
}
/**
* Shows a success notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 3000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showSuccessNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
showNotification(message, undefined, duration, "success", deduplicate);
}
/**
* Shows an error notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 5000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showErrorNotification(message: string, duration: number = 5000, deduplicate: boolean = false): void {
showNotification(message, undefined, duration, "error", deduplicate);
}
/**
* Shows an info notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 3000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showInfoNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
showNotification(message, undefined, duration, "info", deduplicate);
}
/**
* Shows a warning notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 3000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showWarningNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
showNotification(message, undefined, duration, "warning", deduplicate);
}
/**
* Shows an alert notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 3000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showAlertNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void {
showNotification(message, undefined, duration, "alert", deduplicate);
}
/**
* Shows a sequence of all notification types for debugging purposes.
*/
export function showAllNotificationTypes(message?: string): void {
const types: ("success" | "error" | "info" | "warning" | "alert")[] = ["success", "error", "info", "warning", "alert"];
types.forEach((type, index) => {
const notificationMessage = message || `This is a '${type}' notification.`;
setTimeout(() => {
showNotification(notificationMessage, undefined, 3000, type, false);
}, index * 400); // Stagger the notifications
});
}

254
src/utils/PreviewUtils.ts Normal file
View File

@@ -0,0 +1,254 @@
import { createModuleLogger } from "./LoggerUtils.js";
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
import type { ComfyNode } from '../types';
const log = createModuleLogger('PreviewUtils');
/**
* Utility functions for creating and managing preview images
*/
export interface PreviewOptions {
/** Whether to include mask in the preview (default: true) */
includeMask?: boolean;
/** Whether to update node.imgs array (default: true) */
updateNodeImages?: boolean;
/** Custom blob source instead of canvas */
customBlob?: Blob;
}
/**
* Creates a preview image from canvas and updates node
* @param canvas - Canvas object with canvasLayers
* @param node - ComfyUI node to update
* @param options - Preview options
* @returns Promise with created Image element
*/
export const createPreviewFromCanvas = withErrorHandling(async function(
canvas: any,
node: ComfyNode,
options: PreviewOptions = {}
): Promise<HTMLImageElement> {
if (!canvas) {
throw createValidationError("Canvas is required", { canvas });
}
if (!node) {
throw createValidationError("Node is required", { node });
}
const {
includeMask = true,
updateNodeImages = true,
customBlob
} = options;
log.debug('Creating preview from canvas:', {
includeMask,
updateNodeImages,
hasCustomBlob: !!customBlob,
nodeId: node.id
});
let blob: Blob | null = customBlob || null;
// Get blob from canvas if not provided
if (!blob) {
if (!canvas.canvasLayers) {
throw createValidationError("Canvas does not have canvasLayers", { canvas });
}
if (includeMask && typeof canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob === 'function') {
blob = await canvas.canvasLayers.getFlattenedCanvasWithMaskAsBlob();
} else if (typeof canvas.canvasLayers.getFlattenedCanvasAsBlob === 'function') {
blob = await canvas.canvasLayers.getFlattenedCanvasAsBlob();
} else {
throw createValidationError("Canvas does not support required blob generation methods", {
canvas,
availableMethods: Object.getOwnPropertyNames(canvas.canvasLayers)
});
}
}
if (!blob) {
throw createValidationError("Failed to generate canvas blob for preview", { canvas, options });
}
// Create preview image
const previewImage = new Image();
previewImage.src = URL.createObjectURL(blob);
// Wait for image to load
await new Promise<void>((resolve, reject) => {
previewImage.onload = () => {
log.debug("Preview image loaded successfully", {
width: previewImage.width,
height: previewImage.height,
nodeId: node.id
});
resolve();
};
previewImage.onerror = (error) => {
log.error("Failed to load preview image", error);
reject(createValidationError("Failed to load preview image", { error, blob: blob?.size }));
};
});
// Update node images if requested
if (updateNodeImages) {
node.imgs = [previewImage];
log.debug("Node images updated with new preview");
}
return previewImage;
}, 'createPreviewFromCanvas');
/**
* Creates a preview image from a blob
* @param blob - Image blob
* @param node - ComfyUI node to update (optional)
* @param updateNodeImages - Whether to update node.imgs (default: false)
* @returns Promise with created Image element
*/
export const createPreviewFromBlob = withErrorHandling(async function(
blob: Blob,
node?: ComfyNode,
updateNodeImages: boolean = false
): Promise<HTMLImageElement> {
if (!blob) {
throw createValidationError("Blob is required", { blob });
}
if (blob.size === 0) {
throw createValidationError("Blob cannot be empty", { blobSize: blob.size });
}
log.debug('Creating preview from blob:', {
blobSize: blob.size,
updateNodeImages,
hasNode: !!node
});
const previewImage = new Image();
previewImage.src = URL.createObjectURL(blob);
await new Promise<void>((resolve, reject) => {
previewImage.onload = () => {
log.debug("Preview image from blob loaded successfully", {
width: previewImage.width,
height: previewImage.height
});
resolve();
};
previewImage.onerror = (error) => {
log.error("Failed to load preview image from blob", error);
reject(createValidationError("Failed to load preview image from blob", { error, blobSize: blob.size }));
};
});
if (updateNodeImages && node) {
node.imgs = [previewImage];
log.debug("Node images updated with blob preview");
}
return previewImage;
}, 'createPreviewFromBlob');
/**
* Updates node preview after canvas changes
* @param canvas - Canvas object
* @param node - ComfyUI node
* @param includeMask - Whether to include mask in preview
* @returns Promise with updated preview image
*/
export const updateNodePreview = withErrorHandling(async function(
canvas: any,
node: ComfyNode,
includeMask: boolean = true
): Promise<HTMLImageElement> {
if (!canvas) {
throw createValidationError("Canvas is required", { canvas });
}
if (!node) {
throw createValidationError("Node is required", { node });
}
log.info('Updating node preview:', {
nodeId: node.id,
includeMask
});
// Trigger canvas render and save state
if (typeof canvas.render === 'function') {
canvas.render();
}
if (typeof canvas.saveState === 'function') {
canvas.saveState();
}
// Create new preview
const previewImage = await createPreviewFromCanvas(canvas, node, {
includeMask,
updateNodeImages: true
});
log.info('Node preview updated successfully');
return previewImage;
}, 'updateNodePreview');
/**
* Clears node preview images
* @param node - ComfyUI node
*/
export function clearNodePreview(node: ComfyNode): void {
log.debug('Clearing node preview:', { nodeId: node.id });
node.imgs = [];
}
/**
* Checks if node has preview images
* @param node - ComfyUI node
* @returns True if node has preview images
*/
export function hasNodePreview(node: ComfyNode): boolean {
return !!(node.imgs && node.imgs.length > 0 && node.imgs[0].src);
}
/**
* Gets the current preview image from node
* @param node - ComfyUI node
* @returns Current preview image or null
*/
export function getCurrentPreview(node: ComfyNode): HTMLImageElement | null {
if (hasNodePreview(node) && node.imgs) {
return node.imgs[0];
}
return null;
}
/**
* Creates a preview with custom processing
* @param canvas - Canvas object
* @param node - ComfyUI node
* @param processor - Custom processing function that takes canvas and returns blob
* @returns Promise with processed preview image
*/
export const createCustomPreview = withErrorHandling(async function(
canvas: any,
node: ComfyNode,
processor: (canvas: any) => Promise<Blob>
): Promise<HTMLImageElement> {
if (!canvas) {
throw createValidationError("Canvas is required", { canvas });
}
if (!node) {
throw createValidationError("Node is required", { node });
}
if (!processor || typeof processor !== 'function') {
throw createValidationError("Processor function is required", { processor });
}
log.debug('Creating custom preview:', { nodeId: node.id });
const blob = await processor(canvas);
return createPreviewFromBlob(blob, node, true);
}, 'createCustomPreview');

View File

@@ -1,7 +1,17 @@
// @ts-ignore // @ts-ignore
import { $el } from "../../../scripts/ui.js"; import { $el } from "../../../scripts/ui.js";
import { createModuleLogger } from "./LoggerUtils.js";
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
const log = createModuleLogger('ResourceManager');
export const addStylesheet = withErrorHandling(function(url: string): void {
if (!url) {
throw createValidationError("URL is required", { url });
}
log.debug('Adding stylesheet:', { url });
export function addStylesheet(url: string): void {
if (url.endsWith(".js")) { if (url.endsWith(".js")) {
url = url.substr(0, url.length - 2) + "css"; url = url.substr(0, url.length - 2) + "css";
} }
@@ -11,9 +21,15 @@ export function addStylesheet(url: string): void {
type: "text/css", type: "text/css",
href: url.startsWith("http") ? url : getUrl(url), href: url.startsWith("http") ? url : getUrl(url),
}); });
}
log.debug('Stylesheet added successfully:', { finalUrl: url });
}, 'addStylesheet');
export function getUrl(path: string, baseUrl?: string | URL): string { export function getUrl(path: string, baseUrl?: string | URL): string {
if (!path) {
throw createValidationError("Path is required", { path });
}
if (baseUrl) { if (baseUrl) {
return new URL(path, baseUrl).toString(); return new URL(path, baseUrl).toString();
} else { } else {
@@ -22,11 +38,24 @@ export function getUrl(path: string, baseUrl?: string | URL): string {
} }
} }
export async function loadTemplate(path: string, baseUrl?: string | URL): Promise<string> { export const loadTemplate = withErrorHandling(async function(path: string, baseUrl?: string | URL): Promise<string> {
if (!path) {
throw createValidationError("Path is required", { path });
}
const url = getUrl(path, baseUrl); const url = getUrl(path, baseUrl);
log.debug('Loading template:', { path, url });
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load template: ${url}`); throw createNetworkError(`Failed to load template: ${url}`, {
url,
status: response.status,
statusText: response.statusText
});
} }
return await response.text();
} const content = await response.text();
log.debug('Template loaded successfully:', { path, contentLength: content.length });
return content;
}, 'loadTemplate');

View File

@@ -1,4 +1,5 @@
import {createModuleLogger} from "./LoggerUtils.js"; import {createModuleLogger} from "./LoggerUtils.js";
import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js";
import type { WebSocketMessage, AckCallbacks } from "../types.js"; import type { WebSocketMessage, AckCallbacks } from "../types.js";
const log = createModuleLogger('WebSocketManager'); const log = createModuleLogger('WebSocketManager');
@@ -26,7 +27,7 @@ class WebSocketManager {
this.connect(); this.connect();
} }
connect() { connect = withErrorHandling(() => {
if (this.socket && this.socket.readyState === WebSocket.OPEN) { if (this.socket && this.socket.readyState === WebSocket.OPEN) {
log.debug("WebSocket is already open."); log.debug("WebSocket is already open.");
return; return;
@@ -37,58 +38,56 @@ class WebSocketManager {
return; return;
} }
if (!this.url) {
throw createValidationError("WebSocket URL is required", { url: this.url });
}
this.isConnecting = true; this.isConnecting = true;
log.info(`Connecting to WebSocket at ${this.url}...`); log.info(`Connecting to WebSocket at ${this.url}...`);
try { this.socket = new WebSocket(this.url);
this.socket = new WebSocket(this.url);
this.socket.onopen = () => { this.socket.onopen = () => {
this.isConnecting = false;
this.reconnectAttempts = 0;
log.info("WebSocket connection established.");
this.flushMessageQueue();
};
this.socket.onmessage = (event: MessageEvent) => {
try {
const data: WebSocketMessage = JSON.parse(event.data);
log.debug("Received message:", data);
if (data.type === 'ack' && data.nodeId) {
const callback = this.ackCallbacks.get(data.nodeId);
if (callback) {
log.debug(`ACK received for nodeId: ${data.nodeId}, resolving promise.`);
callback.resolve(data);
this.ackCallbacks.delete(data.nodeId);
}
}
} catch (error) {
log.error("Error parsing incoming WebSocket message:", error);
}
};
this.socket.onclose = (event: CloseEvent) => {
this.isConnecting = false;
if (event.wasClean) {
log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`);
} else {
log.warn("WebSocket connection died. Attempting to reconnect...");
this.handleReconnect();
}
};
this.socket.onerror = (error: Event) => {
this.isConnecting = false;
log.error("WebSocket error:", error);
};
} catch (error) {
this.isConnecting = false; this.isConnecting = false;
log.error("Failed to create WebSocket connection:", error); this.reconnectAttempts = 0;
this.handleReconnect(); log.info("WebSocket connection established.");
} this.flushMessageQueue();
} };
this.socket.onmessage = (event: MessageEvent) => {
try {
const data: WebSocketMessage = JSON.parse(event.data);
log.debug("Received message:", data);
if (data.type === 'ack' && data.nodeId) {
const callback = this.ackCallbacks.get(data.nodeId);
if (callback) {
log.debug(`ACK received for nodeId: ${data.nodeId}, resolving promise.`);
callback.resolve(data);
this.ackCallbacks.delete(data.nodeId);
}
}
} catch (error) {
log.error("Error parsing incoming WebSocket message:", error);
}
};
this.socket.onclose = (event: CloseEvent) => {
this.isConnecting = false;
if (event.wasClean) {
log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`);
} else {
log.warn("WebSocket connection died. Attempting to reconnect...");
this.handleReconnect();
}
};
this.socket.onerror = (error: Event) => {
this.isConnecting = false;
throw createNetworkError("WebSocket connection error", { error, url: this.url });
};
}, 'WebSocketManager.connect');
handleReconnect() { handleReconnect() {
if (this.reconnectAttempts < this.maxReconnectAttempts) { if (this.reconnectAttempts < this.maxReconnectAttempts) {
@@ -100,13 +99,17 @@ class WebSocketManager {
} }
} }
sendMessage(data: WebSocketMessage, requiresAck = false): Promise<WebSocketMessage | void> { sendMessage = withErrorHandling(async (data: WebSocketMessage, requiresAck = false): Promise<WebSocketMessage | void> => {
return new Promise((resolve, reject) => { if (!data || typeof data !== 'object') {
const nodeId = data.nodeId; throw createValidationError("Message data is required", { data });
if (requiresAck && !nodeId) { }
return reject(new Error("A nodeId is required for messages that need acknowledgment."));
}
const nodeId = data.nodeId;
if (requiresAck && !nodeId) {
throw createValidationError("A nodeId is required for messages that need acknowledgment", { data, requiresAck });
}
return new Promise((resolve, reject) => {
const message = JSON.stringify(data); const message = JSON.stringify(data);
if (this.socket && this.socket.readyState === WebSocket.OPEN) { if (this.socket && this.socket.readyState === WebSocket.OPEN) {
@@ -117,7 +120,7 @@ class WebSocketManager {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
this.ackCallbacks.delete(nodeId); this.ackCallbacks.delete(nodeId);
reject(new Error(`ACK timeout for nodeId ${nodeId}`)); reject(createNetworkError(`ACK timeout for nodeId ${nodeId}`, { nodeId, timeout: 10000 }));
log.warn(`ACK timeout for nodeId ${nodeId}.`); log.warn(`ACK timeout for nodeId ${nodeId}.`);
}, 10000); // 10-second timeout }, 10000); // 10-second timeout
@@ -142,13 +145,16 @@ class WebSocketManager {
} }
if (requiresAck) { if (requiresAck) {
reject(new Error("Cannot send message with ACK required while disconnected.")); reject(createNetworkError("Cannot send message with ACK required while disconnected", {
socketState: this.socket?.readyState,
isConnecting: this.isConnecting
}));
} else { } else {
resolve(); resolve();
} }
} }
}); });
} }, 'WebSocketManager.sendMessage');
flushMessageQueue() { flushMessageQueue() {
log.debug(`Flushing ${this.messageQueue.length} queued messages.`); log.debug(`Flushing ${this.messageQueue.length} queued messages.`);

View File

@@ -1,4 +1,5 @@
import {createModuleLogger} from "./LoggerUtils.js"; import {createModuleLogger} from "./LoggerUtils.js";
import { withErrorHandling, createValidationError } from "../ErrorHandler.js";
import type { Canvas } from '../Canvas.js'; import type { Canvas } from '../Canvas.js';
// @ts-ignore // @ts-ignore
import {ComfyApp} from "../../../scripts/app.js"; import {ComfyApp} from "../../../scripts/app.js";
@@ -146,51 +147,28 @@ export function press_maskeditor_cancel(app: ComfyApp): void {
* @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia * @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski) * @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
*/ */
export function start_mask_editor_with_predefined_mask(canvasInstance: Canvas, maskImage: HTMLImageElement | HTMLCanvasElement, sendCleanImage = true): void { export const start_mask_editor_with_predefined_mask = withErrorHandling(function(canvasInstance: Canvas, maskImage: HTMLImageElement | HTMLCanvasElement, sendCleanImage = true): void {
if (!canvasInstance || !maskImage) { if (!canvasInstance) {
log.error('Canvas instance and mask image are required'); throw createValidationError('Canvas instance is required', { canvasInstance });
return; }
if (!maskImage) {
throw createValidationError('Mask image is required', { maskImage });
} }
canvasInstance.startMaskEditor(maskImage, sendCleanImage); canvasInstance.startMaskEditor(maskImage, sendCleanImage);
} }, 'start_mask_editor_with_predefined_mask');
/** /**
* Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska) * Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska)
* @param {Canvas} canvasInstance - Instancja Canvas * @param {Canvas} canvasInstance - Instancja Canvas
*/ */
export function start_mask_editor_auto(canvasInstance: Canvas): void { export const start_mask_editor_auto = withErrorHandling(function(canvasInstance: Canvas): void {
if (!canvasInstance) { if (!canvasInstance) {
log.error('Canvas instance is required'); throw createValidationError('Canvas instance is required', { canvasInstance });
return;
} }
canvasInstance.startMaskEditor(null, true); canvasInstance.startMaskEditor(null, true);
} }, 'start_mask_editor_auto');
/** // Duplikowane funkcje zostały przeniesione do ImageUtils.ts:
* Tworzy maskę z obrazu dla użycia w mask editorze // - create_mask_from_image_src -> createMaskFromImageSrc
* @param {string} imageSrc - Źródło obrazu (URL lub data URL) // - canvas_to_mask_image -> canvasToMaskImage
* @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
*/
export function create_mask_from_image_src(imageSrc: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = imageSrc;
});
}
/**
* Konwertuje canvas do Image dla użycia jako maska
* @param {HTMLCanvasElement} canvas - Canvas do konwersji
* @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
*/
export function canvas_to_mask_image(canvas: HTMLCanvasElement): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = canvas.toDataURL();
});
}