mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 21:12:12 -03:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b54ab28cb | ||
|
|
503ec126a5 | ||
|
|
3d6e3901d0 | ||
|
|
4df89a793e | ||
|
|
e42e08e35d | ||
|
|
7ed6f7ee93 | ||
|
|
9b0d4b3149 | ||
|
|
f0f3d419f8 | ||
|
|
26e2036388 | ||
|
|
22f5d028a2 |
79
README.md
79
README.md
@@ -28,13 +28,13 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/0f557d87-fd5e-422b-ab7e-dbdd4cab156c
|
https://github.com/user-attachments/assets/90fffb9a-dae2-4d19-aca2-5d47600f0a01
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
|
https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
|
||||||
|
|
||||||
## ✨ Key Features
|
## ✨ Key Features
|
||||||
|
|
||||||
- **Freeform Inpainting Area:** Draw any custom (non-rectangular) area directly inside the image for inpainting. The tool generates content that is coherent with the rest of the image, without requiring a brush.
|
- **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.
|
||||||
@@ -71,16 +71,67 @@ https://github.com/user-attachments/assets/9c7ce1de-873b-4a3b-8579-0fc67642af3a
|
|||||||
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 (10–50px)** depending on the **size of the image** to create smooth transitions between the inpainted area and existing content. Larger images generally benefit from more feathering.
|
||||||
|
* Experiment with **mask expansion** (e.g., 10–20px) if you notice hard edges or visible seams.
|
||||||
|
* Use **Output Area Extension** based on image size:
|
||||||
|
|
||||||
|
* **Extend the output area in all directions** to give the model more **context during generation**, especially for larger or more complex images.
|
||||||
|
* If **visible seams** still appear in the inpainting results:
|
||||||
|
|
||||||
|
* Use the **Manual Blend Menu** (right-click on the mask area) to access **color and edge adjustment tools** for precise fine-tuning and seamless integration.
|
||||||
|
* **Image placement behavior:**
|
||||||
|
|
||||||
|
* The generated or pasted image is automatically inserted into the area defined by the **blue shape** you draw.
|
||||||
|
* The model uses the area within the **dashed white preview outline** as the **full context** during generation.
|
||||||
|
* Make sure the dashed region covers enough surrounding content to preserve lighting, texture, and scene coherence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🧪 Workflow Example
|
## 🧪 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**
|
||||||
|
|
||||||

|
### 🔹 Simple Test Workflow
|
||||||
|
This workflow allows **quick testing** of node behavior and output structures **without requiring additional models or complex dependencies**. Useful for inspecting how basic outputs are generated and connected.
|
||||||
|

|
||||||
|
|
||||||
|
### 🔹 Flux Inpainting Workflow
|
||||||

|
This example shows a typical **inpainting setup using the Flux model**. It demonstrates how to integrate model-based fill with contextual generation for seamless content restoration.
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -89,7 +140,6 @@ Click on the image above, then drag and drop it into your ComfyUI workflow windo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## 🎮 Controls & Shortcuts
|
## 🎮 Controls & Shortcuts
|
||||||
|
|
||||||
### Canvas Control
|
### Canvas Control
|
||||||
@@ -100,7 +150,7 @@ 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 shape for output area |
|
| `Shift + S + Left Click` | Draw custom polygonal shape for output area |
|
||||||
| `Single Click (background)` | Deselect all layers |
|
| `Single Click (background)` | Deselect all layers |
|
||||||
| `Esc` | Close fullscreen editor mode |
|
| `Esc` | Close fullscreen editor mode |
|
||||||
| `Double Click (background)` | Deselect all layers |
|
| `Double Click (background)` | Deselect all layers |
|
||||||
@@ -151,6 +201,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
|
||||||
@@ -165,7 +223,8 @@ optional feature and requires a model.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🐞 Known Issue:
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
### `node_id` not auto-filled → black output
|
### `node_id` not auto-filled → black output
|
||||||
|
|
||||||
In some cases, **ComfyUI doesn't auto-fill the `node_id`** when adding a node.
|
In some cases, **ComfyUI doesn't auto-fill the `node_id`** when adding a node.
|
||||||
@@ -189,5 +248,9 @@ This project is licensed under the MIT License. Feel free to use, modify, and di
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🙏 Acknowledgments
|
||||||
|
|
||||||
Based on the original [**Comfyui-Ycanvas**](https://github.com/yichengup/Comfyui-Ycanvas) by yichengup. This fork
|
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.
|
||||||
|
|||||||
BIN
example_workflows/LayerForge_flux_fill_inpaint_example.jpg
Normal file
BIN
example_workflows/LayerForge_flux_fill_inpaint_example.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@@ -622,8 +622,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"cnr_id": "Comfyui-Ycanvas",
|
"cnr_id": "layerforge",
|
||||||
"ver": "3941104bd59dd79c19d612da1b11c05d87c2ed1c",
|
"ver": "22f5d028a2d4c3163014eba4896ef86810d81616",
|
||||||
"Node name for S&R": "CanvasNode",
|
"Node name for S&R": "CanvasNode",
|
||||||
"widget_ue_connectable": {}
|
"widget_ue_connectable": {}
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.0 MiB |
BIN
example_workflows/LayerForge_test_simple_workflow.jpg
Normal file
BIN
example_workflows/LayerForge_test_simple_workflow.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -241,8 +241,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"cnr_id": "Comfyui-Ycanvas",
|
"cnr_id": "layerforge",
|
||||||
"ver": "f6a491e83bab9481a2cac3367541a3b7803df9ab",
|
"ver": "22f5d028a2d4c3163014eba4896ef86810d81616",
|
||||||
"Node name for S&R": "CanvasNode",
|
"Node name for S&R": "CanvasNode",
|
||||||
"widget_ue_connectable": {}
|
"widget_ue_connectable": {}
|
||||||
},
|
},
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 939 KiB |
@@ -539,7 +539,10 @@ export class CanvasInteractions {
|
|||||||
width: layer.width, height: layer.height,
|
width: layer.width, height: layer.height,
|
||||||
rotation: layer.rotation,
|
rotation: layer.rotation,
|
||||||
centerX: layer.x + layer.width / 2,
|
centerX: layer.x + layer.width / 2,
|
||||||
centerY: layer.y + layer.height / 2
|
centerY: layer.y + layer.height / 2,
|
||||||
|
originalWidth: layer.originalWidth,
|
||||||
|
originalHeight: layer.originalHeight,
|
||||||
|
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
|
||||||
};
|
};
|
||||||
this.interaction.dragStart = { ...worldCoords };
|
this.interaction.dragStart = { ...worldCoords };
|
||||||
if (handle === 'rot') {
|
if (handle === 'rot') {
|
||||||
@@ -692,12 +695,8 @@ export class CanvasInteractions {
|
|||||||
let mouseY = worldCoords.y;
|
let mouseY = worldCoords.y;
|
||||||
if (this.interaction.isCtrlPressed) {
|
if (this.interaction.isCtrlPressed) {
|
||||||
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
||||||
const snappedMouseX = snapToGrid(mouseX);
|
mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX;
|
||||||
if (Math.abs(mouseX - snappedMouseX) < snapThreshold)
|
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
|
||||||
mouseX = snappedMouseX;
|
|
||||||
const snappedMouseY = snapToGrid(mouseY);
|
|
||||||
if (Math.abs(mouseY - snappedMouseY) < snapThreshold)
|
|
||||||
mouseY = snappedMouseY;
|
|
||||||
}
|
}
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined)
|
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined)
|
||||||
@@ -707,43 +706,113 @@ export class CanvasInteractions {
|
|||||||
const rad = o.rotation * Math.PI / 180;
|
const rad = o.rotation * Math.PI / 180;
|
||||||
const cos = Math.cos(rad);
|
const cos = Math.cos(rad);
|
||||||
const sin = Math.sin(rad);
|
const sin = Math.sin(rad);
|
||||||
|
// Vector from anchor to mouse
|
||||||
const vecX = mouseX - anchor.x;
|
const vecX = mouseX - anchor.x;
|
||||||
const vecY = mouseY - anchor.y;
|
const vecY = mouseY - anchor.y;
|
||||||
let newWidth = vecX * cos + vecY * sin;
|
// Rotate vector to align with layer's local coordinates
|
||||||
let newHeight = vecY * cos - vecX * sin;
|
let localVecX = vecX * cos + vecY * sin;
|
||||||
if (isShiftPressed) {
|
let localVecY = vecY * cos - vecX * sin;
|
||||||
const originalAspectRatio = o.width / o.height;
|
// Determine sign based on handle
|
||||||
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
||||||
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
||||||
}
|
localVecX *= signX;
|
||||||
else {
|
localVecY *= signY;
|
||||||
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
|
// If not a corner handle, keep original dimension
|
||||||
}
|
|
||||||
}
|
|
||||||
let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
|
||||||
let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
|
||||||
newWidth *= signX;
|
|
||||||
newHeight *= signY;
|
|
||||||
if (signX === 0)
|
if (signX === 0)
|
||||||
newWidth = o.width;
|
localVecX = o.width;
|
||||||
if (signY === 0)
|
if (signY === 0)
|
||||||
newHeight = o.height;
|
localVecY = o.height;
|
||||||
if (newWidth < 10)
|
if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) {
|
||||||
newWidth = 10;
|
// CROP MODE: Calculate delta based on mouse movement and apply to cropBounds.
|
||||||
if (newHeight < 10)
|
// Calculate mouse movement since drag start, in the layer's local coordinate system.
|
||||||
newHeight = 10;
|
const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0);
|
||||||
layer.width = newWidth;
|
const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0);
|
||||||
layer.height = newHeight;
|
const mouseX_local = mouseX - (o.centerX ?? 0);
|
||||||
const deltaW = newWidth - o.width;
|
const mouseY_local = mouseY - (o.centerY ?? 0);
|
||||||
const deltaH = newHeight - o.height;
|
// Rotate mouse delta into the layer's unrotated frame
|
||||||
const shiftX = (deltaW / 2) * signX;
|
const deltaX_world = mouseX_local - dragStartX_local;
|
||||||
const shiftY = (deltaH / 2) * signY;
|
const deltaY_world = mouseY_local - dragStartY_local;
|
||||||
const worldShiftX = shiftX * cos - shiftY * sin;
|
const mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
|
||||||
const worldShiftY = shiftX * sin + shiftY * cos;
|
const mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin;
|
||||||
const newCenterX = o.centerX + worldShiftX;
|
// Convert the on-screen mouse delta to an image-space delta.
|
||||||
const newCenterY = o.centerY + worldShiftY;
|
const screenToImageScaleX = o.originalWidth / o.width;
|
||||||
layer.x = newCenterX - layer.width / 2;
|
const screenToImageScaleY = o.originalHeight / o.height;
|
||||||
layer.y = newCenterY - layer.height / 2;
|
const delta_image_x = mouseDeltaX_local * screenToImageScaleX;
|
||||||
|
const delta_image_y = mouseDeltaY_local * screenToImageScaleY;
|
||||||
|
let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag
|
||||||
|
// Apply the image-space delta to the appropriate edges of the crop bounds
|
||||||
|
if (handle?.includes('w')) {
|
||||||
|
newCropBounds.x += delta_image_x;
|
||||||
|
newCropBounds.width -= delta_image_x;
|
||||||
|
}
|
||||||
|
if (handle?.includes('e')) {
|
||||||
|
newCropBounds.width += delta_image_x;
|
||||||
|
}
|
||||||
|
if (handle?.includes('n')) {
|
||||||
|
newCropBounds.y += delta_image_y;
|
||||||
|
newCropBounds.height -= delta_image_y;
|
||||||
|
}
|
||||||
|
if (handle?.includes('s')) {
|
||||||
|
newCropBounds.height += delta_image_y;
|
||||||
|
}
|
||||||
|
// Clamp crop bounds to stay within the original image and maintain minimum size
|
||||||
|
if (newCropBounds.width < 1) {
|
||||||
|
if (handle?.includes('w'))
|
||||||
|
newCropBounds.x = o.cropBounds.x + o.cropBounds.width - 1;
|
||||||
|
newCropBounds.width = 1;
|
||||||
|
}
|
||||||
|
if (newCropBounds.height < 1) {
|
||||||
|
if (handle?.includes('n'))
|
||||||
|
newCropBounds.y = o.cropBounds.y + o.cropBounds.height - 1;
|
||||||
|
newCropBounds.height = 1;
|
||||||
|
}
|
||||||
|
if (newCropBounds.x < 0) {
|
||||||
|
newCropBounds.width += newCropBounds.x;
|
||||||
|
newCropBounds.x = 0;
|
||||||
|
}
|
||||||
|
if (newCropBounds.y < 0) {
|
||||||
|
newCropBounds.height += newCropBounds.y;
|
||||||
|
newCropBounds.y = 0;
|
||||||
|
}
|
||||||
|
if (newCropBounds.x + newCropBounds.width > o.originalWidth) {
|
||||||
|
newCropBounds.width = o.originalWidth - newCropBounds.x;
|
||||||
|
}
|
||||||
|
if (newCropBounds.y + newCropBounds.height > o.originalHeight) {
|
||||||
|
newCropBounds.height = o.originalHeight - newCropBounds.y;
|
||||||
|
}
|
||||||
|
layer.cropBounds = newCropBounds;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// TRANSFORM MODE: Resize the layer's main transform frame
|
||||||
|
let newWidth = localVecX;
|
||||||
|
let newHeight = localVecY;
|
||||||
|
if (isShiftPressed) {
|
||||||
|
const originalAspectRatio = o.width / o.height;
|
||||||
|
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
||||||
|
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newWidth < 10)
|
||||||
|
newWidth = 10;
|
||||||
|
if (newHeight < 10)
|
||||||
|
newHeight = 10;
|
||||||
|
layer.width = newWidth;
|
||||||
|
layer.height = newHeight;
|
||||||
|
// Update position to keep anchor point fixed
|
||||||
|
const deltaW = layer.width - o.width;
|
||||||
|
const deltaH = layer.height - o.height;
|
||||||
|
const shiftX = (deltaW / 2) * signX;
|
||||||
|
const shiftY = (deltaH / 2) * signY;
|
||||||
|
const worldShiftX = shiftX * cos - shiftY * sin;
|
||||||
|
const worldShiftY = shiftX * sin + shiftY * cos;
|
||||||
|
const newCenterX = o.centerX + worldShiftX;
|
||||||
|
const newCenterY = o.centerY + worldShiftY;
|
||||||
|
layer.x = newCenterX - layer.width / 2;
|
||||||
|
layer.y = newCenterY - layer.height / 2;
|
||||||
|
}
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
rotateLayerFromHandle(worldCoords, isShiftPressed) {
|
rotateLayerFromHandle(worldCoords, isShiftPressed) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { createDistanceFieldMaskSync } from "./utils/ImageAnalysis.js";
|
|||||||
const log = createModuleLogger('CanvasLayers');
|
const log = createModuleLogger('CanvasLayers');
|
||||||
export class CanvasLayers {
|
export class CanvasLayers {
|
||||||
constructor(canvas) {
|
constructor(canvas) {
|
||||||
|
this._canvasMaskCache = new Map();
|
||||||
this.blendMenuElement = null;
|
this.blendMenuElement = null;
|
||||||
this.blendMenuWorldX = 0;
|
this.blendMenuWorldX = 0;
|
||||||
this.blendMenuWorldY = 0;
|
this.blendMenuWorldY = 0;
|
||||||
@@ -309,6 +310,7 @@ export class CanvasLayers {
|
|||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
layer.width *= scale;
|
layer.width *= scale;
|
||||||
layer.height *= scale;
|
layer.height *= scale;
|
||||||
|
this.invalidateBlendCache(layer);
|
||||||
});
|
});
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
@@ -318,11 +320,14 @@ export class CanvasLayers {
|
|||||||
return;
|
return;
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
layer.rotation += angle;
|
layer.rotation += angle;
|
||||||
|
this.invalidateBlendCache(layer);
|
||||||
});
|
});
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
}
|
}
|
||||||
getLayerAtPosition(worldX, worldY) {
|
getLayerAtPosition(worldX, worldY) {
|
||||||
|
// Always sort by zIndex so topmost is checked first
|
||||||
|
this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
|
||||||
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
|
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
|
||||||
const layer = this.canvas.layers[i];
|
const layer = this.canvas.layers[i];
|
||||||
// Skip invisible layers
|
// Skip invisible layers
|
||||||
@@ -365,69 +370,197 @@ export class CanvasLayers {
|
|||||||
const blendArea = layer.blendArea ?? 0;
|
const blendArea = layer.blendArea ?? 0;
|
||||||
const needsBlendAreaEffect = blendArea > 0;
|
const needsBlendAreaEffect = blendArea > 0;
|
||||||
if (needsBlendAreaEffect) {
|
if (needsBlendAreaEffect) {
|
||||||
log.debug(`Applying blend area effect for layer ${layer.id}, blendArea: ${blendArea}%`);
|
// Check if we have a valid cached blended image
|
||||||
// Get or create distance field mask
|
if (layer.blendedImageCache && !layer.blendedImageDirty) {
|
||||||
const maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
|
// Use cached blended image for optimal performance
|
||||||
if (maskCanvas) {
|
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||||
// Create a temporary canvas for the masked layer
|
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height);
|
ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
if (tempCtx) {
|
}
|
||||||
// Draw the original image
|
else {
|
||||||
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
// Cache is invalid or doesn't exist, update it
|
||||||
// Apply the distance field mask using destination-in for transparency effect
|
this.updateLayerBlendEffect(layer);
|
||||||
tempCtx.globalCompositeOperation = 'destination-in';
|
// Use the newly created cache if available, otherwise fallback
|
||||||
tempCtx.drawImage(maskCanvas, 0, 0, layer.width, layer.height);
|
if (layer.blendedImageCache) {
|
||||||
// Draw the result
|
|
||||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||||
ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Fallback to normal drawing
|
// Fallback to normal drawing
|
||||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
this._drawLayerImage(ctx, layer);
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
// Fallback to normal drawing
|
|
||||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Normal drawing without blend area effect
|
// Normal drawing without blend area effect
|
||||||
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
this._drawLayerImage(ctx, layer);
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
}
|
}
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
getDistanceFieldMaskSync(image, blendArea) {
|
_drawLayerImage(ctx, layer) {
|
||||||
// Check cache first
|
ctx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||||
let imageCache = this.distanceFieldCache.get(image);
|
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||||
if (!imageCache) {
|
// Use cropBounds if they exist, otherwise use the full image dimensions as the source
|
||||||
imageCache = new Map();
|
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||||
this.distanceFieldCache.set(image, imageCache);
|
if (!layer.originalWidth || !layer.originalHeight) {
|
||||||
|
// Fallback for older layers without original dimensions or if data is missing
|
||||||
|
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
let maskCanvas = imageCache.get(blendArea);
|
// Calculate the on-screen scale of the layer's transform frame
|
||||||
if (!maskCanvas) {
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
|
// Calculate the on-screen size of the cropped portion
|
||||||
|
const dWidth = s.width * layerScaleX;
|
||||||
|
const dHeight = s.height * layerScaleY;
|
||||||
|
// Calculate the on-screen position of the top-left of the cropped portion.
|
||||||
|
// This is relative to the layer's center (the context's 0,0).
|
||||||
|
const dX = (-layer.width / 2) + (s.x * layerScaleX);
|
||||||
|
const dY = (-layer.height / 2) + (s.y * layerScaleY);
|
||||||
|
ctx.drawImage(layer.image, s.x, s.y, s.width, s.height, // source rect (from original image)
|
||||||
|
dX, dY, dWidth, dHeight // destination rect (scaled and positioned within the transform frame)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Invalidates the blended image cache for a layer
|
||||||
|
*/
|
||||||
|
invalidateBlendCache(layer) {
|
||||||
|
layer.blendedImageDirty = true;
|
||||||
|
layer.blendedImageCache = undefined;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Updates the blended image cache for a layer with blendArea effect
|
||||||
|
*/
|
||||||
|
updateLayerBlendEffect(layer) {
|
||||||
|
const blendArea = layer.blendArea ?? 0;
|
||||||
|
if (blendArea <= 0) {
|
||||||
|
// No blend effect needed, clear cache
|
||||||
|
layer.blendedImageCache = undefined;
|
||||||
|
layer.blendedImageDirty = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
log.debug(`Updating blend effect cache for layer ${layer.id}, blendArea: ${blendArea}%`);
|
||||||
|
// Create the blended image using the same logic as _drawLayer
|
||||||
|
let maskCanvas = null;
|
||||||
|
let maskWidth = layer.width;
|
||||||
|
let maskHeight = layer.height;
|
||||||
|
if (layer.cropBounds && layer.originalWidth && layer.originalHeight) {
|
||||||
|
// Create a cropped canvas
|
||||||
|
const s = layer.cropBounds;
|
||||||
|
const { canvas: cropCanvas, ctx: cropCtx } = createCanvas(s.width, s.height);
|
||||||
|
if (cropCtx) {
|
||||||
|
cropCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, 0, 0, s.width, s.height);
|
||||||
|
// Generate distance field mask for the cropped region
|
||||||
|
maskCanvas = this.getDistanceFieldMaskSync(cropCanvas, blendArea);
|
||||||
|
maskWidth = s.width;
|
||||||
|
maskHeight = s.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// No crop, use full image
|
||||||
|
maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
|
||||||
|
maskWidth = layer.originalWidth || layer.width;
|
||||||
|
maskHeight = layer.originalHeight || layer.height;
|
||||||
|
}
|
||||||
|
if (maskCanvas) {
|
||||||
|
// Create the final blended canvas
|
||||||
|
const { canvas: blendedCanvas, ctx: blendedCtx } = createCanvas(layer.width, layer.height);
|
||||||
|
if (blendedCtx) {
|
||||||
|
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||||
|
if (!layer.originalWidth || !layer.originalHeight) {
|
||||||
|
blendedCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
|
const dWidth = s.width * layerScaleX;
|
||||||
|
const dHeight = s.height * layerScaleY;
|
||||||
|
const dX = s.x * layerScaleX;
|
||||||
|
const dY = s.y * layerScaleY;
|
||||||
|
blendedCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, dX, dY, dWidth, dHeight);
|
||||||
|
// Apply the distance field mask only to the visible (cropped) area
|
||||||
|
blendedCtx.globalCompositeOperation = 'destination-in';
|
||||||
|
// Scale the mask to match the drawn area
|
||||||
|
blendedCtx.drawImage(maskCanvas, 0, 0, maskWidth, maskHeight, dX, dY, dWidth, dHeight);
|
||||||
|
}
|
||||||
|
// Store the blended result in cache
|
||||||
|
layer.blendedImageCache = blendedCanvas;
|
||||||
|
layer.blendedImageDirty = false;
|
||||||
|
log.debug(`Blend effect cache updated for layer ${layer.id}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.warn(`Failed to create blended canvas context for layer ${layer.id}`);
|
||||||
|
layer.blendedImageCache = undefined;
|
||||||
|
layer.blendedImageDirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.warn(`Failed to create distance field mask for layer ${layer.id}`);
|
||||||
|
layer.blendedImageCache = undefined;
|
||||||
|
layer.blendedImageDirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error(`Error updating blend effect for layer ${layer.id}:`, error);
|
||||||
|
layer.blendedImageCache = undefined;
|
||||||
|
layer.blendedImageDirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getDistanceFieldMaskSync(imageOrCanvas, blendArea) {
|
||||||
|
// Use a WeakMap for images, and a Map for canvases (since canvases are not always stable references)
|
||||||
|
let cacheKey = imageOrCanvas;
|
||||||
|
if (imageOrCanvas instanceof HTMLCanvasElement) {
|
||||||
|
// For canvases, use a Map on this instance (not WeakMap)
|
||||||
|
if (!this._canvasMaskCache)
|
||||||
|
this._canvasMaskCache = new Map();
|
||||||
|
let canvasCache = this._canvasMaskCache.get(imageOrCanvas);
|
||||||
|
if (!canvasCache) {
|
||||||
|
canvasCache = new Map();
|
||||||
|
this._canvasMaskCache.set(imageOrCanvas, canvasCache);
|
||||||
|
}
|
||||||
|
if (canvasCache.has(blendArea)) {
|
||||||
|
log.info(`Using cached distance field mask for blendArea: ${blendArea}% (canvas)`);
|
||||||
|
return canvasCache.get(blendArea) || null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
|
log.info(`Creating distance field mask for blendArea: ${blendArea}% (canvas)`);
|
||||||
maskCanvas = createDistanceFieldMaskSync(image, blendArea);
|
const maskCanvas = createDistanceFieldMaskSync(imageOrCanvas, blendArea);
|
||||||
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
|
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
|
||||||
imageCache.set(blendArea, maskCanvas);
|
canvasCache.set(blendArea, maskCanvas);
|
||||||
|
return maskCanvas;
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
log.error('Failed to create distance field mask:', error);
|
log.error('Failed to create distance field mask (canvas):', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
|
// For images, use the original WeakMap cache
|
||||||
|
let imageCache = this.distanceFieldCache.get(imageOrCanvas);
|
||||||
|
if (!imageCache) {
|
||||||
|
imageCache = new Map();
|
||||||
|
this.distanceFieldCache.set(imageOrCanvas, imageCache);
|
||||||
|
}
|
||||||
|
let maskCanvas = imageCache.get(blendArea);
|
||||||
|
if (!maskCanvas) {
|
||||||
|
try {
|
||||||
|
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
|
||||||
|
maskCanvas = createDistanceFieldMaskSync(imageOrCanvas, blendArea);
|
||||||
|
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
|
||||||
|
imageCache.set(blendArea, maskCanvas);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.error('Failed to create distance field mask:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
|
||||||
|
}
|
||||||
|
return maskCanvas;
|
||||||
}
|
}
|
||||||
return maskCanvas;
|
|
||||||
}
|
}
|
||||||
_drawLayers(ctx, layers, options = {}) {
|
_drawLayers(ctx, layers, options = {}) {
|
||||||
const sortedLayers = [...layers].sort((a, b) => a.zIndex - b.zIndex);
|
const sortedLayers = [...layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||||
@@ -445,6 +578,7 @@ export class CanvasLayers {
|
|||||||
return;
|
return;
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
layer.flipH = !layer.flipH;
|
layer.flipH = !layer.flipH;
|
||||||
|
this.invalidateBlendCache(layer);
|
||||||
});
|
});
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
@@ -454,6 +588,7 @@ export class CanvasLayers {
|
|||||||
return;
|
return;
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer) => {
|
||||||
layer.flipV = !layer.flipV;
|
layer.flipV = !layer.flipV;
|
||||||
|
this.invalidateBlendCache(layer);
|
||||||
});
|
});
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
@@ -527,30 +662,47 @@ export class CanvasLayers {
|
|||||||
this.canvas.saveState();
|
this.canvas.saveState();
|
||||||
}
|
}
|
||||||
getHandles(layer) {
|
getHandles(layer) {
|
||||||
const centerX = layer.x + layer.width / 2;
|
const layerCenterX = layer.x + layer.width / 2;
|
||||||
const centerY = layer.y + layer.height / 2;
|
const layerCenterY = layer.y + layer.height / 2;
|
||||||
const rad = layer.rotation * Math.PI / 180;
|
const rad = layer.rotation * Math.PI / 180;
|
||||||
const cos = Math.cos(rad);
|
const cos = Math.cos(rad);
|
||||||
const sin = Math.sin(rad);
|
const sin = Math.sin(rad);
|
||||||
const halfW = layer.width / 2;
|
let handleCenterX, handleCenterY, halfW, halfH;
|
||||||
const halfH = layer.height / 2;
|
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
|
||||||
|
// CROP MODE: Handles are relative to the cropped area
|
||||||
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
|
const cropRectW = layer.cropBounds.width * layerScaleX;
|
||||||
|
const cropRectH = layer.cropBounds.height * layerScaleY;
|
||||||
|
// Center of the CROP rectangle in the layer's local, un-rotated space
|
||||||
|
const cropCenterX_local = (-layer.width / 2) + ((layer.cropBounds.x + layer.cropBounds.width / 2) * layerScaleX);
|
||||||
|
const cropCenterY_local = (-layer.height / 2) + ((layer.cropBounds.y + layer.cropBounds.height / 2) * layerScaleY);
|
||||||
|
// Rotate this local center to find the world-space center of the crop rect
|
||||||
|
handleCenterX = layerCenterX + (cropCenterX_local * cos - cropCenterY_local * sin);
|
||||||
|
handleCenterY = layerCenterY + (cropCenterX_local * sin + cropCenterY_local * cos);
|
||||||
|
halfW = cropRectW / 2;
|
||||||
|
halfH = cropRectH / 2;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// TRANSFORM MODE: Handles are relative to the full layer transform frame
|
||||||
|
handleCenterX = layerCenterX;
|
||||||
|
handleCenterY = layerCenterY;
|
||||||
|
halfW = layer.width / 2;
|
||||||
|
halfH = layer.height / 2;
|
||||||
|
}
|
||||||
const localHandles = {
|
const localHandles = {
|
||||||
'n': { x: 0, y: -halfH },
|
'n': { x: 0, y: -halfH }, 'ne': { x: halfW, y: -halfH },
|
||||||
'ne': { x: halfW, y: -halfH },
|
'e': { x: halfW, y: 0 }, 'se': { x: halfW, y: halfH },
|
||||||
'e': { x: halfW, y: 0 },
|
's': { x: 0, y: halfH }, 'sw': { x: -halfW, y: halfH },
|
||||||
'se': { x: halfW, y: halfH },
|
'w': { x: -halfW, y: 0 }, 'nw': { x: -halfW, y: -halfH },
|
||||||
's': { x: 0, y: halfH },
|
|
||||||
'sw': { x: -halfW, y: halfH },
|
|
||||||
'w': { x: -halfW, y: 0 },
|
|
||||||
'nw': { x: -halfW, y: -halfH },
|
|
||||||
'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom }
|
'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom }
|
||||||
};
|
};
|
||||||
const worldHandles = {};
|
const worldHandles = {};
|
||||||
for (const key in localHandles) {
|
for (const key in localHandles) {
|
||||||
const p = localHandles[key];
|
const p = localHandles[key];
|
||||||
worldHandles[key] = {
|
worldHandles[key] = {
|
||||||
x: centerX + (p.x * cos - p.y * sin),
|
x: handleCenterX + (p.x * cos - p.y * sin),
|
||||||
y: centerY + (p.x * sin + p.y * cos)
|
y: handleCenterY + (p.x * sin + p.y * cos)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return worldHandles;
|
return worldHandles;
|
||||||
@@ -716,10 +868,16 @@ export class CanvasLayers {
|
|||||||
if (selectedLayer) {
|
if (selectedLayer) {
|
||||||
const newValue = parseInt(blendAreaSlider.value, 10);
|
const newValue = parseInt(blendAreaSlider.value, 10);
|
||||||
selectedLayer.blendArea = newValue;
|
selectedLayer.blendArea = newValue;
|
||||||
|
// Invalidate cache when blend area changes
|
||||||
|
this.invalidateBlendCache(selectedLayer);
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
blendAreaSlider.addEventListener('change', () => {
|
blendAreaSlider.addEventListener('change', () => {
|
||||||
|
if (selectedLayer) {
|
||||||
|
// Update the blend effect cache when the slider value is finalized
|
||||||
|
this.updateLayerBlendEffect(selectedLayer);
|
||||||
|
}
|
||||||
this.canvas.saveState();
|
this.canvas.saveState();
|
||||||
});
|
});
|
||||||
blendAreaContainer.appendChild(blendAreaLabel);
|
blendAreaContainer.appendChild(blendAreaLabel);
|
||||||
|
|||||||
@@ -431,38 +431,63 @@ export class CanvasRenderer {
|
|||||||
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 ---
|
||||||
// Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia)
|
ctx.lineWidth = lineWidth;
|
||||||
const halfW = layer.width / 2;
|
// 1. Draw dashed blue line for the full transform frame (the "original size" container)
|
||||||
const halfH = layer.height / 2;
|
ctx.strokeStyle = '#007bff';
|
||||||
// Górna krawędź
|
ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
|
||||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
// Prawa krawędź
|
ctx.setLineDash([]);
|
||||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
// 2. Draw solid blue line for the crop bounds
|
||||||
// Dolna krawędź
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
// Lewa krawędź
|
const s = layer.cropBounds;
|
||||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
|
||||||
// Rysuj linię do uchwytu rotacji (zawsze ciągła)
|
const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
|
||||||
ctx.setLineDash([]);
|
const cropRectW = s.width * layerScaleX;
|
||||||
ctx.beginPath();
|
const cropRectH = s.height * layerScaleY;
|
||||||
ctx.moveTo(0, -layer.height / 2);
|
ctx.strokeStyle = '#007bff'; // Solid blue
|
||||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
|
||||||
ctx.stroke();
|
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
|
||||||
// Rysuj uchwyty
|
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();
|
||||||
|
ctx.moveTo(0, -halfH);
|
||||||
|
ctx.lineTo(0, -halfH - 20 / this.canvas.viewport.zoom);
|
||||||
|
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;
|
||||||
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, we need it in the layer's rotated space
|
||||||
const localX = point.x - (layer.x + layer.width / 2);
|
const localX = point.x - (layer.x + layer.width / 2);
|
||||||
const localY = point.y - (layer.y + layer.height / 2);
|
const localY = point.y - (layer.y + layer.height / 2);
|
||||||
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 rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
||||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
||||||
|
ctx.beginPath();
|
||||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|||||||
@@ -286,6 +286,9 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer, index) => {
|
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer, index) => {
|
||||||
const newLayer = { ...layer, imageId: layer.imageId || '' };
|
const newLayer = { ...layer, imageId: layer.imageId || '' };
|
||||||
delete newLayer.image;
|
delete newLayer.image;
|
||||||
|
// Remove cache properties that cannot be serialized for the worker
|
||||||
|
delete newLayer.blendedImageCache;
|
||||||
|
delete newLayer.blendedImageDirty;
|
||||||
if (layer.image instanceof HTMLImageElement) {
|
if (layer.image instanceof HTMLImageElement) {
|
||||||
if (layer.imageId) {
|
if (layer.imageId) {
|
||||||
newLayer.imageId = layer.imageId;
|
newLayer.imageId = layer.imageId;
|
||||||
|
|||||||
125
js/CanvasView.js
125
js/CanvasView.js
@@ -17,6 +17,32 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
onStateChange: () => updateOutput(node, canvas)
|
onStateChange: () => updateOutput(node, canvas)
|
||||||
});
|
});
|
||||||
const imageCache = new ImageCache();
|
const imageCache = new ImageCache();
|
||||||
|
/**
|
||||||
|
* Helper function to update the icon of a switch component.
|
||||||
|
* @param knobIconEl The HTML element for the switch's knob icon.
|
||||||
|
* @param isChecked The current state of the switch (e.g., checkbox.checked).
|
||||||
|
* @param iconToolTrue The icon tool name for the 'true' state.
|
||||||
|
* @param iconToolFalse The icon tool name for the 'false' state.
|
||||||
|
* @param fallbackTrue The text fallback for the 'true' state.
|
||||||
|
* @param fallbackFalse The text fallback for the 'false' state.
|
||||||
|
*/
|
||||||
|
const updateSwitchIcon = (knobIconEl, isChecked, iconToolTrue, iconToolFalse, fallbackTrue, fallbackFalse) => {
|
||||||
|
if (!knobIconEl)
|
||||||
|
return;
|
||||||
|
const iconTool = isChecked ? iconToolTrue : iconToolFalse;
|
||||||
|
const fallbackText = isChecked ? fallbackTrue : fallbackFalse;
|
||||||
|
const icon = iconLoader.getIcon(iconTool);
|
||||||
|
knobIconEl.innerHTML = ''; // Clear previous icon
|
||||||
|
if (icon instanceof HTMLImageElement) {
|
||||||
|
const clonedIcon = icon.cloneNode();
|
||||||
|
clonedIcon.style.width = '20px';
|
||||||
|
clonedIcon.style.height = '20px';
|
||||||
|
knobIconEl.appendChild(clonedIcon);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
knobIconEl.textContent = fallbackText;
|
||||||
|
}
|
||||||
|
};
|
||||||
const helpTooltip = $el("div.painter-tooltip", {
|
const helpTooltip = $el("div.painter-tooltip", {
|
||||||
id: `painter-help-tooltip-${node.id}`,
|
id: `painter-help-tooltip-${node.id}`,
|
||||||
});
|
});
|
||||||
@@ -158,27 +184,15 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
showTooltip(switchEl, tooltipContent);
|
showTooltip(switchEl, tooltipContent);
|
||||||
});
|
});
|
||||||
switchEl.addEventListener("mouseleave", hideTooltip);
|
switchEl.addEventListener("mouseleave", hideTooltip);
|
||||||
// Dynamic icon and text update on toggle
|
// Dynamic icon update on toggle
|
||||||
const input = switchEl.querySelector('input[type="checkbox"]');
|
const input = switchEl.querySelector('input[type="checkbox"]');
|
||||||
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon');
|
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon');
|
||||||
const updateSwitchView = (isClipspace) => {
|
input.addEventListener('change', () => {
|
||||||
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD;
|
updateSwitchIcon(knobIcon, input.checked, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
|
||||||
const icon = iconLoader.getIcon(iconTool);
|
});
|
||||||
if (icon instanceof HTMLImageElement) {
|
|
||||||
knobIcon.innerHTML = '';
|
|
||||||
const clonedIcon = icon.cloneNode();
|
|
||||||
clonedIcon.style.width = '20px';
|
|
||||||
clonedIcon.style.height = '20px';
|
|
||||||
knobIcon.appendChild(clonedIcon);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
knobIcon.textContent = isClipspace ? "🗂️" : "📋";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
input.addEventListener('change', () => updateSwitchView(input.checked));
|
|
||||||
// Initial state
|
// Initial state
|
||||||
iconLoader.preloadToolIcons().then(() => {
|
iconLoader.preloadToolIcons().then(() => {
|
||||||
updateSwitchView(isClipspace);
|
updateSwitchIcon(knobIcon, isClipspace, LAYERFORGE_TOOLS.CLIPSPACE, LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD, "🗂️", "📋");
|
||||||
});
|
});
|
||||||
return switchEl;
|
return switchEl;
|
||||||
})()
|
})()
|
||||||
@@ -293,6 +307,50 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
]),
|
]),
|
||||||
$el("div.painter-separator"),
|
$el("div.painter-separator"),
|
||||||
$el("div.painter-button-group", {}, [
|
$el("div.painter-button-group", {}, [
|
||||||
|
(() => {
|
||||||
|
const switchEl = $el("label.clipboard-switch.requires-selection", {
|
||||||
|
id: `crop-transform-switch-${node.id}`,
|
||||||
|
title: "Toggle between Transform and Crop mode for selected layer(s)"
|
||||||
|
}, [
|
||||||
|
$el("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
checked: false,
|
||||||
|
onchange: (e) => {
|
||||||
|
const isCropMode = e.target.checked;
|
||||||
|
const selectedLayers = canvas.canvasSelection.selectedLayers;
|
||||||
|
if (selectedLayers.length === 0)
|
||||||
|
return;
|
||||||
|
selectedLayers.forEach((layer) => {
|
||||||
|
layer.cropMode = isCropMode;
|
||||||
|
if (isCropMode && !layer.cropBounds) {
|
||||||
|
layer.cropBounds = { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
canvas.saveState();
|
||||||
|
canvas.render();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
$el("span.switch-track"),
|
||||||
|
$el("span.switch-labels", { style: { fontSize: "11px" } }, [
|
||||||
|
$el("span.text-clipspace", {}, ["Crop"]),
|
||||||
|
$el("span.text-system", {}, ["Transform"])
|
||||||
|
]),
|
||||||
|
$el("span.switch-knob", {}, [
|
||||||
|
$el("span.switch-icon", { id: `crop-transform-icon-${node.id}` })
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
const input = switchEl.querySelector('input[type="checkbox"]');
|
||||||
|
const knobIcon = switchEl.querySelector('.switch-icon');
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
updateSwitchIcon(knobIcon, input.checked, LAYERFORGE_TOOLS.CROP, LAYERFORGE_TOOLS.TRANSFORM, "✂️", "✥");
|
||||||
|
});
|
||||||
|
// Initial state
|
||||||
|
iconLoader.preloadToolIcons().then(() => {
|
||||||
|
updateSwitchIcon(knobIcon, false, // Initial state is transform
|
||||||
|
LAYERFORGE_TOOLS.CROP, LAYERFORGE_TOOLS.TRANSFORM, "✂️", "✥");
|
||||||
|
});
|
||||||
|
return switchEl;
|
||||||
|
})(),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Rotate +90°",
|
textContent: "Rotate +90°",
|
||||||
title: "Rotate selected layer(s) by +90 degrees",
|
title: "Rotate selected layer(s) by +90 degrees",
|
||||||
@@ -629,19 +687,38 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
const updateButtonStates = () => {
|
const updateButtonStates = () => {
|
||||||
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
||||||
const hasSelection = selectionCount > 0;
|
const hasSelection = selectionCount > 0;
|
||||||
controlPanel.querySelectorAll('.requires-selection').forEach((btn) => {
|
// --- Handle Standard Buttons ---
|
||||||
const button = btn;
|
controlPanel.querySelectorAll('.requires-selection').forEach((el) => {
|
||||||
if (button.textContent === 'Fuse') {
|
if (el.tagName === 'BUTTON') {
|
||||||
button.disabled = selectionCount < 2;
|
if (el.textContent === 'Fuse') {
|
||||||
}
|
el.disabled = selectionCount < 2;
|
||||||
else {
|
}
|
||||||
button.disabled = !hasSelection;
|
else {
|
||||||
|
el.disabled = !hasSelection;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const mattingBtn = controlPanel.querySelector('.matting-button');
|
const mattingBtn = controlPanel.querySelector('.matting-button');
|
||||||
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
|
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
|
||||||
mattingBtn.disabled = selectionCount !== 1;
|
mattingBtn.disabled = selectionCount !== 1;
|
||||||
}
|
}
|
||||||
|
// --- Handle Crop/Transform Switch ---
|
||||||
|
const switchEl = controlPanel.querySelector(`#crop-transform-switch-${node.id}`);
|
||||||
|
if (switchEl) {
|
||||||
|
const input = switchEl.querySelector('input');
|
||||||
|
const knobIcon = switchEl.querySelector('.switch-icon');
|
||||||
|
const isDisabled = !hasSelection;
|
||||||
|
switchEl.classList.toggle('disabled', isDisabled);
|
||||||
|
input.disabled = isDisabled;
|
||||||
|
if (!isDisabled) {
|
||||||
|
const isCropMode = canvas.canvasSelection.selectedLayers[0].cropMode || false;
|
||||||
|
if (input.checked !== isCropMode) {
|
||||||
|
input.checked = isCropMode;
|
||||||
|
}
|
||||||
|
// Update icon view
|
||||||
|
updateSwitchIcon(knobIcon, isCropMode, LAYERFORGE_TOOLS.CROP, LAYERFORGE_TOOLS.TRANSFORM, "✂️", "✥");
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
canvas.canvasSelection.onSelectionChange = updateButtonStates;
|
canvas.canvasSelection.onSelectionChange = updateButtonStates;
|
||||||
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);
|
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);
|
||||||
|
|||||||
@@ -51,6 +51,32 @@
|
|||||||
border-color: #3a76d6;
|
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 {
|
.painter-button.success {
|
||||||
border-color: #4ae27a;
|
border-color: #4ae27a;
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
@@ -306,6 +332,20 @@
|
|||||||
opacity: 0;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.painter-separator {
|
.painter-separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
|
|||||||
@@ -19,13 +19,19 @@ export const LAYERFORGE_TOOLS = {
|
|||||||
SETTINGS: 'settings',
|
SETTINGS: 'settings',
|
||||||
SYSTEM_CLIPBOARD: 'system_clipboard',
|
SYSTEM_CLIPBOARD: 'system_clipboard',
|
||||||
CLIPSPACE: 'clipspace',
|
CLIPSPACE: 'clipspace',
|
||||||
|
CROP: 'crop',
|
||||||
|
TRANSFORM: 'transform',
|
||||||
};
|
};
|
||||||
// SVG Icons for LayerForge tools
|
// 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 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 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 = {
|
const LAYERFORGE_TOOL_ICONS = {
|
||||||
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
|
[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.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.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.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.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>')}`,
|
||||||
@@ -54,7 +60,9 @@ const LAYERFORGE_TOOL_COLORS = {
|
|||||||
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
|
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
|
||||||
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
|
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
|
||||||
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
|
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
|
||||||
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292'
|
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292',
|
||||||
|
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
|
||||||
|
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
|
||||||
};
|
};
|
||||||
export class IconLoader {
|
export class IconLoader {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -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.5.0"
|
version = "1.5.2"
|
||||||
license = { text = "MIT License" }
|
license = { text = "MIT License" }
|
||||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||||
|
|
||||||
|
|||||||
@@ -626,7 +626,10 @@ export class CanvasInteractions {
|
|||||||
width: layer.width, height: layer.height,
|
width: layer.width, height: layer.height,
|
||||||
rotation: layer.rotation,
|
rotation: layer.rotation,
|
||||||
centerX: layer.x + layer.width / 2,
|
centerX: layer.x + layer.width / 2,
|
||||||
centerY: layer.y + layer.height / 2
|
centerY: layer.y + layer.height / 2,
|
||||||
|
originalWidth: layer.originalWidth,
|
||||||
|
originalHeight: layer.originalHeight,
|
||||||
|
cropBounds: layer.cropBounds ? { ...layer.cropBounds } : undefined
|
||||||
};
|
};
|
||||||
this.interaction.dragStart = {...worldCoords};
|
this.interaction.dragStart = {...worldCoords};
|
||||||
|
|
||||||
@@ -797,66 +800,137 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
if (this.interaction.isCtrlPressed) {
|
if (this.interaction.isCtrlPressed) {
|
||||||
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
const snapThreshold = 10 / this.canvas.viewport.zoom;
|
||||||
const snappedMouseX = snapToGrid(mouseX);
|
mouseX = Math.abs(mouseX - snapToGrid(mouseX)) < snapThreshold ? snapToGrid(mouseX) : mouseX;
|
||||||
if (Math.abs(mouseX - snappedMouseX) < snapThreshold) mouseX = snappedMouseX;
|
mouseY = Math.abs(mouseY - snapToGrid(mouseY)) < snapThreshold ? snapToGrid(mouseY) : mouseY;
|
||||||
const snappedMouseY = snapToGrid(mouseY);
|
|
||||||
if (Math.abs(mouseY - snappedMouseY) < snapThreshold) mouseY = snappedMouseY;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const o = this.interaction.transformOrigin;
|
const o = this.interaction.transformOrigin;
|
||||||
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
if (o.rotation === undefined || o.width === undefined || o.height === undefined || o.centerX === undefined || o.centerY === undefined) return;
|
||||||
|
|
||||||
const handle = this.interaction.resizeHandle;
|
const handle = this.interaction.resizeHandle;
|
||||||
const anchor = this.interaction.resizeAnchor;
|
const anchor = this.interaction.resizeAnchor;
|
||||||
|
|
||||||
const rad = o.rotation * Math.PI / 180;
|
const rad = o.rotation * Math.PI / 180;
|
||||||
const cos = Math.cos(rad);
|
const cos = Math.cos(rad);
|
||||||
const sin = Math.sin(rad);
|
const sin = Math.sin(rad);
|
||||||
|
|
||||||
|
// Vector from anchor to mouse
|
||||||
const vecX = mouseX - anchor.x;
|
const vecX = mouseX - anchor.x;
|
||||||
const vecY = mouseY - anchor.y;
|
const vecY = mouseY - anchor.y;
|
||||||
|
|
||||||
let newWidth = vecX * cos + vecY * sin;
|
// Rotate vector to align with layer's local coordinates
|
||||||
let newHeight = vecY * cos - vecX * sin;
|
let localVecX = vecX * cos + vecY * sin;
|
||||||
|
let localVecY = vecY * cos - vecX * sin;
|
||||||
|
|
||||||
if (isShiftPressed) {
|
// Determine sign based on handle
|
||||||
const originalAspectRatio = o.width / o.height;
|
const signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
||||||
|
const signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
||||||
|
|
||||||
|
localVecX *= signX;
|
||||||
|
localVecY *= signY;
|
||||||
|
|
||||||
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
// If not a corner handle, keep original dimension
|
||||||
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
if (signX === 0) localVecX = o.width;
|
||||||
} else {
|
if (signY === 0) localVecY = o.height;
|
||||||
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
|
|
||||||
|
if (layer.cropMode && o.cropBounds && o.originalWidth && o.originalHeight) {
|
||||||
|
// CROP MODE: Calculate delta based on mouse movement and apply to cropBounds.
|
||||||
|
|
||||||
|
// Calculate mouse movement since drag start, in the layer's local coordinate system.
|
||||||
|
const dragStartX_local = this.interaction.dragStart.x - (o.centerX ?? 0);
|
||||||
|
const dragStartY_local = this.interaction.dragStart.y - (o.centerY ?? 0);
|
||||||
|
const mouseX_local = mouseX - (o.centerX ?? 0);
|
||||||
|
const mouseY_local = mouseY - (o.centerY ?? 0);
|
||||||
|
|
||||||
|
// Rotate mouse delta into the layer's unrotated frame
|
||||||
|
const deltaX_world = mouseX_local - dragStartX_local;
|
||||||
|
const deltaY_world = mouseY_local - dragStartY_local;
|
||||||
|
const mouseDeltaX_local = deltaX_world * cos + deltaY_world * sin;
|
||||||
|
const mouseDeltaY_local = deltaY_world * cos - deltaX_world * sin;
|
||||||
|
|
||||||
|
// Convert the on-screen mouse delta to an image-space delta.
|
||||||
|
const screenToImageScaleX = o.originalWidth / o.width;
|
||||||
|
const screenToImageScaleY = o.originalHeight / o.height;
|
||||||
|
|
||||||
|
const delta_image_x = mouseDeltaX_local * screenToImageScaleX;
|
||||||
|
const delta_image_y = mouseDeltaY_local * screenToImageScaleY;
|
||||||
|
|
||||||
|
let newCropBounds = { ...o.cropBounds }; // Start with the bounds from the beginning of the drag
|
||||||
|
|
||||||
|
// Apply the image-space delta to the appropriate edges of the crop bounds
|
||||||
|
if (handle?.includes('w')) {
|
||||||
|
newCropBounds.x += delta_image_x;
|
||||||
|
newCropBounds.width -= delta_image_x;
|
||||||
}
|
}
|
||||||
|
if (handle?.includes('e')) {
|
||||||
|
newCropBounds.width += delta_image_x;
|
||||||
|
}
|
||||||
|
if (handle?.includes('n')) {
|
||||||
|
newCropBounds.y += delta_image_y;
|
||||||
|
newCropBounds.height -= delta_image_y;
|
||||||
|
}
|
||||||
|
if (handle?.includes('s')) {
|
||||||
|
newCropBounds.height += delta_image_y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp crop bounds to stay within the original image and maintain minimum size
|
||||||
|
if (newCropBounds.width < 1) {
|
||||||
|
if (handle?.includes('w')) newCropBounds.x = o.cropBounds.x + o.cropBounds.width -1;
|
||||||
|
newCropBounds.width = 1;
|
||||||
|
}
|
||||||
|
if (newCropBounds.height < 1) {
|
||||||
|
if (handle?.includes('n')) newCropBounds.y = o.cropBounds.y + o.cropBounds.height - 1;
|
||||||
|
newCropBounds.height = 1;
|
||||||
|
}
|
||||||
|
if (newCropBounds.x < 0) {
|
||||||
|
newCropBounds.width += newCropBounds.x;
|
||||||
|
newCropBounds.x = 0;
|
||||||
|
}
|
||||||
|
if (newCropBounds.y < 0) {
|
||||||
|
newCropBounds.height += newCropBounds.y;
|
||||||
|
newCropBounds.y = 0;
|
||||||
|
}
|
||||||
|
if (newCropBounds.x + newCropBounds.width > o.originalWidth) {
|
||||||
|
newCropBounds.width = o.originalWidth - newCropBounds.x;
|
||||||
|
}
|
||||||
|
if (newCropBounds.y + newCropBounds.height > o.originalHeight) {
|
||||||
|
newCropBounds.height = o.originalHeight - newCropBounds.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.cropBounds = newCropBounds;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// TRANSFORM MODE: Resize the layer's main transform frame
|
||||||
|
let newWidth = localVecX;
|
||||||
|
let newHeight = localVecY;
|
||||||
|
|
||||||
|
if (isShiftPressed) {
|
||||||
|
const originalAspectRatio = o.width / o.height;
|
||||||
|
if (Math.abs(newWidth) > Math.abs(newHeight) * originalAspectRatio) {
|
||||||
|
newHeight = (Math.sign(newHeight) || 1) * Math.abs(newWidth) / originalAspectRatio;
|
||||||
|
} else {
|
||||||
|
newWidth = (Math.sign(newWidth) || 1) * Math.abs(newHeight) * originalAspectRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newWidth < 10) newWidth = 10;
|
||||||
|
if (newHeight < 10) newHeight = 10;
|
||||||
|
|
||||||
|
layer.width = newWidth;
|
||||||
|
layer.height = newHeight;
|
||||||
|
|
||||||
|
// Update position to keep anchor point fixed
|
||||||
|
const deltaW = layer.width - o.width;
|
||||||
|
const deltaH = layer.height - o.height;
|
||||||
|
const shiftX = (deltaW / 2) * signX;
|
||||||
|
const shiftY = (deltaH / 2) * signY;
|
||||||
|
const worldShiftX = shiftX * cos - shiftY * sin;
|
||||||
|
const worldShiftY = shiftX * sin + shiftY * cos;
|
||||||
|
const newCenterX = o.centerX + worldShiftX;
|
||||||
|
const newCenterY = o.centerY + worldShiftY;
|
||||||
|
layer.x = newCenterX - layer.width / 2;
|
||||||
|
layer.y = newCenterY - layer.height / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
let signX = handle?.includes('e') ? 1 : (handle?.includes('w') ? -1 : 0);
|
|
||||||
let signY = handle?.includes('s') ? 1 : (handle?.includes('n') ? -1 : 0);
|
|
||||||
|
|
||||||
newWidth *= signX;
|
|
||||||
newHeight *= signY;
|
|
||||||
|
|
||||||
if (signX === 0) newWidth = o.width;
|
|
||||||
if (signY === 0) newHeight = o.height;
|
|
||||||
|
|
||||||
if (newWidth < 10) newWidth = 10;
|
|
||||||
if (newHeight < 10) newHeight = 10;
|
|
||||||
|
|
||||||
layer.width = newWidth;
|
|
||||||
layer.height = newHeight;
|
|
||||||
|
|
||||||
const deltaW = newWidth - o.width;
|
|
||||||
const deltaH = newHeight - o.height;
|
|
||||||
|
|
||||||
const shiftX = (deltaW / 2) * signX;
|
|
||||||
const shiftY = (deltaH / 2) * signY;
|
|
||||||
|
|
||||||
const worldShiftX = shiftX * cos - shiftY * sin;
|
|
||||||
const worldShiftY = shiftX * sin + shiftY * cos;
|
|
||||||
|
|
||||||
const newCenterX = o.centerX + worldShiftX;
|
|
||||||
const newCenterY = o.centerY + worldShiftY;
|
|
||||||
|
|
||||||
layer.x = newCenterX - layer.width / 2;
|
|
||||||
layer.y = newCenterY - layer.height / 2;
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface BlendMode {
|
|||||||
|
|
||||||
export class CanvasLayers {
|
export class CanvasLayers {
|
||||||
private canvas: Canvas;
|
private canvas: Canvas;
|
||||||
|
private _canvasMaskCache: Map<HTMLCanvasElement, Map<number, HTMLCanvasElement>> = new Map();
|
||||||
public clipboardManager: ClipboardManager;
|
public clipboardManager: ClipboardManager;
|
||||||
private blendModes: BlendMode[];
|
private blendModes: BlendMode[];
|
||||||
private selectedBlendMode: string | null;
|
private selectedBlendMode: string | null;
|
||||||
@@ -354,6 +355,7 @@ export class CanvasLayers {
|
|||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||||
layer.width *= scale;
|
layer.width *= scale;
|
||||||
layer.height *= scale;
|
layer.height *= scale;
|
||||||
|
this.invalidateBlendCache(layer);
|
||||||
});
|
});
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
@@ -364,12 +366,16 @@ export class CanvasLayers {
|
|||||||
|
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||||
layer.rotation += angle;
|
layer.rotation += angle;
|
||||||
|
this.invalidateBlendCache(layer);
|
||||||
});
|
});
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
getLayerAtPosition(worldX: number, worldY: number): { layer: Layer, localX: number, localY: number } | null {
|
getLayerAtPosition(worldX: number, worldY: number): { layer: Layer, localX: number, localY: number } | null {
|
||||||
|
// Always sort by zIndex so topmost is checked first
|
||||||
|
this.canvas.layers.sort((a, b) => a.zIndex - b.zIndex);
|
||||||
|
|
||||||
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
|
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
|
||||||
const layer = this.canvas.layers[i];
|
const layer = this.canvas.layers[i];
|
||||||
|
|
||||||
@@ -424,72 +430,222 @@ export class CanvasLayers {
|
|||||||
const needsBlendAreaEffect = blendArea > 0;
|
const needsBlendAreaEffect = blendArea > 0;
|
||||||
|
|
||||||
if (needsBlendAreaEffect) {
|
if (needsBlendAreaEffect) {
|
||||||
log.debug(`Applying blend area effect for layer ${layer.id}, blendArea: ${blendArea}%`);
|
// Check if we have a valid cached blended image
|
||||||
// Get or create distance field mask
|
if (layer.blendedImageCache && !layer.blendedImageDirty) {
|
||||||
const maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
|
// Use cached blended image for optimal performance
|
||||||
|
|
||||||
if (maskCanvas) {
|
|
||||||
// Create a temporary canvas for the masked layer
|
|
||||||
const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height);
|
|
||||||
|
|
||||||
if (tempCtx) {
|
|
||||||
// Draw the original image
|
|
||||||
tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
|
||||||
|
|
||||||
// Apply the distance field mask using destination-in for transparency effect
|
|
||||||
tempCtx.globalCompositeOperation = 'destination-in';
|
|
||||||
tempCtx.drawImage(maskCanvas, 0, 0, layer.width, layer.height);
|
|
||||||
|
|
||||||
// Draw the result
|
|
||||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
} else {
|
|
||||||
// Fallback to normal drawing
|
|
||||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Fallback to normal drawing
|
|
||||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
|
} else {
|
||||||
|
// Cache is invalid or doesn't exist, update it
|
||||||
|
this.updateLayerBlendEffect(layer);
|
||||||
|
|
||||||
|
// Use the newly created cache if available, otherwise fallback
|
||||||
|
if (layer.blendedImageCache) {
|
||||||
|
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
||||||
|
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||||
|
ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
|
} else {
|
||||||
|
// Fallback to normal drawing
|
||||||
|
this._drawLayerImage(ctx, layer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal drawing without blend area effect
|
// Normal drawing without blend area effect
|
||||||
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
this._drawLayerImage(ctx, layer);
|
||||||
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
|
||||||
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDistanceFieldMaskSync(image: HTMLImageElement, blendArea: number): HTMLCanvasElement | null {
|
private _drawLayerImage(ctx: CanvasRenderingContext2D, layer: Layer): void {
|
||||||
// Check cache first
|
ctx.globalCompositeOperation = layer.blendMode as any || 'normal';
|
||||||
let imageCache = this.distanceFieldCache.get(image);
|
ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||||
if (!imageCache) {
|
|
||||||
imageCache = new Map();
|
|
||||||
this.distanceFieldCache.set(image, imageCache);
|
|
||||||
}
|
|
||||||
|
|
||||||
let maskCanvas = imageCache.get(blendArea);
|
// Use cropBounds if they exist, otherwise use the full image dimensions as the source
|
||||||
if (!maskCanvas) {
|
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||||
|
|
||||||
|
if (!layer.originalWidth || !layer.originalHeight) {
|
||||||
|
// Fallback for older layers without original dimensions or if data is missing
|
||||||
|
ctx.drawImage(layer.image, -layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the on-screen scale of the layer's transform frame
|
||||||
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
|
|
||||||
|
// Calculate the on-screen size of the cropped portion
|
||||||
|
const dWidth = s.width * layerScaleX;
|
||||||
|
const dHeight = s.height * layerScaleY;
|
||||||
|
|
||||||
|
// Calculate the on-screen position of the top-left of the cropped portion.
|
||||||
|
// This is relative to the layer's center (the context's 0,0).
|
||||||
|
const dX = (-layer.width / 2) + (s.x * layerScaleX);
|
||||||
|
const dY = (-layer.height / 2) + (s.y * layerScaleY);
|
||||||
|
|
||||||
|
ctx.drawImage(
|
||||||
|
layer.image,
|
||||||
|
s.x, s.y, s.width, s.height, // source rect (from original image)
|
||||||
|
dX, dY, dWidth, dHeight // destination rect (scaled and positioned within the transform frame)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidates the blended image cache for a layer
|
||||||
|
*/
|
||||||
|
public invalidateBlendCache(layer: Layer): void {
|
||||||
|
layer.blendedImageDirty = true;
|
||||||
|
layer.blendedImageCache = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the blended image cache for a layer with blendArea effect
|
||||||
|
*/
|
||||||
|
public updateLayerBlendEffect(layer: Layer): void {
|
||||||
|
const blendArea = layer.blendArea ?? 0;
|
||||||
|
|
||||||
|
if (blendArea <= 0) {
|
||||||
|
// No blend effect needed, clear cache
|
||||||
|
layer.blendedImageCache = undefined;
|
||||||
|
layer.blendedImageDirty = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.debug(`Updating blend effect cache for layer ${layer.id}, blendArea: ${blendArea}%`);
|
||||||
|
|
||||||
|
// Create the blended image using the same logic as _drawLayer
|
||||||
|
let maskCanvas: HTMLCanvasElement | null = null;
|
||||||
|
let maskWidth = layer.width;
|
||||||
|
let maskHeight = layer.height;
|
||||||
|
|
||||||
|
if (layer.cropBounds && layer.originalWidth && layer.originalHeight) {
|
||||||
|
// Create a cropped canvas
|
||||||
|
const s = layer.cropBounds;
|
||||||
|
const { canvas: cropCanvas, ctx: cropCtx } = createCanvas(s.width, s.height);
|
||||||
|
if (cropCtx) {
|
||||||
|
cropCtx.drawImage(
|
||||||
|
layer.image,
|
||||||
|
s.x, s.y, s.width, s.height,
|
||||||
|
0, 0, s.width, s.height
|
||||||
|
);
|
||||||
|
// Generate distance field mask for the cropped region
|
||||||
|
maskCanvas = this.getDistanceFieldMaskSync(cropCanvas, blendArea);
|
||||||
|
maskWidth = s.width;
|
||||||
|
maskHeight = s.height;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No crop, use full image
|
||||||
|
maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea);
|
||||||
|
maskWidth = layer.originalWidth || layer.width;
|
||||||
|
maskHeight = layer.originalHeight || layer.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maskCanvas) {
|
||||||
|
// Create the final blended canvas
|
||||||
|
const { canvas: blendedCanvas, ctx: blendedCtx } = createCanvas(layer.width, layer.height);
|
||||||
|
|
||||||
|
if (blendedCtx) {
|
||||||
|
const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||||
|
|
||||||
|
if (!layer.originalWidth || !layer.originalHeight) {
|
||||||
|
blendedCtx.drawImage(layer.image, 0, 0, layer.width, layer.height);
|
||||||
|
} else {
|
||||||
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
|
|
||||||
|
const dWidth = s.width * layerScaleX;
|
||||||
|
const dHeight = s.height * layerScaleY;
|
||||||
|
const dX = s.x * layerScaleX;
|
||||||
|
const dY = s.y * layerScaleY;
|
||||||
|
|
||||||
|
blendedCtx.drawImage(
|
||||||
|
layer.image,
|
||||||
|
s.x, s.y, s.width, s.height,
|
||||||
|
dX, dY, dWidth, dHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply the distance field mask only to the visible (cropped) area
|
||||||
|
blendedCtx.globalCompositeOperation = 'destination-in';
|
||||||
|
// Scale the mask to match the drawn area
|
||||||
|
blendedCtx.drawImage(
|
||||||
|
maskCanvas,
|
||||||
|
0, 0, maskWidth, maskHeight,
|
||||||
|
dX, dY, dWidth, dHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the blended result in cache
|
||||||
|
layer.blendedImageCache = blendedCanvas;
|
||||||
|
layer.blendedImageDirty = false;
|
||||||
|
|
||||||
|
log.debug(`Blend effect cache updated for layer ${layer.id}`);
|
||||||
|
} else {
|
||||||
|
log.warn(`Failed to create blended canvas context for layer ${layer.id}`);
|
||||||
|
layer.blendedImageCache = undefined;
|
||||||
|
layer.blendedImageDirty = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn(`Failed to create distance field mask for layer ${layer.id}`);
|
||||||
|
layer.blendedImageCache = undefined;
|
||||||
|
layer.blendedImageDirty = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Error updating blend effect for layer ${layer.id}:`, error);
|
||||||
|
layer.blendedImageCache = undefined;
|
||||||
|
layer.blendedImageDirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDistanceFieldMaskSync(imageOrCanvas: HTMLImageElement | HTMLCanvasElement, blendArea: number): HTMLCanvasElement | null {
|
||||||
|
// Use a WeakMap for images, and a Map for canvases (since canvases are not always stable references)
|
||||||
|
let cacheKey: any = imageOrCanvas;
|
||||||
|
if (imageOrCanvas instanceof HTMLCanvasElement) {
|
||||||
|
// For canvases, use a Map on this instance (not WeakMap)
|
||||||
|
if (!this._canvasMaskCache) this._canvasMaskCache = new Map();
|
||||||
|
let canvasCache = this._canvasMaskCache.get(imageOrCanvas);
|
||||||
|
if (!canvasCache) {
|
||||||
|
canvasCache = new Map();
|
||||||
|
this._canvasMaskCache.set(imageOrCanvas, canvasCache);
|
||||||
|
}
|
||||||
|
if (canvasCache.has(blendArea)) {
|
||||||
|
log.info(`Using cached distance field mask for blendArea: ${blendArea}% (canvas)`);
|
||||||
|
return canvasCache.get(blendArea) || null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
|
log.info(`Creating distance field mask for blendArea: ${blendArea}% (canvas)`);
|
||||||
maskCanvas = createDistanceFieldMaskSync(image, blendArea);
|
const maskCanvas = createDistanceFieldMaskSync(imageOrCanvas as any, blendArea);
|
||||||
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
|
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
|
||||||
imageCache.set(blendArea, maskCanvas);
|
canvasCache.set(blendArea, maskCanvas);
|
||||||
|
return maskCanvas;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Failed to create distance field mask:', error);
|
log.error('Failed to create distance field mask (canvas):', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
|
// For images, use the original WeakMap cache
|
||||||
|
let imageCache = this.distanceFieldCache.get(imageOrCanvas);
|
||||||
|
if (!imageCache) {
|
||||||
|
imageCache = new Map();
|
||||||
|
this.distanceFieldCache.set(imageOrCanvas, imageCache);
|
||||||
|
}
|
||||||
|
let maskCanvas = imageCache.get(blendArea);
|
||||||
|
if (!maskCanvas) {
|
||||||
|
try {
|
||||||
|
log.info(`Creating distance field mask for blendArea: ${blendArea}%`);
|
||||||
|
maskCanvas = createDistanceFieldMaskSync(imageOrCanvas, blendArea);
|
||||||
|
log.info(`Distance field mask created successfully, size: ${maskCanvas.width}x${maskCanvas.height}`);
|
||||||
|
imageCache.set(blendArea, maskCanvas);
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Failed to create distance field mask:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info(`Using cached distance field mask for blendArea: ${blendArea}%`);
|
||||||
|
}
|
||||||
|
return maskCanvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
return maskCanvas;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _drawLayers(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
|
private _drawLayers(ctx: CanvasRenderingContext2D, layers: Layer[], options: { offsetX?: number, offsetY?: number } = {}): void {
|
||||||
@@ -509,6 +665,7 @@ export class CanvasLayers {
|
|||||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||||
layer.flipH = !layer.flipH;
|
layer.flipH = !layer.flipH;
|
||||||
|
this.invalidateBlendCache(layer);
|
||||||
});
|
});
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
@@ -518,6 +675,7 @@ export class CanvasLayers {
|
|||||||
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
if (this.canvas.canvasSelection.selectedLayers.length === 0) return;
|
||||||
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => {
|
||||||
layer.flipV = !layer.flipV;
|
layer.flipV = !layer.flipV;
|
||||||
|
this.invalidateBlendCache(layer);
|
||||||
});
|
});
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
this.canvas.requestSaveState();
|
this.canvas.requestSaveState();
|
||||||
@@ -606,23 +764,45 @@ export class CanvasLayers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getHandles(layer: Layer): Record<string, Point> {
|
getHandles(layer: Layer): Record<string, Point> {
|
||||||
const centerX = layer.x + layer.width / 2;
|
const layerCenterX = layer.x + layer.width / 2;
|
||||||
const centerY = layer.y + layer.height / 2;
|
const layerCenterY = layer.y + layer.height / 2;
|
||||||
const rad = layer.rotation * Math.PI / 180;
|
const rad = layer.rotation * Math.PI / 180;
|
||||||
const cos = Math.cos(rad);
|
const cos = Math.cos(rad);
|
||||||
const sin = Math.sin(rad);
|
const sin = Math.sin(rad);
|
||||||
|
|
||||||
const halfW = layer.width / 2;
|
let handleCenterX, handleCenterY, halfW, halfH;
|
||||||
const halfH = layer.height / 2;
|
|
||||||
|
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
|
||||||
|
// CROP MODE: Handles are relative to the cropped area
|
||||||
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
|
|
||||||
|
const cropRectW = layer.cropBounds.width * layerScaleX;
|
||||||
|
const cropRectH = layer.cropBounds.height * layerScaleY;
|
||||||
|
|
||||||
|
// Center of the CROP rectangle in the layer's local, un-rotated space
|
||||||
|
const cropCenterX_local = (-layer.width / 2) + ((layer.cropBounds.x + layer.cropBounds.width / 2) * layerScaleX);
|
||||||
|
const cropCenterY_local = (-layer.height / 2) + ((layer.cropBounds.y + layer.cropBounds.height / 2) * layerScaleY);
|
||||||
|
|
||||||
|
// Rotate this local center to find the world-space center of the crop rect
|
||||||
|
handleCenterX = layerCenterX + (cropCenterX_local * cos - cropCenterY_local * sin);
|
||||||
|
handleCenterY = layerCenterY + (cropCenterX_local * sin + cropCenterY_local * cos);
|
||||||
|
|
||||||
|
halfW = cropRectW / 2;
|
||||||
|
halfH = cropRectH / 2;
|
||||||
|
} else {
|
||||||
|
// TRANSFORM MODE: Handles are relative to the full layer transform frame
|
||||||
|
handleCenterX = layerCenterX;
|
||||||
|
handleCenterY = layerCenterY;
|
||||||
|
halfW = layer.width / 2;
|
||||||
|
halfH = layer.height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
const localHandles: Record<string, Point> = {
|
const localHandles: Record<string, Point> = {
|
||||||
'n': { x: 0, y: -halfH },
|
'n': { x: 0, y: -halfH }, 'ne': { x: halfW, y: -halfH },
|
||||||
'ne': { x: halfW, y: -halfH },
|
'e': { x: halfW, y: 0 }, 'se': { x: halfW, y: halfH },
|
||||||
'e': { x: halfW, y: 0 },
|
's': { x: 0, y: halfH }, 'sw': { x: -halfW, y: halfH },
|
||||||
'se': { x: halfW, y: halfH },
|
'w': { x: -halfW, y: 0 }, 'nw': { x: -halfW, y: -halfH },
|
||||||
's': { x: 0, y: halfH },
|
|
||||||
'sw': { x: -halfW, y: halfH },
|
|
||||||
'w': { x: -halfW, y: 0 },
|
|
||||||
'nw': { x: -halfW, y: -halfH },
|
|
||||||
'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom }
|
'rot': { x: 0, y: -halfH - 20 / this.canvas.viewport.zoom }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -630,8 +810,8 @@ export class CanvasLayers {
|
|||||||
for (const key in localHandles) {
|
for (const key in localHandles) {
|
||||||
const p = localHandles[key];
|
const p = localHandles[key];
|
||||||
worldHandles[key] = {
|
worldHandles[key] = {
|
||||||
x: centerX + (p.x * cos - p.y * sin),
|
x: handleCenterX + (p.x * cos - p.y * sin),
|
||||||
y: centerY + (p.x * sin + p.y * cos)
|
y: handleCenterY + (p.x * sin + p.y * cos)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return worldHandles;
|
return worldHandles;
|
||||||
@@ -833,11 +1013,17 @@ export class CanvasLayers {
|
|||||||
if (selectedLayer) {
|
if (selectedLayer) {
|
||||||
const newValue = parseInt(blendAreaSlider.value, 10);
|
const newValue = parseInt(blendAreaSlider.value, 10);
|
||||||
selectedLayer.blendArea = newValue;
|
selectedLayer.blendArea = newValue;
|
||||||
|
// Invalidate cache when blend area changes
|
||||||
|
this.invalidateBlendCache(selectedLayer);
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
blendAreaSlider.addEventListener('change', () => {
|
blendAreaSlider.addEventListener('change', () => {
|
||||||
|
if (selectedLayer) {
|
||||||
|
// Update the blend effect cache when the slider value is finalized
|
||||||
|
this.updateLayerBlendEffect(selectedLayer);
|
||||||
|
}
|
||||||
this.canvas.saveState();
|
this.canvas.saveState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -532,38 +532,66 @@ export class CanvasRenderer {
|
|||||||
drawSelectionFrame(ctx: any, layer: any) {
|
drawSelectionFrame(ctx: any, layer: any) {
|
||||||
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';
|
|
||||||
ctx.lineWidth = lineWidth;
|
if (layer.cropMode && layer.cropBounds && layer.originalWidth) {
|
||||||
|
// --- CROP MODE ---
|
||||||
|
ctx.lineWidth = lineWidth;
|
||||||
|
|
||||||
|
// 1. Draw dashed blue line for the full transform frame (the "original size" container)
|
||||||
|
ctx.strokeStyle = '#007bff';
|
||||||
|
ctx.setLineDash([8 / this.canvas.viewport.zoom, 8 / this.canvas.viewport.zoom]);
|
||||||
|
ctx.strokeRect(-layer.width / 2, -layer.height / 2, layer.width, layer.height);
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
|
// 2. Draw solid blue line for the crop bounds
|
||||||
|
const layerScaleX = layer.width / layer.originalWidth;
|
||||||
|
const layerScaleY = layer.height / layer.originalHeight;
|
||||||
|
const s = layer.cropBounds;
|
||||||
|
|
||||||
|
const cropRectX = (-layer.width / 2) + (s.x * layerScaleX);
|
||||||
|
const cropRectY = (-layer.height / 2) + (s.y * layerScaleY);
|
||||||
|
const cropRectW = s.width * layerScaleX;
|
||||||
|
const cropRectH = s.height * layerScaleY;
|
||||||
|
|
||||||
|
ctx.strokeStyle = '#007bff'; // Solid blue
|
||||||
|
this.drawAdaptiveLine(ctx, cropRectX, cropRectY, cropRectX + cropRectW, cropRectY, layer); // Top
|
||||||
|
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY, cropRectX + cropRectW, cropRectY + cropRectH, layer); // Right
|
||||||
|
this.drawAdaptiveLine(ctx, cropRectX + cropRectW, cropRectY + cropRectH, cropRectX, cropRectY + cropRectH, layer); // Bottom
|
||||||
|
this.drawAdaptiveLine(ctx, cropRectX, cropRectY + cropRectH, cropRectX, cropRectY, layer); // Left
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// --- TRANSFORM MODE ---
|
||||||
|
ctx.strokeStyle = '#00ff00'; // Green
|
||||||
|
ctx.lineWidth = lineWidth;
|
||||||
|
const halfW = layer.width / 2;
|
||||||
|
const halfH = layer.height / 2;
|
||||||
|
|
||||||
|
// Draw adaptive solid green line for transform frame
|
||||||
|
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
||||||
|
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
||||||
|
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
||||||
|
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
||||||
|
|
||||||
|
// Draw line to rotation handle
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, -halfH);
|
||||||
|
ctx.lineTo(0, -halfH - 20 / this.canvas.viewport.zoom);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
// Rysuj ramkę z adaptacyjnymi liniami (ciągłe/przerywane w zależności od przykrycia)
|
// --- DRAW HANDLES (Unified Logic) ---
|
||||||
const halfW = layer.width / 2;
|
|
||||||
const halfH = layer.height / 2;
|
|
||||||
|
|
||||||
// Górna krawędź
|
|
||||||
this.drawAdaptiveLine(ctx, -halfW, -halfH, halfW, -halfH, layer);
|
|
||||||
// Prawa krawędź
|
|
||||||
this.drawAdaptiveLine(ctx, halfW, -halfH, halfW, halfH, layer);
|
|
||||||
// Dolna krawędź
|
|
||||||
this.drawAdaptiveLine(ctx, halfW, halfH, -halfW, halfH, layer);
|
|
||||||
// Lewa krawędź
|
|
||||||
this.drawAdaptiveLine(ctx, -halfW, halfH, -halfW, -halfH, layer);
|
|
||||||
|
|
||||||
// Rysuj linię do uchwytu rotacji (zawsze ciągła)
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(0, -layer.height / 2);
|
|
||||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// Rysuj uchwyty
|
|
||||||
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;
|
||||||
|
|
||||||
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, we need it in the layer's rotated space
|
||||||
const localX = point.x - (layer.x + layer.width / 2);
|
const localX = point.x - (layer.x + layer.width / 2);
|
||||||
const localY = point.y - (layer.y + layer.height / 2);
|
const localY = point.y - (layer.y + layer.height / 2);
|
||||||
|
|
||||||
@@ -571,6 +599,7 @@ export class CanvasRenderer {
|
|||||||
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
const rotatedX = localX * Math.cos(rad) - localY * Math.sin(rad);
|
||||||
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
const rotatedY = localX * Math.sin(rad) + localY * Math.cos(rad);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
ctx.arc(rotatedX, rotatedY, handleRadius, 0, Math.PI * 2);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|||||||
@@ -326,6 +326,9 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor
|
|||||||
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer: Layer, index: number) => {
|
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer: Layer, index: number) => {
|
||||||
const newLayer: Omit<Layer, 'image'> & { imageId: string } = { ...layer, imageId: layer.imageId || '' };
|
const newLayer: Omit<Layer, 'image'> & { imageId: string } = { ...layer, imageId: layer.imageId || '' };
|
||||||
delete (newLayer as any).image;
|
delete (newLayer as any).image;
|
||||||
|
// Remove cache properties that cannot be serialized for the worker
|
||||||
|
delete (newLayer as any).blendedImageCache;
|
||||||
|
delete (newLayer as any).blendedImageDirty;
|
||||||
|
|
||||||
if (layer.image instanceof HTMLImageElement) {
|
if (layer.image instanceof HTMLImageElement) {
|
||||||
if (layer.imageId) {
|
if (layer.imageId) {
|
||||||
|
|||||||
@@ -33,6 +33,40 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
});
|
});
|
||||||
const imageCache = new ImageCache();
|
const imageCache = new ImageCache();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to update the icon of a switch component.
|
||||||
|
* @param knobIconEl The HTML element for the switch's knob icon.
|
||||||
|
* @param isChecked The current state of the switch (e.g., checkbox.checked).
|
||||||
|
* @param iconToolTrue The icon tool name for the 'true' state.
|
||||||
|
* @param iconToolFalse The icon tool name for the 'false' state.
|
||||||
|
* @param fallbackTrue The text fallback for the 'true' state.
|
||||||
|
* @param fallbackFalse The text fallback for the 'false' state.
|
||||||
|
*/
|
||||||
|
const updateSwitchIcon = (
|
||||||
|
knobIconEl: HTMLElement,
|
||||||
|
isChecked: boolean,
|
||||||
|
iconToolTrue: string,
|
||||||
|
iconToolFalse: string,
|
||||||
|
fallbackTrue: string,
|
||||||
|
fallbackFalse: string
|
||||||
|
) => {
|
||||||
|
if (!knobIconEl) return;
|
||||||
|
|
||||||
|
const iconTool = isChecked ? iconToolTrue : iconToolFalse;
|
||||||
|
const fallbackText = isChecked ? fallbackTrue : fallbackFalse;
|
||||||
|
const icon = iconLoader.getIcon(iconTool);
|
||||||
|
|
||||||
|
knobIconEl.innerHTML = ''; // Clear previous icon
|
||||||
|
if (icon instanceof HTMLImageElement) {
|
||||||
|
const clonedIcon = icon.cloneNode() as HTMLImageElement;
|
||||||
|
clonedIcon.style.width = '20px';
|
||||||
|
clonedIcon.style.height = '20px';
|
||||||
|
knobIconEl.appendChild(clonedIcon);
|
||||||
|
} else {
|
||||||
|
knobIconEl.textContent = fallbackText;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const helpTooltip = $el("div.painter-tooltip", {
|
const helpTooltip = $el("div.painter-tooltip", {
|
||||||
id: `painter-help-tooltip-${node.id}`,
|
id: `painter-help-tooltip-${node.id}`,
|
||||||
}) as HTMLDivElement;
|
}) as HTMLDivElement;
|
||||||
@@ -184,29 +218,31 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
});
|
});
|
||||||
switchEl.addEventListener("mouseleave", hideTooltip);
|
switchEl.addEventListener("mouseleave", hideTooltip);
|
||||||
|
|
||||||
// Dynamic icon and text update on toggle
|
// Dynamic icon update on toggle
|
||||||
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||||
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon') as HTMLElement;
|
const knobIcon = switchEl.querySelector('.switch-knob .switch-icon') as HTMLElement;
|
||||||
|
|
||||||
const updateSwitchView = (isClipspace: boolean) => {
|
input.addEventListener('change', () => {
|
||||||
const iconTool = isClipspace ? LAYERFORGE_TOOLS.CLIPSPACE : LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD;
|
updateSwitchIcon(
|
||||||
const icon = iconLoader.getIcon(iconTool);
|
knobIcon,
|
||||||
if (icon instanceof HTMLImageElement) {
|
input.checked,
|
||||||
knobIcon.innerHTML = '';
|
LAYERFORGE_TOOLS.CLIPSPACE,
|
||||||
const clonedIcon = icon.cloneNode() as HTMLImageElement;
|
LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD,
|
||||||
clonedIcon.style.width = '20px';
|
"🗂️",
|
||||||
clonedIcon.style.height = '20px';
|
"📋"
|
||||||
knobIcon.appendChild(clonedIcon);
|
);
|
||||||
} else {
|
});
|
||||||
knobIcon.textContent = isClipspace ? "🗂️" : "📋";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
input.addEventListener('change', () => updateSwitchView(input.checked));
|
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
iconLoader.preloadToolIcons().then(() => {
|
iconLoader.preloadToolIcons().then(() => {
|
||||||
updateSwitchView(isClipspace);
|
updateSwitchIcon(
|
||||||
|
knobIcon,
|
||||||
|
isClipspace,
|
||||||
|
LAYERFORGE_TOOLS.CLIPSPACE,
|
||||||
|
LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD,
|
||||||
|
"🗂️",
|
||||||
|
"📋"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return switchEl;
|
return switchEl;
|
||||||
@@ -326,6 +362,68 @@ async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp):
|
|||||||
|
|
||||||
$el("div.painter-separator"),
|
$el("div.painter-separator"),
|
||||||
$el("div.painter-button-group", {}, [
|
$el("div.painter-button-group", {}, [
|
||||||
|
(() => {
|
||||||
|
const switchEl = $el("label.clipboard-switch.requires-selection", {
|
||||||
|
id: `crop-transform-switch-${node.id}`,
|
||||||
|
title: "Toggle between Transform and Crop mode for selected layer(s)"
|
||||||
|
}, [
|
||||||
|
$el("input", {
|
||||||
|
type: "checkbox",
|
||||||
|
checked: false,
|
||||||
|
onchange: (e: Event) => {
|
||||||
|
const isCropMode = (e.target as HTMLInputElement).checked;
|
||||||
|
const selectedLayers = canvas.canvasSelection.selectedLayers;
|
||||||
|
if (selectedLayers.length === 0) return;
|
||||||
|
|
||||||
|
selectedLayers.forEach((layer: Layer) => {
|
||||||
|
layer.cropMode = isCropMode;
|
||||||
|
if (isCropMode && !layer.cropBounds) {
|
||||||
|
layer.cropBounds = { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.saveState();
|
||||||
|
canvas.render();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
$el("span.switch-track"),
|
||||||
|
$el("span.switch-labels", { style: { fontSize: "11px" } }, [
|
||||||
|
$el("span.text-clipspace", {}, ["Crop"]),
|
||||||
|
$el("span.text-system", {}, ["Transform"])
|
||||||
|
]),
|
||||||
|
$el("span.switch-knob", {}, [
|
||||||
|
$el("span.switch-icon", { id: `crop-transform-icon-${node.id}`})
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
|
||||||
|
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||||
|
const knobIcon = switchEl.querySelector('.switch-icon') as HTMLElement;
|
||||||
|
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
updateSwitchIcon(
|
||||||
|
knobIcon,
|
||||||
|
input.checked,
|
||||||
|
LAYERFORGE_TOOLS.CROP,
|
||||||
|
LAYERFORGE_TOOLS.TRANSFORM,
|
||||||
|
"✂️",
|
||||||
|
"✥"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
iconLoader.preloadToolIcons().then(() => {
|
||||||
|
updateSwitchIcon(
|
||||||
|
knobIcon,
|
||||||
|
false, // Initial state is transform
|
||||||
|
LAYERFORGE_TOOLS.CROP,
|
||||||
|
LAYERFORGE_TOOLS.TRANSFORM,
|
||||||
|
"✂️",
|
||||||
|
"✥"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return switchEl;
|
||||||
|
})(),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Rotate +90°",
|
textContent: "Rotate +90°",
|
||||||
title: "Rotate selected layer(s) by +90 degrees",
|
title: "Rotate selected layer(s) by +90 degrees",
|
||||||
@@ -672,18 +770,50 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
const updateButtonStates = () => {
|
const updateButtonStates = () => {
|
||||||
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
const selectionCount = canvas.canvasSelection.selectedLayers.length;
|
||||||
const hasSelection = selectionCount > 0;
|
const hasSelection = selectionCount > 0;
|
||||||
controlPanel.querySelectorAll('.requires-selection').forEach((btn: any) => {
|
|
||||||
const button = btn as HTMLButtonElement;
|
// --- Handle Standard Buttons ---
|
||||||
if (button.textContent === 'Fuse') {
|
controlPanel.querySelectorAll('.requires-selection').forEach((el: any) => {
|
||||||
button.disabled = selectionCount < 2;
|
if (el.tagName === 'BUTTON') {
|
||||||
} else {
|
if (el.textContent === 'Fuse') {
|
||||||
button.disabled = !hasSelection;
|
el.disabled = selectionCount < 2;
|
||||||
|
} else {
|
||||||
|
el.disabled = !hasSelection;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const mattingBtn = controlPanel.querySelector('.matting-button') as HTMLButtonElement;
|
const mattingBtn = controlPanel.querySelector('.matting-button') as HTMLButtonElement;
|
||||||
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
|
if (mattingBtn && !mattingBtn.classList.contains('loading')) {
|
||||||
mattingBtn.disabled = selectionCount !== 1;
|
mattingBtn.disabled = selectionCount !== 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Handle Crop/Transform Switch ---
|
||||||
|
const switchEl = controlPanel.querySelector(`#crop-transform-switch-${node.id}`) as HTMLLabelElement;
|
||||||
|
if (switchEl) {
|
||||||
|
const input = switchEl.querySelector('input') as HTMLInputElement;
|
||||||
|
const knobIcon = switchEl.querySelector('.switch-icon') as HTMLElement;
|
||||||
|
|
||||||
|
const isDisabled = !hasSelection;
|
||||||
|
switchEl.classList.toggle('disabled', isDisabled);
|
||||||
|
input.disabled = isDisabled;
|
||||||
|
|
||||||
|
if (!isDisabled) {
|
||||||
|
const isCropMode = canvas.canvasSelection.selectedLayers[0].cropMode || false;
|
||||||
|
if (input.checked !== isCropMode) {
|
||||||
|
input.checked = isCropMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update icon view
|
||||||
|
updateSwitchIcon(
|
||||||
|
knobIcon,
|
||||||
|
isCropMode,
|
||||||
|
LAYERFORGE_TOOLS.CROP,
|
||||||
|
LAYERFORGE_TOOLS.TRANSFORM,
|
||||||
|
"✂️",
|
||||||
|
"✥"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
canvas.canvasSelection.onSelectionChange = updateButtonStates;
|
canvas.canvasSelection.onSelectionChange = updateButtonStates;
|
||||||
|
|||||||
@@ -51,6 +51,32 @@
|
|||||||
border-color: #3a76d6;
|
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 {
|
.painter-button.success {
|
||||||
border-color: #4ae27a;
|
border-color: #4ae27a;
|
||||||
background-color: #444;
|
background-color: #444;
|
||||||
@@ -306,6 +332,20 @@
|
|||||||
opacity: 0;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.painter-separator {
|
.painter-separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ export interface Layer {
|
|||||||
flipH?: boolean;
|
flipH?: boolean;
|
||||||
flipV?: boolean;
|
flipV?: boolean;
|
||||||
blendArea?: number;
|
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
|
||||||
|
};
|
||||||
|
blendedImageCache?: HTMLCanvasElement; // Cache for the pre-rendered blendArea effect
|
||||||
|
blendedImageDirty?: boolean; // Flag to invalidate the cache
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComfyNode {
|
export interface ComfyNode {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const LAYERFORGE_TOOLS = {
|
|||||||
DELETE: 'delete',
|
DELETE: 'delete',
|
||||||
DUPLICATE: 'duplicate',
|
DUPLICATE: 'duplicate',
|
||||||
BLEND_MODE: 'blend_mode',
|
BLEND_MODE: 'blend_mode',
|
||||||
OPACITY: 'opacity',
|
OPACITY: 'opacity',
|
||||||
MASK: 'mask',
|
MASK: 'mask',
|
||||||
BRUSH: 'brush',
|
BRUSH: 'brush',
|
||||||
ERASER: 'eraser',
|
ERASER: 'eraser',
|
||||||
@@ -21,16 +21,22 @@ export const LAYERFORGE_TOOLS = {
|
|||||||
SETTINGS: 'settings',
|
SETTINGS: 'settings',
|
||||||
SYSTEM_CLIPBOARD: 'system_clipboard',
|
SYSTEM_CLIPBOARD: 'system_clipboard',
|
||||||
CLIPSPACE: 'clipspace',
|
CLIPSPACE: 'clipspace',
|
||||||
|
CROP: 'crop',
|
||||||
|
TRANSFORM: 'transform',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// SVG Icons for LayerForge tools
|
// 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 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 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 = {
|
const LAYERFORGE_TOOL_ICONS = {
|
||||||
[LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD]: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SYSTEM_CLIPBOARD_ICON_SVG)}`,
|
[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.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.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.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>')}`,
|
||||||
@@ -72,7 +78,9 @@ const LAYERFORGE_TOOL_COLORS = {
|
|||||||
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
|
[LAYERFORGE_TOOLS.BRUSH]: '#4285F4',
|
||||||
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
|
[LAYERFORGE_TOOLS.ERASER]: '#FBBC05',
|
||||||
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
|
[LAYERFORGE_TOOLS.SHAPE]: '#FF6D01',
|
||||||
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292'
|
[LAYERFORGE_TOOLS.SETTINGS]: '#F06292',
|
||||||
|
[LAYERFORGE_TOOLS.CROP]: '#EA4335',
|
||||||
|
[LAYERFORGE_TOOLS.TRANSFORM]: '#34A853',
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IconCache {
|
export interface IconCache {
|
||||||
|
|||||||
Reference in New Issue
Block a user