mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-26 06:45:44 -03:00
Initial commit
Project scaffolding and initial file setup.
This commit is contained in:
@@ -168,6 +168,7 @@ class CanvasNode:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}),
|
"fit_on_add": ("BOOLEAN", {"default": False, "label_on": "Fit on Add/Paste", "label_off": "Default Behavior"}),
|
||||||
|
"show_preview": ("BOOLEAN", {"default": False, "label_on": "Show Preview", "label_off": "Hide Preview"}),
|
||||||
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}),
|
"trigger": ("INT", {"default": 0, "min": 0, "max": 99999999, "step": 1, "hidden": True}),
|
||||||
"node_id": ("STRING", {"default": "0", "hidden": True}),
|
"node_id": ("STRING", {"default": "0", "hidden": True}),
|
||||||
},
|
},
|
||||||
@@ -231,7 +232,7 @@ class CanvasNode:
|
|||||||
|
|
||||||
_processing_lock = threading.Lock()
|
_processing_lock = threading.Lock()
|
||||||
|
|
||||||
def process_canvas_image(self, fit_on_add, trigger, node_id, prompt=None, unique_id=None):
|
def process_canvas_image(self, fit_on_add, show_preview, trigger, node_id, prompt=None, unique_id=None):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
@@ -470,6 +471,70 @@ class CanvasNode:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
@PromptServer.instance.routes.post("/ycnode/load_image_from_path")
|
||||||
|
async def load_image_from_path_route(request):
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
file_path = data.get('file_path')
|
||||||
|
|
||||||
|
if not file_path:
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': 'file_path is required'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
log_info(f"Attempting to load image from path: {file_path}")
|
||||||
|
|
||||||
|
# Check if file exists and is accessible
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
log_warn(f"File not found: {file_path}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f'File not found: {file_path}'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
# Check if it's an image file
|
||||||
|
valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.avif')
|
||||||
|
if not file_path.lower().endswith(valid_extensions):
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Invalid image file extension. Supported: {valid_extensions}'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# Try to load and convert the image
|
||||||
|
try:
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
# Convert to RGB if necessary
|
||||||
|
if img.mode != 'RGB':
|
||||||
|
img = img.convert('RGB')
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
buffered = io.BytesIO()
|
||||||
|
img.save(buffered, format="PNG")
|
||||||
|
img_str = base64.b64encode(buffered.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
log_info(f"Successfully loaded image from path: {file_path}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': True,
|
||||||
|
'image_data': f"data:image/png;base64,{img_str}",
|
||||||
|
'width': img.width,
|
||||||
|
'height': img.height
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as img_error:
|
||||||
|
log_error(f"Error processing image file {file_path}: {str(img_error)}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Error processing image file: {str(img_error)}'
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_error(f"Error in load_image_from_path_route: {str(e)}")
|
||||||
|
return web.json_response({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|
||||||
def store_image(self, image_data):
|
def store_image(self, image_data):
|
||||||
|
|
||||||
if isinstance(image_data, str) and image_data.startswith('data:image'):
|
if isinstance(image_data, str) and image_data.startswith('data:image'):
|
||||||
|
|||||||
1106
js/Canvas.js
1106
js/Canvas.js
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@ export class CanvasIO {
|
|||||||
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
|
log.warn(`Node ${this.canvas.node.id} has no layers, creating empty canvas`);
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
await this.canvas.saveStateToDB(true);
|
await this.canvas.canvasState.saveStateToDB(true);
|
||||||
const nodeId = this.canvas.node.id;
|
const nodeId = this.canvas.node.id;
|
||||||
const delay = (nodeId % 10) * 50;
|
const delay = (nodeId % 10) * 50;
|
||||||
if (delay > 0) {
|
if (delay > 0) {
|
||||||
@@ -102,7 +102,7 @@ export class CanvasIO {
|
|||||||
const tempMaskCanvas = document.createElement('canvas');
|
const tempMaskCanvas = document.createElement('canvas');
|
||||||
tempMaskCanvas.width = this.canvas.width;
|
tempMaskCanvas.width = this.canvas.width;
|
||||||
tempMaskCanvas.height = this.canvas.height;
|
tempMaskCanvas.height = this.canvas.height;
|
||||||
const tempMaskCtx = tempMaskCanvas.getContext('2d');
|
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ export class CanvasIO {
|
|||||||
const tempMaskCanvas = document.createElement('canvas');
|
const tempMaskCanvas = document.createElement('canvas');
|
||||||
tempMaskCanvas.width = this.canvas.width;
|
tempMaskCanvas.width = this.canvas.width;
|
||||||
tempMaskCanvas.height = this.canvas.height;
|
tempMaskCanvas.height = this.canvas.height;
|
||||||
const tempMaskCtx = tempMaskCanvas.getContext('2d');
|
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ export class CanvasIO {
|
|||||||
this.canvas.height / inputImage.height * 0.8
|
this.canvas.height / inputImage.height * 0.8
|
||||||
);
|
);
|
||||||
|
|
||||||
const layer = await this.canvas.addLayerWithImage(image, {
|
const layer = await this.canvas.canvasLayers.addLayerWithImage(image, {
|
||||||
x: (this.canvas.width - inputImage.width * scale) / 2,
|
x: (this.canvas.width - inputImage.width * scale) / 2,
|
||||||
y: (this.canvas.height - inputImage.height * scale) / 2,
|
y: (this.canvas.height - inputImage.height * scale) / 2,
|
||||||
width: inputImage.width * scale,
|
width: inputImage.width * scale,
|
||||||
@@ -403,7 +403,7 @@ export class CanvasIO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
canvas.width = tensor.width;
|
canvas.width = tensor.width;
|
||||||
canvas.height = tensor.height;
|
canvas.height = tensor.height;
|
||||||
|
|
||||||
@@ -611,7 +611,7 @@ export class CanvasIO {
|
|||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = imageData.width;
|
canvas.width = imageData.width;
|
||||||
canvas.height = imageData.height;
|
canvas.height = imageData.height;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
ctx.putImageData(imageData, 0, 0);
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -684,7 +684,7 @@ export class CanvasIO {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = img.width;
|
tempCanvas.width = img.width;
|
||||||
tempCanvas.height = img.height;
|
tempCanvas.height = img.height;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
tempCtx.drawImage(img, 0, 0);
|
tempCtx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
@@ -693,7 +693,7 @@ export class CanvasIO {
|
|||||||
const maskCanvas = document.createElement('canvas');
|
const maskCanvas = document.createElement('canvas');
|
||||||
maskCanvas.width = img.width;
|
maskCanvas.width = img.width;
|
||||||
maskCanvas.height = img.height;
|
maskCanvas.height = img.height;
|
||||||
const maskCtx = maskCanvas.getContext('2d');
|
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
maskCtx.drawImage(mask, 0, 0);
|
maskCtx.drawImage(mask, 0, 0);
|
||||||
const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
|
const maskData = maskCtx.getImageData(0, 0, img.width, img.height);
|
||||||
|
|
||||||
@@ -744,7 +744,7 @@ export class CanvasIO {
|
|||||||
img.src = result.image_data;
|
img.src = result.image_data;
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.canvas.addLayerWithImage(img, {
|
await this.canvas.canvasLayers.addLayerWithImage(img, {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: this.canvas.width,
|
width: this.canvas.width,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export class CanvasInteractions {
|
|||||||
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
|
this.canvas.canvas.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||||
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
|
this.canvas.canvas.addEventListener('keyup', this.handleKeyUp.bind(this));
|
||||||
|
|
||||||
|
document.addEventListener('paste', this.handlePasteEvent.bind(this));
|
||||||
|
|
||||||
this.canvas.canvas.addEventListener('mouseenter', (e) => {
|
this.canvas.canvas.addEventListener('mouseenter', (e) => {
|
||||||
this.canvas.isMouseOver = true;
|
this.canvas.isMouseOver = true;
|
||||||
this.handleMouseEnter(e);
|
this.handleMouseEnter(e);
|
||||||
@@ -42,6 +44,13 @@ export class CanvasInteractions {
|
|||||||
this.canvas.isMouseOver = false;
|
this.canvas.isMouseOver = false;
|
||||||
this.handleMouseLeave(e);
|
this.handleMouseLeave(e);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.canvas.canvas.addEventListener('dragover', this.handleDragOver.bind(this));
|
||||||
|
this.canvas.canvas.addEventListener('dragenter', this.handleDragEnter.bind(this));
|
||||||
|
this.canvas.canvas.addEventListener('dragleave', this.handleDragLeave.bind(this));
|
||||||
|
this.canvas.canvas.addEventListener('drop', this.handleDrop.bind(this));
|
||||||
|
|
||||||
|
this.canvas.canvas.addEventListener('contextmenu', this.handleContextMenu.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
resetInteractionState() {
|
resetInteractionState() {
|
||||||
@@ -86,26 +95,34 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
this.interaction.lastClickTime = currentTime;
|
this.interaction.lastClickTime = currentTime;
|
||||||
|
|
||||||
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
if (e.button === 2) {
|
||||||
|
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
||||||
|
if (clickedLayerResult && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
|
||||||
|
e.preventDefault(); // Prevent context menu
|
||||||
|
this.canvas.canvasLayers.showBlendModeMenu(viewCoords.x ,viewCoords.y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
this.startCanvasResize(worldCoords);
|
||||||
|
this.canvas.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||||
if (transformTarget) {
|
if (transformTarget) {
|
||||||
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
|
this.startLayerTransform(transformTarget.layer, transformTarget.handle, worldCoords);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clickedLayerResult = this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
const clickedLayerResult = this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y);
|
||||||
if (clickedLayerResult) {
|
if (clickedLayerResult) {
|
||||||
if (e.shiftKey && this.canvas.selectedLayers.includes(clickedLayerResult.layer)) {
|
|
||||||
this.canvas.showBlendModeMenu(e.clientX, e.clientY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.startLayerDrag(clickedLayerResult.layer, worldCoords);
|
this.startLayerDrag(clickedLayerResult.layer, worldCoords);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.shiftKey) {
|
|
||||||
this.startCanvasResize(worldCoords);
|
|
||||||
} else {
|
|
||||||
this.startPanning(e);
|
this.startPanning(e);
|
||||||
}
|
|
||||||
|
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
@@ -176,7 +193,7 @@ export class CanvasInteractions {
|
|||||||
|
|
||||||
if (interactionEnded) {
|
if (interactionEnded) {
|
||||||
this.canvas.saveState();
|
this.canvas.saveState();
|
||||||
this.canvas.saveStateToDB(true);
|
this.canvas.canvasState.saveStateToDB(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +211,11 @@ export class CanvasInteractions {
|
|||||||
this.resetInteractionState();
|
this.resetInteractionState();
|
||||||
this.canvas.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||||
|
this.canvas.canvasLayers.internalClipboard = [];
|
||||||
|
log.info("Internal clipboard cleared - mouse left canvas");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMouseEnter(e) {
|
handleMouseEnter(e) {
|
||||||
@@ -202,6 +224,11 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleContextMenu(e) {
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
handleWheel(e) {
|
handleWheel(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (this.canvas.maskTool.isActive) {
|
if (this.canvas.maskTool.isActive) {
|
||||||
@@ -297,16 +324,16 @@ export class CanvasInteractions {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
this.canvas.redo();
|
this.canvas.canvasState.redo();
|
||||||
} else {
|
} else {
|
||||||
this.canvas.undo();
|
this.canvas.canvasState.undo();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key.toLowerCase() === 'y') {
|
if (e.key.toLowerCase() === 'y') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.canvas.redo();
|
this.canvas.canvasState.redo();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,30 +351,27 @@ export class CanvasInteractions {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
this.canvas.redo();
|
this.canvas.canvasState.redo();
|
||||||
} else {
|
} else {
|
||||||
this.canvas.undo();
|
this.canvas.canvasState.undo();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key.toLowerCase() === 'y') {
|
if (e.key.toLowerCase() === 'y') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.canvas.redo();
|
this.canvas.canvasState.redo();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key.toLowerCase() === 'c') {
|
if (e.key.toLowerCase() === 'c') {
|
||||||
if (this.canvas.selectedLayers.length > 0) {
|
if (this.canvas.selectedLayers.length > 0) {
|
||||||
e.preventDefault();
|
this.canvas.canvasLayers.copySelectedLayers();
|
||||||
e.stopPropagation();
|
|
||||||
this.canvas.copySelectedLayers();
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key.toLowerCase() === 'v') {
|
if (e.key.toLowerCase() === 'v') {
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.canvas.handlePaste('mouse');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,7 +423,7 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateCursor(worldCoords) {
|
updateCursor(worldCoords) {
|
||||||
const transformTarget = this.canvas.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
const transformTarget = this.canvas.canvasLayers.getHandleAtPosition(worldCoords.x, worldCoords.y);
|
||||||
|
|
||||||
if (transformTarget) {
|
if (transformTarget) {
|
||||||
const handleName = transformTarget.handle;
|
const handleName = transformTarget.handle;
|
||||||
@@ -409,7 +433,7 @@ export class CanvasInteractions {
|
|||||||
'rot': 'grab'
|
'rot': 'grab'
|
||||||
};
|
};
|
||||||
this.canvas.canvas.style.cursor = cursorMap[handleName];
|
this.canvas.canvas.style.cursor = cursorMap[handleName];
|
||||||
} else if (this.canvas.getLayerAtPosition(worldCoords.x, worldCoords.y)) {
|
} else if (this.canvas.canvasLayers.getLayerAtPosition(worldCoords.x, worldCoords.y)) {
|
||||||
this.canvas.canvas.style.cursor = 'move';
|
this.canvas.canvas.style.cursor = 'move';
|
||||||
} else {
|
} else {
|
||||||
this.canvas.canvas.style.cursor = 'default';
|
this.canvas.canvas.style.cursor = 'default';
|
||||||
@@ -432,7 +456,7 @@ export class CanvasInteractions {
|
|||||||
} else {
|
} else {
|
||||||
this.interaction.mode = 'resizing';
|
this.interaction.mode = 'resizing';
|
||||||
this.interaction.resizeHandle = handle;
|
this.interaction.resizeHandle = handle;
|
||||||
const handles = this.canvas.getHandles(layer);
|
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||||
const oppositeHandleKey = {
|
const oppositeHandleKey = {
|
||||||
'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
|
'n': 's', 's': 'n', 'e': 'w', 'w': 'e',
|
||||||
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
|
'nw': 'se', 'se': 'nw', 'ne': 'sw', 'sw': 'ne'
|
||||||
@@ -712,4 +736,130 @@ export class CanvasInteractions {
|
|||||||
this.canvas.viewport.y -= rectY;
|
this.canvas.viewport.y -= rectY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDragEnter(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||||
|
this.canvas.canvas.style.backgroundColor = 'rgba(45, 90, 160, 0.1)';
|
||||||
|
this.canvas.canvas.style.border = '2px dashed #2d5aa0';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDragLeave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // Prevent ComfyUI from handling this event
|
||||||
|
|
||||||
|
if (!this.canvas.canvas.contains(e.relatedTarget)) {
|
||||||
|
this.canvas.canvas.style.backgroundColor = '';
|
||||||
|
this.canvas.canvas.style.border = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // CRITICAL: Prevent ComfyUI from handling this event and loading workflow
|
||||||
|
|
||||||
|
log.info("Canvas drag & drop event intercepted - preventing ComfyUI workflow loading");
|
||||||
|
|
||||||
|
this.canvas.canvas.style.backgroundColor = '';
|
||||||
|
this.canvas.canvas.style.border = '';
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
|
||||||
|
|
||||||
|
log.info(`Dropped ${files.length} file(s) onto canvas at position (${worldCoords.x}, ${worldCoords.y})`);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
try {
|
||||||
|
await this.loadDroppedImageFile(file, worldCoords);
|
||||||
|
log.info(`Successfully loaded dropped image: ${file.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
log.error(`Failed to load dropped image ${file.name}:`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn(`Skipped non-image file: ${file.name} (${file.type})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDroppedImageFile(file, worldCoords) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
|
||||||
|
const fitOnAddWidget = this.canvas.node.widgets.find(w => w.name === "fit_on_add");
|
||||||
|
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
|
||||||
|
|
||||||
|
await this.canvas.addLayer(img, {}, addMode);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
log.error(`Failed to load dropped image: ${file.name}`);
|
||||||
|
};
|
||||||
|
img.src = e.target.result;
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
log.error(`Failed to read dropped file: ${file.name}`);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePasteEvent(e) {
|
||||||
|
|
||||||
|
const shouldHandle = this.canvas.isMouseOver ||
|
||||||
|
this.canvas.canvas.contains(document.activeElement) ||
|
||||||
|
document.activeElement === this.canvas.canvas ||
|
||||||
|
document.activeElement === document.body;
|
||||||
|
|
||||||
|
if (!shouldHandle) {
|
||||||
|
log.debug("Paste event ignored - not focused on canvas");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Paste event detected, checking clipboard preference");
|
||||||
|
|
||||||
|
const preference = this.canvas.canvasLayers.clipboardPreference;
|
||||||
|
|
||||||
|
if (preference === 'clipspace') {
|
||||||
|
|
||||||
|
log.info("Clipboard preference is clipspace, delegating to ClipboardManager");
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipboardData = e.clipboardData;
|
||||||
|
if (clipboardData && clipboardData.items) {
|
||||||
|
for (const item of clipboardData.items) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
log.info("Found direct image data in paste event");
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (event) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'mouse');
|
||||||
|
};
|
||||||
|
img.src = event.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.canvas.canvasLayers.clipboardManager.handlePaste('mouse', preference);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import {saveImage, removeImage} from "./db.js";
|
|||||||
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
import {createModuleLogger} from "./utils/LoggerUtils.js";
|
||||||
import {generateUUID, generateUniqueFileName} from "./utils/CommonUtils.js";
|
import {generateUUID, generateUniqueFileName} from "./utils/CommonUtils.js";
|
||||||
import {withErrorHandling, createValidationError} from "./ErrorHandler.js";
|
import {withErrorHandling, createValidationError} from "./ErrorHandler.js";
|
||||||
|
import {app, ComfyApp} from "../../scripts/app.js";
|
||||||
|
import {ClipboardManager} from "./utils/ClipboardManager.js";
|
||||||
|
|
||||||
const log = createModuleLogger('CanvasLayers');
|
const log = createModuleLogger('CanvasLayers');
|
||||||
|
|
||||||
export class CanvasLayers {
|
export class CanvasLayers {
|
||||||
constructor(canvasLayers) {
|
constructor(canvas) {
|
||||||
this.canvasLayers = canvasLayers;
|
this.canvas = canvas;
|
||||||
|
this.clipboardManager = new ClipboardManager(canvas);
|
||||||
this.blendModes = [
|
this.blendModes = [
|
||||||
{name: 'normal', label: 'Normal'},
|
{name: 'normal', label: 'Normal'},
|
||||||
{name: 'multiply', label: 'Multiply'},
|
{name: 'multiply', label: 'Multiply'},
|
||||||
@@ -26,85 +29,120 @@ export class CanvasLayers {
|
|||||||
this.blendOpacity = 100;
|
this.blendOpacity = 100;
|
||||||
this.isAdjustingOpacity = false;
|
this.isAdjustingOpacity = false;
|
||||||
this.internalClipboard = [];
|
this.internalClipboard = [];
|
||||||
|
this.clipboardPreference = 'system'; // 'system', 'clipspace'
|
||||||
}
|
}
|
||||||
|
|
||||||
async copySelectedLayers() {
|
async copySelectedLayers() {
|
||||||
if (this.canvasLayers.selectedLayers.length === 0) return;
|
if (this.canvas.selectedLayers.length === 0) return;
|
||||||
this.internalClipboard = this.canvasLayers.selectedLayers.map(layer => ({...layer}));
|
|
||||||
|
this.internalClipboard = this.canvas.selectedLayers.map(layer => ({...layer}));
|
||||||
log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`);
|
log.info(`Copied ${this.internalClipboard.length} layer(s) to internal clipboard.`);
|
||||||
try {
|
|
||||||
const blob = await this.getFlattenedSelectionAsBlob();
|
const blob = await this.getFlattenedSelectionAsBlob();
|
||||||
if (blob) {
|
if (!blob) {
|
||||||
|
log.warn("Failed to create flattened selection blob");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.clipboardPreference === 'clipspace') {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const dataURL = await new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
|
||||||
|
if (this.canvas.node.imgs) {
|
||||||
|
this.canvas.node.imgs = [img];
|
||||||
|
} else {
|
||||||
|
this.canvas.node.imgs = [img];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ComfyApp.copyToClipspace) {
|
||||||
|
ComfyApp.copyToClipspace(this.canvas.node);
|
||||||
|
log.info("Flattened selection copied to ComfyUI Clipspace.");
|
||||||
|
} else {
|
||||||
|
log.warn("ComfyUI copyToClipspace not available");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.src = dataURL;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to copy image to ComfyUI Clipspace:", error);
|
||||||
|
|
||||||
|
try {
|
||||||
const item = new ClipboardItem({'image/png': blob});
|
const item = new ClipboardItem({'image/png': blob});
|
||||||
await navigator.clipboard.write([item]);
|
await navigator.clipboard.write([item]);
|
||||||
log.info("Flattened selection copied to the system clipboard.");
|
log.info("Fallback: Flattened selection copied to system clipboard.");
|
||||||
|
} catch (fallbackError) {
|
||||||
|
log.error("Failed to copy to system clipboard as fallback:", fallbackError);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
try {
|
||||||
|
const item = new ClipboardItem({'image/png': blob});
|
||||||
|
await navigator.clipboard.write([item]);
|
||||||
|
log.info("Flattened selection copied to system clipboard.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("Failed to copy image to system clipboard:", error);
|
log.error("Failed to copy image to system clipboard:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pasteLayers() {
|
pasteLayers() {
|
||||||
if (this.internalClipboard.length === 0) return;
|
if (this.internalClipboard.length === 0) return;
|
||||||
this.canvasLayers.saveState();
|
this.canvas.saveState();
|
||||||
const newLayers = [];
|
const newLayers = [];
|
||||||
const pasteOffset = 20;
|
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
this.internalClipboard.forEach(layer => {
|
||||||
|
minX = Math.min(minX, layer.x);
|
||||||
|
minY = Math.min(minY, layer.y);
|
||||||
|
maxX = Math.max(maxX, layer.x + layer.width);
|
||||||
|
maxY = Math.max(maxY, layer.y + layer.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
const centerX = (minX + maxX) / 2;
|
||||||
|
const centerY = (minY + maxY) / 2;
|
||||||
|
|
||||||
|
const mouseX = this.canvas.lastMousePosition.x;
|
||||||
|
const mouseY = this.canvas.lastMousePosition.y;
|
||||||
|
const offsetX = mouseX - centerX;
|
||||||
|
const offsetY = mouseY - centerY;
|
||||||
|
|
||||||
this.internalClipboard.forEach(clipboardLayer => {
|
this.internalClipboard.forEach(clipboardLayer => {
|
||||||
const newLayer = {
|
const newLayer = {
|
||||||
...clipboardLayer,
|
...clipboardLayer,
|
||||||
x: clipboardLayer.x + pasteOffset / this.canvasLayers.viewport.zoom,
|
x: clipboardLayer.x + offsetX,
|
||||||
y: clipboardLayer.y + pasteOffset / this.canvasLayers.viewport.zoom,
|
y: clipboardLayer.y + offsetY,
|
||||||
zIndex: this.canvasLayers.layers.length
|
zIndex: this.canvas.layers.length
|
||||||
};
|
};
|
||||||
this.canvasLayers.layers.push(newLayer);
|
this.canvas.layers.push(newLayer);
|
||||||
newLayers.push(newLayer);
|
newLayers.push(newLayer);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.canvasLayers.updateSelection(newLayers);
|
this.canvas.updateSelection(newLayers);
|
||||||
this.canvasLayers.render();
|
this.canvas.render();
|
||||||
log.info(`Pasted ${newLayers.length} layer(s).`);
|
log.info(`Pasted ${newLayers.length} layer(s) at mouse position (${mouseX}, ${mouseY}).`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePaste(addMode = 'mouse') {
|
async handlePaste(addMode = 'mouse') {
|
||||||
try {
|
try {
|
||||||
if (!navigator.clipboard?.read) {
|
log.info(`Paste operation started with preference: ${this.clipboardPreference}`);
|
||||||
log.info("Browser does not support clipboard read API. Falling back to internal paste.");
|
|
||||||
this.pasteLayers();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clipboardItems = await navigator.clipboard.read();
|
await this.clipboardManager.handlePaste(addMode, this.clipboardPreference);
|
||||||
let imagePasted = false;
|
|
||||||
|
|
||||||
for (const item of clipboardItems) {
|
|
||||||
const imageType = item.types.find(type => type.startsWith('image/'));
|
|
||||||
|
|
||||||
if (imageType) {
|
|
||||||
const blob = await item.getType(imageType);
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = async () => {
|
|
||||||
await this.addLayerWithImage(img, {}, addMode);
|
|
||||||
};
|
|
||||||
img.src = event.target.result;
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
imagePasted = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!imagePasted) {
|
|
||||||
this.pasteLayers();
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Paste operation failed, falling back to internal paste. Error:", err);
|
log.error("Paste operation failed:", err);
|
||||||
this.pasteLayers();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default') => {
|
addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default') => {
|
||||||
if (!image) {
|
if (!image) {
|
||||||
throw createValidationError("Image is required for layer creation");
|
throw createValidationError("Image is required for layer creation");
|
||||||
@@ -113,24 +151,24 @@ export class CanvasLayers {
|
|||||||
log.debug("Adding layer with image:", image, "with mode:", addMode);
|
log.debug("Adding layer with image:", image, "with mode:", addMode);
|
||||||
const imageId = generateUUID();
|
const imageId = generateUUID();
|
||||||
await saveImage(imageId, image.src);
|
await saveImage(imageId, image.src);
|
||||||
this.canvasLayers.imageCache.set(imageId, image.src);
|
this.canvas.imageCache.set(imageId, image.src);
|
||||||
|
|
||||||
let finalWidth = image.width;
|
let finalWidth = image.width;
|
||||||
let finalHeight = image.height;
|
let finalHeight = image.height;
|
||||||
let finalX, finalY;
|
let finalX, finalY;
|
||||||
|
|
||||||
if (addMode === 'fit') {
|
if (addMode === 'fit') {
|
||||||
const scale = Math.min(this.canvasLayers.width / image.width, this.canvasLayers.height / image.height);
|
const scale = Math.min(this.canvas.width / image.width, this.canvas.height / image.height);
|
||||||
finalWidth = image.width * scale;
|
finalWidth = image.width * scale;
|
||||||
finalHeight = image.height * scale;
|
finalHeight = image.height * scale;
|
||||||
finalX = (this.canvasLayers.width - finalWidth) / 2;
|
finalX = (this.canvas.width - finalWidth) / 2;
|
||||||
finalY = (this.canvasLayers.height - finalHeight) / 2;
|
finalY = (this.canvas.height - finalHeight) / 2;
|
||||||
} else if (addMode === 'mouse') {
|
} else if (addMode === 'mouse') {
|
||||||
finalX = this.canvasLayers.lastMousePosition.x - finalWidth / 2;
|
finalX = this.canvas.lastMousePosition.x - finalWidth / 2;
|
||||||
finalY = this.canvasLayers.lastMousePosition.y - finalHeight / 2;
|
finalY = this.canvas.lastMousePosition.y - finalHeight / 2;
|
||||||
} else { // 'center' or 'default'
|
} else { // 'center' or 'default'
|
||||||
finalX = (this.canvasLayers.width - finalWidth) / 2;
|
finalX = (this.canvas.width - finalWidth) / 2;
|
||||||
finalY = (this.canvasLayers.height - finalHeight) / 2;
|
finalY = (this.canvas.height - finalHeight) / 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const layer = {
|
const layer = {
|
||||||
@@ -143,16 +181,16 @@ export class CanvasLayers {
|
|||||||
originalWidth: image.width,
|
originalWidth: image.width,
|
||||||
originalHeight: image.height,
|
originalHeight: image.height,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
zIndex: this.canvasLayers.layers.length,
|
zIndex: this.canvas.layers.length,
|
||||||
blendMode: 'normal',
|
blendMode: 'normal',
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
...layerProps
|
...layerProps
|
||||||
};
|
};
|
||||||
|
|
||||||
this.canvasLayers.layers.push(layer);
|
this.canvas.layers.push(layer);
|
||||||
this.canvasLayers.updateSelection([layer]);
|
this.canvas.updateSelection([layer]);
|
||||||
this.canvasLayers.render();
|
this.canvas.render();
|
||||||
this.canvasLayers.saveState();
|
this.canvas.saveState();
|
||||||
|
|
||||||
log.info("Layer added successfully");
|
log.info("Layer added successfully");
|
||||||
return layer;
|
return layer;
|
||||||
@@ -162,53 +200,27 @@ export class CanvasLayers {
|
|||||||
return this.addLayerWithImage(image);
|
return this.addLayerWithImage(image);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeLayer(index) {
|
|
||||||
if (index >= 0 && index < this.canvasLayers.layers.length) {
|
|
||||||
const layer = this.canvasLayers.layers[index];
|
|
||||||
if (layer.imageId) {
|
|
||||||
const isImageUsedElsewhere = this.canvasLayers.layers.some((l, i) => i !== index && l.imageId === layer.imageId);
|
|
||||||
if (!isImageUsedElsewhere) {
|
|
||||||
await removeImage(layer.imageId);
|
|
||||||
this.canvasLayers.imageCache.delete(layer.imageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.canvasLayers.layers.splice(index, 1);
|
|
||||||
this.canvasLayers.selectedLayer = this.canvasLayers.layers[this.canvasLayers.layers.length - 1] || null;
|
|
||||||
this.canvasLayers.render();
|
|
||||||
this.canvasLayers.saveState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
moveLayer(fromIndex, toIndex) {
|
|
||||||
if (fromIndex >= 0 && fromIndex < this.canvasLayers.layers.length &&
|
|
||||||
toIndex >= 0 && toIndex < this.canvasLayers.layers.length) {
|
|
||||||
const layer = this.canvasLayers.layers.splice(fromIndex, 1)[0];
|
|
||||||
this.canvasLayers.layers.splice(toIndex, 0, layer);
|
|
||||||
this.canvasLayers.render();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
moveLayerUp() {
|
moveLayerUp() {
|
||||||
if (this.canvasLayers.selectedLayers.length === 0) return;
|
if (this.canvas.selectedLayers.length === 0) return;
|
||||||
const selectedIndicesSet = new Set(this.canvasLayers.selectedLayers.map(layer => this.canvasLayers.layers.indexOf(layer)));
|
const selectedIndicesSet = new Set(this.canvas.selectedLayers.map(layer => this.canvas.layers.indexOf(layer)));
|
||||||
|
|
||||||
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => b - a);
|
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => b - a);
|
||||||
|
|
||||||
sortedIndices.forEach(index => {
|
sortedIndices.forEach(index => {
|
||||||
const targetIndex = index + 1;
|
const targetIndex = index + 1;
|
||||||
|
|
||||||
if (targetIndex < this.canvasLayers.layers.length && !selectedIndicesSet.has(targetIndex)) {
|
if (targetIndex < this.canvas.layers.length && !selectedIndicesSet.has(targetIndex)) {
|
||||||
[this.canvasLayers.layers[index], this.canvasLayers.layers[targetIndex]] = [this.canvasLayers.layers[targetIndex], this.canvasLayers.layers[index]];
|
[this.canvas.layers[index], this.canvas.layers[targetIndex]] = [this.canvas.layers[targetIndex], this.canvas.layers[index]];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.canvasLayers.layers.forEach((layer, i) => layer.zIndex = i);
|
this.canvas.layers.forEach((layer, i) => layer.zIndex = i);
|
||||||
this.canvasLayers.render();
|
this.canvas.render();
|
||||||
this.canvasLayers.saveState();
|
this.canvas.saveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
moveLayerDown() {
|
moveLayerDown() {
|
||||||
if (this.canvasLayers.selectedLayers.length === 0) return;
|
if (this.canvas.selectedLayers.length === 0) return;
|
||||||
const selectedIndicesSet = new Set(this.canvasLayers.selectedLayers.map(layer => this.canvasLayers.layers.indexOf(layer)));
|
const selectedIndicesSet = new Set(this.canvas.selectedLayers.map(layer => this.canvas.layers.indexOf(layer)));
|
||||||
|
|
||||||
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => a - b);
|
const sortedIndices = Array.from(selectedIndicesSet).sort((a, b) => a - b);
|
||||||
|
|
||||||
@@ -216,17 +228,46 @@ export class CanvasLayers {
|
|||||||
const targetIndex = index - 1;
|
const targetIndex = index - 1;
|
||||||
|
|
||||||
if (targetIndex >= 0 && !selectedIndicesSet.has(targetIndex)) {
|
if (targetIndex >= 0 && !selectedIndicesSet.has(targetIndex)) {
|
||||||
[this.canvasLayers.layers[index], this.canvasLayers.layers[targetIndex]] = [this.canvasLayers.layers[targetIndex], this.canvasLayers.layers[index]];
|
[this.canvas.layers[index], this.canvas.layers[targetIndex]] = [this.canvas.layers[targetIndex], this.canvas.layers[index]];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.canvasLayers.layers.forEach((layer, i) => layer.zIndex = i);
|
this.canvas.layers.forEach((layer, i) => layer.zIndex = i);
|
||||||
this.canvasLayers.render();
|
this.canvas.render();
|
||||||
this.canvasLayers.saveState();
|
this.canvas.saveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zmienia rozmiar wybranych warstw
|
||||||
|
* @param {number} scale - Skala zmiany rozmiaru
|
||||||
|
*/
|
||||||
|
resizeLayer(scale) {
|
||||||
|
if (this.canvas.selectedLayers.length === 0) return;
|
||||||
|
|
||||||
|
this.canvas.selectedLayers.forEach(layer => {
|
||||||
|
layer.width *= scale;
|
||||||
|
layer.height *= scale;
|
||||||
|
});
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obraca wybrane warstwy
|
||||||
|
* @param {number} angle - Kąt obrotu w stopniach
|
||||||
|
*/
|
||||||
|
rotateLayer(angle) {
|
||||||
|
if (this.canvas.selectedLayers.length === 0) return;
|
||||||
|
|
||||||
|
this.canvas.selectedLayers.forEach(layer => {
|
||||||
|
layer.rotation += angle;
|
||||||
|
});
|
||||||
|
this.canvas.render();
|
||||||
|
this.canvas.saveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
getLayerAtPosition(worldX, worldY) {
|
getLayerAtPosition(worldX, worldY) {
|
||||||
for (let i = this.canvasLayers.layers.length - 1; i >= 0; i--) {
|
for (let i = this.canvas.layers.length - 1; i >= 0; i--) {
|
||||||
const layer = this.canvasLayers.layers[i];
|
const layer = this.canvas.layers[i];
|
||||||
|
|
||||||
const centerX = layer.x + layer.width / 2;
|
const centerX = layer.x + layer.width / 2;
|
||||||
const centerY = layer.y + layer.height / 2;
|
const centerY = layer.y + layer.height / 2;
|
||||||
@@ -253,12 +294,12 @@ export class CanvasLayers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async mirrorHorizontal() {
|
async mirrorHorizontal() {
|
||||||
if (this.canvasLayers.selectedLayers.length === 0) return;
|
if (this.canvas.selectedLayers.length === 0) return;
|
||||||
|
|
||||||
const promises = this.canvasLayers.selectedLayers.map(layer => {
|
const promises = this.canvas.selectedLayers.map(layer => {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
tempCanvas.width = layer.image.width;
|
tempCanvas.width = layer.image.width;
|
||||||
tempCanvas.height = layer.image.height;
|
tempCanvas.height = layer.image.height;
|
||||||
|
|
||||||
@@ -276,17 +317,17 @@ export class CanvasLayers {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
this.canvasLayers.render();
|
this.canvas.render();
|
||||||
this.canvasLayers.saveState();
|
this.canvas.saveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async mirrorVertical() {
|
async mirrorVertical() {
|
||||||
if (this.canvasLayers.selectedLayers.length === 0) return;
|
if (this.canvas.selectedLayers.length === 0) return;
|
||||||
|
|
||||||
const promises = this.canvasLayers.selectedLayers.map(layer => {
|
const promises = this.canvas.selectedLayers.map(layer => {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
tempCanvas.width = layer.image.width;
|
tempCanvas.width = layer.image.width;
|
||||||
tempCanvas.height = layer.image.height;
|
tempCanvas.height = layer.image.height;
|
||||||
|
|
||||||
@@ -304,14 +345,14 @@ export class CanvasLayers {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
this.canvasLayers.render();
|
this.canvas.render();
|
||||||
this.canvasLayers.saveState();
|
this.canvas.saveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLayerImageData(layer) {
|
async getLayerImageData(layer) {
|
||||||
try {
|
try {
|
||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
tempCanvas.width = layer.width;
|
tempCanvas.width = layer.width;
|
||||||
tempCanvas.height = layer.height;
|
tempCanvas.height = layer.height;
|
||||||
@@ -342,52 +383,24 @@ export class CanvasLayers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
updateOutputAreaSize(width, height, saveHistory = true) {
|
updateOutputAreaSize(width, height, saveHistory = true) {
|
||||||
if (saveHistory) {
|
if (saveHistory) {
|
||||||
this.canvasLayers.saveState();
|
this.canvas.saveState();
|
||||||
}
|
}
|
||||||
this.canvasLayers.width = width;
|
this.canvas.width = width;
|
||||||
this.canvasLayers.height = height;
|
this.canvas.height = height;
|
||||||
this.canvasLayers.maskTool.resize(width, height);
|
this.canvas.maskTool.resize(width, height);
|
||||||
|
|
||||||
this.canvasLayers.canvasLayers.width = width;
|
this.canvas.canvas.width = width;
|
||||||
this.canvasLayers.canvasLayers.height = height;
|
this.canvas.canvas.height = height;
|
||||||
|
|
||||||
this.canvasLayers.render();
|
this.canvas.render();
|
||||||
|
|
||||||
if (saveHistory) {
|
if (saveHistory) {
|
||||||
this.canvasLayers.saveStateToDB();
|
this.canvas.canvasState.saveStateToDB();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addMattedLayer(image, mask) {
|
|
||||||
const layer = {
|
|
||||||
image: image,
|
|
||||||
mask: mask,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: image.width,
|
|
||||||
height: image.height,
|
|
||||||
rotation: 0,
|
|
||||||
zIndex: this.canvasLayers.layers.length
|
|
||||||
};
|
|
||||||
|
|
||||||
this.canvasLayers.layers.push(layer);
|
|
||||||
this.canvasLayers.selectedLayer = layer;
|
|
||||||
this.canvasLayers.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
isRotationHandle(x, y) {
|
|
||||||
if (!this.canvasLayers.selectedLayer) return false;
|
|
||||||
|
|
||||||
const handleX = this.canvasLayers.selectedLayer.x + this.canvasLayers.selectedLayer.width / 2;
|
|
||||||
const handleY = this.canvasLayers.selectedLayer.y - 20;
|
|
||||||
const handleRadius = 5;
|
|
||||||
|
|
||||||
return Math.sqrt(Math.pow(x - handleX, 2) + Math.pow(y - handleY, 2)) <= handleRadius;
|
|
||||||
}
|
|
||||||
|
|
||||||
getHandles(layer) {
|
getHandles(layer) {
|
||||||
if (!layer) return {};
|
if (!layer) return {};
|
||||||
|
|
||||||
@@ -408,7 +421,7 @@ export class CanvasLayers {
|
|||||||
'sw': {x: -halfW, y: halfH},
|
'sw': {x: -halfW, y: halfH},
|
||||||
'w': {x: -halfW, y: 0},
|
'w': {x: -halfW, y: 0},
|
||||||
'nw': {x: -halfW, y: -halfH},
|
'nw': {x: -halfW, y: -halfH},
|
||||||
'rot': {x: 0, y: -halfH - 20 / this.canvasLayers.viewport.zoom}
|
'rot': {x: 0, y: -halfH - 20 / this.canvas.viewport.zoom}
|
||||||
};
|
};
|
||||||
|
|
||||||
const worldHandles = {};
|
const worldHandles = {};
|
||||||
@@ -423,11 +436,11 @@ export class CanvasLayers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getHandleAtPosition(worldX, worldY) {
|
getHandleAtPosition(worldX, worldY) {
|
||||||
if (this.canvasLayers.selectedLayers.length === 0) return null;
|
if (this.canvas.selectedLayers.length === 0) return null;
|
||||||
|
|
||||||
const handleRadius = 8 / this.canvasLayers.viewport.zoom;
|
const handleRadius = 8 / this.canvas.viewport.zoom;
|
||||||
for (let i = this.canvasLayers.selectedLayers.length - 1; i >= 0; i--) {
|
for (let i = this.canvas.selectedLayers.length - 1; i >= 0; i--) {
|
||||||
const layer = this.canvasLayers.selectedLayers[i];
|
const layer = this.canvas.selectedLayers[i];
|
||||||
const handles = this.getHandles(layer);
|
const handles = this.getHandles(layer);
|
||||||
|
|
||||||
for (const key in handles) {
|
for (const key in handles) {
|
||||||
@@ -442,34 +455,6 @@ export class CanvasLayers {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getResizeHandle(x, y) {
|
|
||||||
if (!this.canvasLayers.selectedLayer) return null;
|
|
||||||
|
|
||||||
const handleRadius = 5;
|
|
||||||
const handles = {
|
|
||||||
'nw': {x: this.canvasLayers.selectedLayer.x, y: this.canvasLayers.selectedLayer.y},
|
|
||||||
'ne': {
|
|
||||||
x: this.canvasLayers.selectedLayer.x + this.canvasLayers.selectedLayer.width,
|
|
||||||
y: this.canvasLayers.selectedLayer.y
|
|
||||||
},
|
|
||||||
'se': {
|
|
||||||
x: this.canvasLayers.selectedLayer.x + this.canvasLayers.selectedLayer.width,
|
|
||||||
y: this.canvasLayers.selectedLayer.y + this.canvasLayers.selectedLayer.height
|
|
||||||
},
|
|
||||||
'sw': {
|
|
||||||
x: this.canvasLayers.selectedLayer.x,
|
|
||||||
y: this.canvasLayers.selectedLayer.y + this.canvasLayers.selectedLayer.height
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [position, point] of Object.entries(handles)) {
|
|
||||||
if (Math.sqrt(Math.pow(x - point.x, 2) + Math.pow(y - point.y, 2)) <= handleRadius) {
|
|
||||||
return position;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
showBlendModeMenu(x, y) {
|
showBlendModeMenu(x, y) {
|
||||||
this.closeBlendModeMenu();
|
this.closeBlendModeMenu();
|
||||||
|
|
||||||
@@ -482,11 +467,69 @@ export class CanvasLayers {
|
|||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
border: 1px solid #3a3a3a;
|
border: 1px solid #3a3a3a;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 5px;
|
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
min-width: 200px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const titleBar = document.createElement('div');
|
||||||
|
titleBar.style.cssText = `
|
||||||
|
background: #3a3a3a;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-bottom: 1px solid #4a4a4a;
|
||||||
|
`;
|
||||||
|
titleBar.textContent = 'Blend Mode';
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.style.cssText = `
|
||||||
|
padding: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
menu.appendChild(titleBar);
|
||||||
|
menu.appendChild(content);
|
||||||
|
|
||||||
|
let isDragging = false;
|
||||||
|
let dragOffset = { x: 0, y: 0 };
|
||||||
|
|
||||||
|
const handleMouseMove = (e) => {
|
||||||
|
if (isDragging) {
|
||||||
|
const newX = e.clientX - dragOffset.x;
|
||||||
|
const newY = e.clientY - dragOffset.y;
|
||||||
|
|
||||||
|
const maxX = window.innerWidth - menu.offsetWidth;
|
||||||
|
const maxY = window.innerHeight - menu.offsetHeight;
|
||||||
|
|
||||||
|
menu.style.left = Math.max(0, Math.min(newX, maxX)) + 'px';
|
||||||
|
menu.style.top = Math.max(0, Math.min(newY, maxY)) + 'px';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
if (isDragging) {
|
||||||
|
isDragging = false;
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
titleBar.addEventListener('mousedown', (e) => {
|
||||||
|
isDragging = true;
|
||||||
|
|
||||||
|
dragOffset.x = e.clientX - parseInt(menu.style.left);
|
||||||
|
dragOffset.y = e.clientY - parseInt(menu.style.top);
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
|
});
|
||||||
|
|
||||||
this.blendModes.forEach(mode => {
|
this.blendModes.forEach(mode => {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'blend-mode-container';
|
container.className = 'blend-mode-container';
|
||||||
@@ -508,58 +551,58 @@ export class CanvasLayers {
|
|||||||
slider.min = '0';
|
slider.min = '0';
|
||||||
slider.max = '100';
|
slider.max = '100';
|
||||||
|
|
||||||
slider.value = this.canvasLayers.selectedLayer.opacity ? Math.round(this.canvasLayers.selectedLayer.opacity * 100) : 100;
|
slider.value = this.canvas.selectedLayer.opacity ? Math.round(this.canvas.selectedLayer.opacity * 100) : 100;
|
||||||
slider.style.cssText = `
|
slider.style.cssText = `
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
display: none;
|
display: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (this.canvasLayers.selectedLayer.blendMode === mode.name) {
|
if (this.canvas.selectedLayer.blendMode === mode.name) {
|
||||||
slider.style.display = 'block';
|
slider.style.display = 'block';
|
||||||
option.style.backgroundColor = '#3a3a3a';
|
option.style.backgroundColor = '#3a3a3a';
|
||||||
}
|
}
|
||||||
|
|
||||||
option.onclick = () => {
|
option.onclick = () => {
|
||||||
menu.querySelectorAll('input[type="range"]').forEach(s => {
|
content.querySelectorAll('input[type="range"]').forEach(s => {
|
||||||
s.style.display = 'none';
|
s.style.display = 'none';
|
||||||
});
|
});
|
||||||
menu.querySelectorAll('.blend-mode-container div').forEach(d => {
|
content.querySelectorAll('.blend-mode-container div').forEach(d => {
|
||||||
d.style.backgroundColor = '';
|
d.style.backgroundColor = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
slider.style.display = 'block';
|
slider.style.display = 'block';
|
||||||
option.style.backgroundColor = '#3a3a3a';
|
option.style.backgroundColor = '#3a3a3a';
|
||||||
|
|
||||||
if (this.canvasLayers.selectedLayer) {
|
if (this.canvas.selectedLayer) {
|
||||||
this.canvasLayers.selectedLayer.blendMode = mode.name;
|
this.canvas.selectedLayer.blendMode = mode.name;
|
||||||
this.canvasLayers.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
slider.addEventListener('input', () => {
|
slider.addEventListener('input', () => {
|
||||||
if (this.canvasLayers.selectedLayer) {
|
if (this.canvas.selectedLayer) {
|
||||||
this.canvasLayers.selectedLayer.opacity = slider.value / 100;
|
this.canvas.selectedLayer.opacity = slider.value / 100;
|
||||||
this.canvasLayers.render();
|
this.canvas.render();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
slider.addEventListener('change', async () => {
|
slider.addEventListener('change', async () => {
|
||||||
if (this.canvasLayers.selectedLayer) {
|
if (this.canvas.selectedLayer) {
|
||||||
this.canvasLayers.selectedLayer.opacity = slider.value / 100;
|
this.canvas.selectedLayer.opacity = slider.value / 100;
|
||||||
this.canvasLayers.render();
|
this.canvas.render();
|
||||||
const saveWithFallback = async (fileName) => {
|
const saveWithFallback = async (fileName) => {
|
||||||
try {
|
try {
|
||||||
const uniqueFileName = generateUniqueFileName(fileName, this.canvasLayers.node.id);
|
const uniqueFileName = generateUniqueFileName(fileName, this.canvas.node.id);
|
||||||
return await this.canvasLayers.saveToServer(uniqueFileName);
|
return await this.canvas.saveToServer(uniqueFileName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error);
|
console.warn(`Failed to save with unique name, falling back to original: ${fileName}`, error);
|
||||||
return await this.canvasLayers.saveToServer(fileName);
|
return await this.canvas.saveToServer(fileName);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await saveWithFallback(this.canvasLayers.widget.value);
|
await saveWithFallback(this.canvas.widget.value);
|
||||||
if (this.canvasLayers.node) {
|
if (this.canvas.node) {
|
||||||
app.graph.runStep();
|
app.graph.runStep();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -567,14 +610,14 @@ export class CanvasLayers {
|
|||||||
|
|
||||||
container.appendChild(option);
|
container.appendChild(option);
|
||||||
container.appendChild(slider);
|
container.appendChild(slider);
|
||||||
menu.appendChild(container);
|
content.appendChild(container);
|
||||||
});
|
});
|
||||||
|
|
||||||
const container = this.canvasLayers.canvas.parentElement || document.body;
|
const container = this.canvas.canvas.parentElement || document.body;
|
||||||
container.appendChild(menu);
|
container.appendChild(menu);
|
||||||
|
|
||||||
const closeMenu = (e) => {
|
const closeMenu = (e) => {
|
||||||
if (!menu.contains(e.target)) {
|
if (!menu.contains(e.target) && !isDragging) {
|
||||||
this.closeBlendModeMenu();
|
this.closeBlendModeMenu();
|
||||||
document.removeEventListener('mousedown', closeMenu);
|
document.removeEventListener('mousedown', closeMenu);
|
||||||
}
|
}
|
||||||
@@ -591,17 +634,6 @@ export class CanvasLayers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBlendModeSelection(mode) {
|
|
||||||
if (this.selectedBlendMode === mode && !this.isAdjustingOpacity) {
|
|
||||||
this.applyBlendMode(mode, this.blendOpacity);
|
|
||||||
this.closeBlendModeMenu();
|
|
||||||
} else {
|
|
||||||
this.selectedBlendMode = mode;
|
|
||||||
this.isAdjustingOpacity = true;
|
|
||||||
this.showOpacitySlider(mode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showOpacitySlider(mode) {
|
showOpacitySlider(mode) {
|
||||||
const slider = document.createElement('input');
|
const slider = document.createElement('input');
|
||||||
slider.type = 'range';
|
slider.type = 'range';
|
||||||
@@ -623,11 +655,11 @@ export class CanvasLayers {
|
|||||||
async getFlattenedCanvasAsBlob() {
|
async getFlattenedCanvasAsBlob() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = this.canvasLayers.width;
|
tempCanvas.width = this.canvas.width;
|
||||||
tempCanvas.height = this.canvasLayers.height;
|
tempCanvas.height = this.canvas.height;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
const sortedLayers = [...this.canvasLayers.layers].sort((a, b) => a.zIndex - b.zIndex);
|
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||||
|
|
||||||
sortedLayers.forEach(layer => {
|
sortedLayers.forEach(layer => {
|
||||||
if (!layer.image) return;
|
if (!layer.image) return;
|
||||||
@@ -660,14 +692,216 @@ export class CanvasLayers {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFlattenedCanvasWithMaskAsBlob() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
tempCanvas.width = this.canvas.width;
|
||||||
|
tempCanvas.height = this.canvas.height;
|
||||||
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
|
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||||
|
|
||||||
|
sortedLayers.forEach(layer => {
|
||||||
|
if (!layer.image) return;
|
||||||
|
|
||||||
|
tempCtx.save();
|
||||||
|
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||||
|
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||||
|
const centerX = layer.x + layer.width / 2;
|
||||||
|
const centerY = layer.y + layer.height / 2;
|
||||||
|
tempCtx.translate(centerX, centerY);
|
||||||
|
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
||||||
|
tempCtx.drawImage(
|
||||||
|
layer.image,
|
||||||
|
-layer.width / 2,
|
||||||
|
-layer.height / 2,
|
||||||
|
layer.width,
|
||||||
|
layer.height
|
||||||
|
);
|
||||||
|
|
||||||
|
tempCtx.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||||
|
const data = imageData.data;
|
||||||
|
|
||||||
|
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||||
|
if (toolMaskCanvas) {
|
||||||
|
|
||||||
|
const tempMaskCanvas = document.createElement('canvas');
|
||||||
|
tempMaskCanvas.width = this.canvas.width;
|
||||||
|
tempMaskCanvas.height = this.canvas.height;
|
||||||
|
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
|
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||||
|
|
||||||
|
const maskX = this.canvas.maskTool.x;
|
||||||
|
const maskY = this.canvas.maskTool.y;
|
||||||
|
|
||||||
|
const sourceX = Math.max(0, -maskX); // Where in the mask canvas to start reading
|
||||||
|
const sourceY = Math.max(0, -maskY);
|
||||||
|
const destX = Math.max(0, maskX); // Where in the output canvas to start writing
|
||||||
|
const destY = Math.max(0, maskY);
|
||||||
|
|
||||||
|
const copyWidth = Math.min(
|
||||||
|
toolMaskCanvas.width - sourceX, // Available width in source
|
||||||
|
this.canvas.width - destX // Available width in destination
|
||||||
|
);
|
||||||
|
const copyHeight = Math.min(
|
||||||
|
toolMaskCanvas.height - sourceY, // Available height in source
|
||||||
|
this.canvas.height - destY // Available height in destination
|
||||||
|
);
|
||||||
|
|
||||||
|
if (copyWidth > 0 && copyHeight > 0) {
|
||||||
|
tempMaskCtx.drawImage(
|
||||||
|
toolMaskCanvas,
|
||||||
|
sourceX, sourceY, copyWidth, copyHeight, // Source rectangle
|
||||||
|
destX, destY, copyWidth, copyHeight // Destination rectangle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
for (let i = 0; i < tempMaskData.data.length; i += 4) {
|
||||||
|
const alpha = tempMaskData.data[i + 3];
|
||||||
|
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
|
||||||
|
tempMaskData.data[i + 3] = alpha;
|
||||||
|
}
|
||||||
|
tempMaskCtx.putImageData(tempMaskData, 0, 0);
|
||||||
|
|
||||||
|
const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
const maskData = maskImageData.data;
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const originalAlpha = data[i + 3];
|
||||||
|
const maskAlpha = maskData[i + 3] / 255; // Użyj kanału alpha maski
|
||||||
|
|
||||||
|
|
||||||
|
const invertedMaskAlpha = 1 - maskAlpha;
|
||||||
|
data[i + 3] = originalAlpha * invertedMaskAlpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
tempCtx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
tempCanvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Canvas toBlob failed.'));
|
||||||
|
}
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFlattenedCanvasForMaskEditor() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
tempCanvas.width = this.canvas.width;
|
||||||
|
tempCanvas.height = this.canvas.height;
|
||||||
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
|
const sortedLayers = [...this.canvas.layers].sort((a, b) => a.zIndex - b.zIndex);
|
||||||
|
|
||||||
|
sortedLayers.forEach(layer => {
|
||||||
|
if (!layer.image) return;
|
||||||
|
|
||||||
|
tempCtx.save();
|
||||||
|
tempCtx.globalCompositeOperation = layer.blendMode || 'normal';
|
||||||
|
tempCtx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1;
|
||||||
|
const centerX = layer.x + layer.width / 2;
|
||||||
|
const centerY = layer.y + layer.height / 2;
|
||||||
|
tempCtx.translate(centerX, centerY);
|
||||||
|
tempCtx.rotate(layer.rotation * Math.PI / 180);
|
||||||
|
tempCtx.drawImage(
|
||||||
|
layer.image,
|
||||||
|
-layer.width / 2,
|
||||||
|
-layer.height / 2,
|
||||||
|
layer.width,
|
||||||
|
layer.height
|
||||||
|
);
|
||||||
|
|
||||||
|
tempCtx.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||||
|
const data = imageData.data;
|
||||||
|
|
||||||
|
const toolMaskCanvas = this.canvas.maskTool.getMask();
|
||||||
|
if (toolMaskCanvas) {
|
||||||
|
|
||||||
|
const tempMaskCanvas = document.createElement('canvas');
|
||||||
|
tempMaskCanvas.width = this.canvas.width;
|
||||||
|
tempMaskCanvas.height = this.canvas.height;
|
||||||
|
const tempMaskCtx = tempMaskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
|
tempMaskCtx.clearRect(0, 0, tempMaskCanvas.width, tempMaskCanvas.height);
|
||||||
|
|
||||||
|
const maskX = this.canvas.maskTool.x;
|
||||||
|
const maskY = this.canvas.maskTool.y;
|
||||||
|
|
||||||
|
const sourceX = Math.max(0, -maskX);
|
||||||
|
const sourceY = Math.max(0, -maskY);
|
||||||
|
const destX = Math.max(0, maskX);
|
||||||
|
const destY = Math.max(0, maskY);
|
||||||
|
|
||||||
|
const copyWidth = Math.min(
|
||||||
|
toolMaskCanvas.width - sourceX,
|
||||||
|
this.canvas.width - destX
|
||||||
|
);
|
||||||
|
const copyHeight = Math.min(
|
||||||
|
toolMaskCanvas.height - sourceY,
|
||||||
|
this.canvas.height - destY
|
||||||
|
);
|
||||||
|
|
||||||
|
if (copyWidth > 0 && copyHeight > 0) {
|
||||||
|
tempMaskCtx.drawImage(
|
||||||
|
toolMaskCanvas,
|
||||||
|
sourceX, sourceY, copyWidth, copyHeight,
|
||||||
|
destX, destY, copyWidth, copyHeight
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempMaskData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
for (let i = 0; i < tempMaskData.data.length; i += 4) {
|
||||||
|
const alpha = tempMaskData.data[i + 3];
|
||||||
|
tempMaskData.data[i] = tempMaskData.data[i + 1] = tempMaskData.data[i + 2] = 255;
|
||||||
|
tempMaskData.data[i + 3] = alpha;
|
||||||
|
}
|
||||||
|
tempMaskCtx.putImageData(tempMaskData, 0, 0);
|
||||||
|
|
||||||
|
const maskImageData = tempMaskCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
const maskData = maskImageData.data;
|
||||||
|
|
||||||
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
|
const originalAlpha = data[i + 3];
|
||||||
|
const maskAlpha = maskData[i + 3] / 255;
|
||||||
|
|
||||||
|
|
||||||
|
const invertedMaskAlpha = 1 - maskAlpha;
|
||||||
|
data[i + 3] = originalAlpha * invertedMaskAlpha;
|
||||||
|
}
|
||||||
|
|
||||||
|
tempCtx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
tempCanvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Canvas toBlob failed.'));
|
||||||
|
}
|
||||||
|
}, 'image/png');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async getFlattenedSelectionAsBlob() {
|
async getFlattenedSelectionAsBlob() {
|
||||||
if (this.canvasLayers.selectedLayers.length === 0) {
|
if (this.canvas.selectedLayers.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
this.canvasLayers.selectedLayers.forEach(layer => {
|
this.canvas.selectedLayers.forEach(layer => {
|
||||||
const centerX = layer.x + layer.width / 2;
|
const centerX = layer.x + layer.width / 2;
|
||||||
const centerY = layer.y + layer.height / 2;
|
const centerY = layer.y + layer.height / 2;
|
||||||
const rad = layer.rotation * Math.PI / 180;
|
const rad = layer.rotation * Math.PI / 180;
|
||||||
@@ -705,11 +939,11 @@ export class CanvasLayers {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = newWidth;
|
tempCanvas.width = newWidth;
|
||||||
tempCanvas.height = newHeight;
|
tempCanvas.height = newHeight;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
tempCtx.translate(-minX, -minY);
|
tempCtx.translate(-minX, -minY);
|
||||||
|
|
||||||
const sortedSelection = [...this.canvasLayers.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
|
const sortedSelection = [...this.canvas.selectedLayers].sort((a, b) => a.zIndex - b.zIndex);
|
||||||
|
|
||||||
sortedSelection.forEach(layer => {
|
sortedSelection.forEach(layer => {
|
||||||
if (!layer.image) return;
|
if (!layer.image) return;
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ export class CanvasRenderer {
|
|||||||
ctx.moveTo(0, -layer.height / 2);
|
ctx.moveTo(0, -layer.height / 2);
|
||||||
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
ctx.lineTo(0, -layer.height / 2 - 20 / this.canvas.viewport.zoom);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
const handles = this.canvas.getHandles(layer);
|
const handles = this.canvas.canvasLayers.getHandles(layer);
|
||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.strokeStyle = '#000000';
|
ctx.strokeStyle = '#000000';
|
||||||
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
ctx.lineWidth = 1 / this.canvas.viewport.zoom;
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ export class CanvasState {
|
|||||||
await setCanvasState(this.canvas.node.id, state);
|
await setCanvasState(this.canvas.node.id, state);
|
||||||
log.info("Canvas state saved to IndexedDB.");
|
log.info("Canvas state saved to IndexedDB.");
|
||||||
this.lastSavedStateSignature = currentStateSignature;
|
this.lastSavedStateSignature = currentStateSignature;
|
||||||
|
this.canvas.render();
|
||||||
}, 'CanvasState.saveStateToDB');
|
}, 'CanvasState.saveStateToDB');
|
||||||
|
|
||||||
if (immediate) {
|
if (immediate) {
|
||||||
@@ -292,7 +293,7 @@ export class CanvasState {
|
|||||||
const clonedCanvas = document.createElement('canvas');
|
const clonedCanvas = document.createElement('canvas');
|
||||||
clonedCanvas.width = maskCanvas.width;
|
clonedCanvas.width = maskCanvas.width;
|
||||||
clonedCanvas.height = maskCanvas.height;
|
clonedCanvas.height = maskCanvas.height;
|
||||||
const clonedCtx = clonedCanvas.getContext('2d');
|
const clonedCtx = clonedCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
clonedCtx.drawImage(maskCanvas, 0, 0);
|
clonedCtx.drawImage(maskCanvas, 0, 0);
|
||||||
|
|
||||||
this.maskUndoStack.push(clonedCanvas);
|
this.maskUndoStack.push(clonedCanvas);
|
||||||
@@ -352,7 +353,7 @@ export class CanvasState {
|
|||||||
if (this.maskUndoStack.length > 0) {
|
if (this.maskUndoStack.length > 0) {
|
||||||
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
const maskCanvas = this.canvas.maskTool.getMask();
|
||||||
const maskCtx = maskCanvas.getContext('2d');
|
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||||
maskCtx.drawImage(prevState, 0, 0);
|
maskCtx.drawImage(prevState, 0, 0);
|
||||||
|
|
||||||
@@ -368,7 +369,7 @@ export class CanvasState {
|
|||||||
const nextState = this.maskRedoStack.pop();
|
const nextState = this.maskRedoStack.pop();
|
||||||
this.maskUndoStack.push(nextState);
|
this.maskUndoStack.push(nextState);
|
||||||
const maskCanvas = this.canvas.maskTool.getMask();
|
const maskCanvas = this.canvas.maskTool.getMask();
|
||||||
const maskCtx = maskCanvas.getContext('2d');
|
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
||||||
maskCtx.drawImage(nextState, 0, 0);
|
maskCtx.drawImage(nextState, 0, 0);
|
||||||
|
|
||||||
|
|||||||
362
js/CanvasView.js
362
js/CanvasView.js
@@ -96,6 +96,33 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.painter-clipboard-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background-color: rgba(0,0,0,0.15);
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.painter-clipboard-group::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 20px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(74, 108, 212, 0.6), transparent);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.painter-clipboard-group .painter-button {
|
||||||
|
margin: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.painter-separator {
|
.painter-separator {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
@@ -214,12 +241,13 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.painter-tooltip table td:first-child {
|
.painter-tooltip table td:first-child {
|
||||||
width: 45%;
|
width: auto;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
min-width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
.painter-tooltip table td:last-child {
|
.painter-tooltip table td:last-child {
|
||||||
width: 55%;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.painter-tooltip table tr:nth-child(odd) td {
|
.painter-tooltip table tr:nth-child(odd) td {
|
||||||
@@ -368,7 +396,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
z-index: 9998;
|
z-index: 111;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -385,6 +413,8 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
@@ -484,7 +514,6 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
helpTooltip.innerHTML = standardShortcuts;
|
helpTooltip.innerHTML = standardShortcuts;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Najpierw wyświetlamy tooltip z visibility: hidden aby obliczyć jego wymiary
|
|
||||||
helpTooltip.style.visibility = 'hidden';
|
helpTooltip.style.visibility = 'hidden';
|
||||||
helpTooltip.style.display = 'block';
|
helpTooltip.style.display = 'block';
|
||||||
|
|
||||||
@@ -493,28 +522,22 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
// Obliczamy pozycję
|
|
||||||
let left = buttonRect.left;
|
let left = buttonRect.left;
|
||||||
let top = buttonRect.bottom + 5;
|
let top = buttonRect.bottom + 5;
|
||||||
|
|
||||||
// Sprawdzamy czy tooltip wychodzi poza prawy brzeg ekranu
|
|
||||||
if (left + tooltipRect.width > viewportWidth) {
|
if (left + tooltipRect.width > viewportWidth) {
|
||||||
left = viewportWidth - tooltipRect.width - 10;
|
left = viewportWidth - tooltipRect.width - 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sprawdzamy czy tooltip wychodzi poza dolny brzeg ekranu
|
|
||||||
if (top + tooltipRect.height > viewportHeight) {
|
if (top + tooltipRect.height > viewportHeight) {
|
||||||
// Wyświetlamy nad przyciskiem zamiast pod
|
|
||||||
top = buttonRect.top - tooltipRect.height - 5;
|
top = buttonRect.top - tooltipRect.height - 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upewniamy się, że tooltip nie wychodzi poza lewy brzeg
|
|
||||||
if (left < 10) left = 10;
|
if (left < 10) left = 10;
|
||||||
|
|
||||||
// Upewniamy się, że tooltip nie wychodzi poza górny brzeg
|
|
||||||
if (top < 10) top = 10;
|
if (top < 10) top = 10;
|
||||||
|
|
||||||
// Ustawiamy finalną pozycję i pokazujemy tooltip
|
|
||||||
helpTooltip.style.left = `${left}px`;
|
helpTooltip.style.left = `${left}px`;
|
||||||
helpTooltip.style.top = `${top}px`;
|
helpTooltip.style.top = `${top}px`;
|
||||||
helpTooltip.style.visibility = 'visible';
|
helpTooltip.style.visibility = 'visible';
|
||||||
@@ -539,7 +562,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
canvas.addLayer(img, addMode);
|
canvas.addLayer(img, {}, addMode);
|
||||||
};
|
};
|
||||||
img.src = event.target.result;
|
img.src = event.target.result;
|
||||||
};
|
};
|
||||||
@@ -552,17 +575,116 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
$el("button.painter-button.primary", {
|
$el("button.painter-button.primary", {
|
||||||
textContent: "Import Input",
|
textContent: "Import Input",
|
||||||
title: "Import image from another node",
|
title: "Import image from another node",
|
||||||
onclick: () => canvas.importLatestImage()
|
onclick: () => canvas.canvasIO.importLatestImage()
|
||||||
}),
|
}),
|
||||||
|
$el("div.painter-clipboard-group", {}, [
|
||||||
$el("button.painter-button.primary", {
|
$el("button.painter-button.primary", {
|
||||||
textContent: "Paste Image",
|
textContent: "Paste Image",
|
||||||
title: "Paste image from clipboard",
|
title: "Paste image from clipboard",
|
||||||
onclick: () => {
|
onclick: () => {
|
||||||
|
|
||||||
const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add");
|
const fitOnAddWidget = node.widgets.find(w => w.name === "fit_on_add");
|
||||||
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
|
const addMode = fitOnAddWidget && fitOnAddWidget.value ? 'fit' : 'center';
|
||||||
canvas.handlePaste(addMode);
|
canvas.canvasLayers.handlePaste(addMode);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
$el("button.painter-button", {
|
||||||
|
id: `clipboard-toggle-${node.id}`,
|
||||||
|
textContent: "📋 System",
|
||||||
|
title: "Toggle clipboard source: System Clipboard",
|
||||||
|
style: {
|
||||||
|
minWidth: "100px",
|
||||||
|
fontSize: "11px",
|
||||||
|
backgroundColor: "#4a4a4a"
|
||||||
|
},
|
||||||
|
onclick: (e) => {
|
||||||
|
const button = e.target;
|
||||||
|
if (canvas.canvasLayers.clipboardPreference === 'system') {
|
||||||
|
canvas.canvasLayers.clipboardPreference = 'clipspace';
|
||||||
|
button.textContent = "📋 Clipspace";
|
||||||
|
button.title = "Toggle clipboard source: ComfyUI Clipspace";
|
||||||
|
button.style.backgroundColor = "#4a6cd4";
|
||||||
|
} else {
|
||||||
|
canvas.canvasLayers.clipboardPreference = 'system';
|
||||||
|
button.textContent = "📋 System";
|
||||||
|
button.title = "Toggle clipboard source: System Clipboard";
|
||||||
|
button.style.backgroundColor = "#4a4a4a";
|
||||||
|
}
|
||||||
|
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
|
||||||
|
},
|
||||||
|
onmouseenter: (e) => {
|
||||||
|
const currentPreference = canvas.canvasLayers.clipboardPreference;
|
||||||
|
let tooltipContent = '';
|
||||||
|
|
||||||
|
if (currentPreference === 'system') {
|
||||||
|
tooltipContent = `
|
||||||
|
<h4>📋 System Clipboard Mode</h4>
|
||||||
|
<table>
|
||||||
|
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>system clipboard</strong> as flattened image</td></tr>
|
||||||
|
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
|
||||||
|
<tr><td></td><td>1️⃣ Internal clipboard (copied layers)</td></tr>
|
||||||
|
<tr><td></td><td>2️⃣ System clipboard (images, screenshots)</td></tr>
|
||||||
|
<tr><td></td><td>3️⃣ System clipboard (file paths, URLs)</td></tr>
|
||||||
|
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
|
||||||
|
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
|
||||||
|
</table>
|
||||||
|
<div style="margin-top: 8px; padding: 6px; background: rgba(255,165,0,0.2); border: 1px solid rgba(255,165,0,0.4); border-radius: 4px; font-size: 11px;">
|
||||||
|
⚠️ <strong>Security Note:</strong> "Paste Image" button for external images may not work due to browser security restrictions. Use Ctrl+V instead or Drag & Drop.
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
|
||||||
|
💡 <strong>Best for:</strong> Working with screenshots, copied images, file paths, and urls.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
tooltipContent = `
|
||||||
|
<h4>📋 ComfyUI Clipspace Mode</h4>
|
||||||
|
<table>
|
||||||
|
<tr><td><kbd>Ctrl + C</kbd></td><td>Copy selected layers to internal clipboard + <strong>ComfyUI Clipspace</strong> as flattened image</td></tr>
|
||||||
|
<tr><td><kbd>Ctrl + V</kbd></td><td><strong>Priority:</strong></td></tr>
|
||||||
|
<tr><td></td><td>1️⃣ Internal clipboard (copied layers)</td></tr>
|
||||||
|
<tr><td></td><td>2️⃣ ComfyUI Clipspace (workflow images)</td></tr>
|
||||||
|
<tr><td></td><td>3️⃣ System clipboard (fallback)</td></tr>
|
||||||
|
<tr><td><kbd>Paste Image</kbd></td><td>Same as Ctrl+V but respects fit_on_add setting</td></tr>
|
||||||
|
<tr><td><kbd>Drag & Drop</kbd></td><td>Load images directly from files</td></tr>
|
||||||
|
</table>
|
||||||
|
<div style="margin-top: 8px; padding: 6px; background: rgba(0,0,0,0.2); border-radius: 4px; font-size: 11px;">
|
||||||
|
💡 <strong>Best for:</strong> ComfyUI workflow integration and node-to-node image transfer
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
helpTooltip.innerHTML = tooltipContent;
|
||||||
|
helpTooltip.style.visibility = 'hidden';
|
||||||
|
helpTooltip.style.display = 'block';
|
||||||
|
|
||||||
|
const buttonRect = e.target.getBoundingClientRect();
|
||||||
|
const tooltipRect = helpTooltip.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let left = buttonRect.left;
|
||||||
|
let top = buttonRect.bottom + 5;
|
||||||
|
|
||||||
|
if (left + tooltipRect.width > viewportWidth) {
|
||||||
|
left = viewportWidth - tooltipRect.width - 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top + tooltipRect.height > viewportHeight) {
|
||||||
|
top = buttonRect.top - tooltipRect.height - 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left < 10) left = 10;
|
||||||
|
if (top < 10) top = 10;
|
||||||
|
|
||||||
|
helpTooltip.style.left = `${left}px`;
|
||||||
|
helpTooltip.style.top = `${top}px`;
|
||||||
|
helpTooltip.style.visibility = 'visible';
|
||||||
|
},
|
||||||
|
onmouseleave: () => {
|
||||||
|
helpTooltip.style.display = 'none';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
$el("div.painter-separator"),
|
$el("div.painter-separator"),
|
||||||
@@ -644,7 +766,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
const height = parseInt(document.getElementById('canvas-height').value) || canvas.height;
|
const height = parseInt(document.getElementById('canvas-height').value) || canvas.height;
|
||||||
canvas.updateOutputAreaSize(width, height);
|
canvas.updateOutputAreaSize(width, height);
|
||||||
document.body.removeChild(dialog);
|
document.body.removeChild(dialog);
|
||||||
// updateOutput is triggered by saveState in updateOutputAreaSize
|
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById('cancel-size').onclick = () => {
|
document.getElementById('cancel-size').onclick = () => {
|
||||||
@@ -660,12 +782,12 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Layer Up",
|
textContent: "Layer Up",
|
||||||
title: "Move selected layer(s) up",
|
title: "Move selected layer(s) up",
|
||||||
onclick: () => canvas.moveLayerUp()
|
onclick: () => canvas.canvasLayers.moveLayerUp()
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Layer Down",
|
textContent: "Layer Down",
|
||||||
title: "Move selected layer(s) down",
|
title: "Move selected layer(s) down",
|
||||||
onclick: () => canvas.moveLayerDown()
|
onclick: () => canvas.canvasLayers.moveLayerDown()
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
@@ -674,27 +796,27 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Rotate +90°",
|
textContent: "Rotate +90°",
|
||||||
title: "Rotate selected layer(s) by +90 degrees",
|
title: "Rotate selected layer(s) by +90 degrees",
|
||||||
onclick: () => canvas.rotateLayer(90)
|
onclick: () => canvas.canvasLayers.rotateLayer(90)
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Scale +5%",
|
textContent: "Scale +5%",
|
||||||
title: "Increase size of selected layer(s) by 5%",
|
title: "Increase size of selected layer(s) by 5%",
|
||||||
onclick: () => canvas.resizeLayer(1.05)
|
onclick: () => canvas.canvasLayers.resizeLayer(1.05)
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Scale -5%",
|
textContent: "Scale -5%",
|
||||||
title: "Decrease size of selected layer(s) by 5%",
|
title: "Decrease size of selected layer(s) by 5%",
|
||||||
onclick: () => canvas.resizeLayer(0.95)
|
onclick: () => canvas.canvasLayers.resizeLayer(0.95)
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Mirror H",
|
textContent: "Mirror H",
|
||||||
title: "Mirror selected layer(s) horizontally",
|
title: "Mirror selected layer(s) horizontally",
|
||||||
onclick: () => canvas.mirrorHorizontal()
|
onclick: () => canvas.canvasLayers.mirrorHorizontal()
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button.requires-selection", {
|
$el("button.painter-button.requires-selection", {
|
||||||
textContent: "Mirror V",
|
textContent: "Mirror V",
|
||||||
title: "Mirror selected layer(s) vertically",
|
title: "Mirror selected layer(s) vertically",
|
||||||
onclick: () => canvas.mirrorVertical()
|
onclick: () => canvas.canvasLayers.mirrorVertical()
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
@@ -716,7 +838,7 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
|
|
||||||
const selectedLayer = canvas.selectedLayers[0];
|
const selectedLayer = canvas.selectedLayers[0];
|
||||||
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
|
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
|
||||||
const imageData = await canvas.getLayerImageData(selectedLayer);
|
const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer);
|
||||||
const response = await fetch("/matting", {
|
const response = await fetch("/matting", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
@@ -749,18 +871,25 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
textContent: "Undo",
|
textContent: "Undo",
|
||||||
title: "Undo last action",
|
title: "Undo last action",
|
||||||
disabled: true,
|
disabled: true,
|
||||||
onclick: () => canvas.undo()
|
onclick: () => canvas.canvasState.undo()
|
||||||
}),
|
}),
|
||||||
$el("button.painter-button", {
|
$el("button.painter-button", {
|
||||||
id: `redo-button-${node.id}`,
|
id: `redo-button-${node.id}`,
|
||||||
textContent: "Redo",
|
textContent: "Redo",
|
||||||
title: "Redo last undone action",
|
title: "Redo last undone action",
|
||||||
disabled: true,
|
disabled: true,
|
||||||
onclick: () => canvas.redo()
|
onclick: () => canvas.canvasState.redo()
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
$el("div.painter-separator"),
|
$el("div.painter-separator"),
|
||||||
$el("div.painter-button-group", {id: "mask-controls"}, [
|
$el("div.painter-button-group", {id: "mask-controls"}, [
|
||||||
|
$el("button.painter-button", {
|
||||||
|
textContent: "Edit Mask",
|
||||||
|
title: "Open the current canvas view in the mask editor",
|
||||||
|
onclick: () => {
|
||||||
|
canvas.startMaskEditor();
|
||||||
|
}
|
||||||
|
}),
|
||||||
$el("button.painter-button", {
|
$el("button.painter-button", {
|
||||||
id: "mask-mode-btn",
|
id: "mask-mode-btn",
|
||||||
textContent: "Draw Mask",
|
textContent: "Draw Mask",
|
||||||
@@ -838,15 +967,15 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"},
|
style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"},
|
||||||
onclick: async () => {
|
onclick: async () => {
|
||||||
try {
|
try {
|
||||||
const stats = canvas.getGarbageCollectionStats();
|
const stats = canvas.imageReferenceManager.getStats();
|
||||||
log.info("GC Stats before cleanup:", stats);
|
log.info("GC Stats before cleanup:", stats);
|
||||||
|
|
||||||
await canvas.runGarbageCollection();
|
await canvas.imageReferenceManager.manualGarbageCollection();
|
||||||
|
|
||||||
const newStats = canvas.getGarbageCollectionStats();
|
const newStats = canvas.imageReferenceManager.getStats();
|
||||||
log.info("GC Stats after cleanup:", newStats);
|
log.info("GC Stats after cleanup:", newStats);
|
||||||
|
|
||||||
alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${newStats.operationCount}/${newStats.operationThreshold}`);
|
alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${canvas.imageReferenceManager.operationCount}/${canvas.imageReferenceManager.operationThreshold}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error("Failed to run garbage collection:", e);
|
log.error("Failed to run garbage collection:", e);
|
||||||
alert("Error running garbage collection. Check the console for details.");
|
alert("Error running garbage collection. Check the console for details.");
|
||||||
@@ -910,9 +1039,23 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
|
|
||||||
const triggerWidget = node.widgets.find(w => w.name === "trigger");
|
const triggerWidget = node.widgets.find(w => w.name === "trigger");
|
||||||
|
|
||||||
const updateOutput = () => {
|
const updateOutput = async () => {
|
||||||
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
|
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
|
||||||
// app.graph.runStep(); // Potentially not needed if we just want to mark dirty
|
|
||||||
|
try {
|
||||||
|
const new_preview = new Image();
|
||||||
|
const blob = await canvas.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
if (blob) {
|
||||||
|
new_preview.src = URL.createObjectURL(blob);
|
||||||
|
await new Promise(r => new_preview.onload = r);
|
||||||
|
node.imgs = [new_preview];
|
||||||
|
} else {
|
||||||
|
node.imgs = [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating node preview:", error);
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
|
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
|
||||||
@@ -948,70 +1091,8 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
height: "100%"
|
height: "100%"
|
||||||
}
|
}
|
||||||
}, [controlPanel, canvasContainer]);
|
}, [controlPanel, canvasContainer]);
|
||||||
const handleFileLoad = async (file) => {
|
|
||||||
log.info("File dropped:", file.name);
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
log.info("Dropped file is not an image.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async (event) => {
|
|
||||||
log.debug("FileReader finished loading dropped file as data:URL.");
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = async () => {
|
|
||||||
log.debug("Image object loaded from dropped data:URL.");
|
|
||||||
const scale = Math.min(
|
|
||||||
canvas.width / img.width,
|
|
||||||
canvas.height / img.height
|
|
||||||
);
|
|
||||||
|
|
||||||
const layer = {
|
|
||||||
image: img,
|
|
||||||
x: (canvas.width - img.width * scale) / 2,
|
|
||||||
y: (canvas.height - img.height * scale) / 2,
|
|
||||||
width: img.width * scale,
|
|
||||||
height: img.height * scale,
|
|
||||||
rotation: 0,
|
|
||||||
zIndex: canvas.layers.length,
|
|
||||||
blendMode: 'normal',
|
|
||||||
opacity: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
canvas.layers.push(layer);
|
|
||||||
canvas.updateSelection([layer]);
|
|
||||||
canvas.render();
|
|
||||||
canvas.saveState();
|
|
||||||
log.info("Dropped layer added and state saved.");
|
|
||||||
};
|
|
||||||
img.src = event.target.result;
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
mainContainer.addEventListener('dragover', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
canvasContainer.classList.add('drag-over');
|
|
||||||
});
|
|
||||||
|
|
||||||
mainContainer.addEventListener('dragleave', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
canvasContainer.classList.remove('drag-over');
|
|
||||||
});
|
|
||||||
|
|
||||||
mainContainer.addEventListener('drop', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
canvasContainer.classList.remove('drag-over');
|
|
||||||
|
|
||||||
if (e.dataTransfer.files) {
|
|
||||||
for (const file of e.dataTransfer.files) {
|
|
||||||
await handleFileLoad(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer);
|
const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer);
|
||||||
|
|
||||||
@@ -1072,14 +1153,34 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
if (!window.canvasExecutionStates) {
|
if (!window.canvasExecutionStates) {
|
||||||
window.canvasExecutionStates = new Map();
|
window.canvasExecutionStates = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
node.canvasWidget = canvas;
|
node.canvasWidget = canvas;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
canvas.loadInitialState();
|
canvas.loadInitialState();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
const showPreviewWidget = node.widgets.find(w => w.name === "show_preview");
|
||||||
|
if (showPreviewWidget) {
|
||||||
|
const originalCallback = showPreviewWidget.callback;
|
||||||
|
|
||||||
|
showPreviewWidget.callback = function (value) {
|
||||||
|
if (originalCallback) {
|
||||||
|
originalCallback.call(this, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canvas && canvas.setPreviewVisibility) {
|
||||||
|
canvas.setPreviewVisibility(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.graph && node.graph.canvas) {
|
||||||
|
node.setDirtyCanvas(true, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canvas: canvas,
|
canvas: canvas,
|
||||||
panel: controlPanel
|
panel: controlPanel
|
||||||
@@ -1154,7 +1255,6 @@ app.registerExtension({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iterate through every widget attached to this node
|
|
||||||
this.widgets.forEach(w => {
|
this.widgets.forEach(w => {
|
||||||
log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`);
|
log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`);
|
||||||
});
|
});
|
||||||
@@ -1206,7 +1306,32 @@ app.registerExtension({
|
|||||||
originalGetExtraMenuOptions?.apply(this, arguments);
|
originalGetExtraMenuOptions?.apply(this, arguments);
|
||||||
|
|
||||||
const self = this;
|
const self = this;
|
||||||
|
|
||||||
|
const maskEditorIndex = options.findIndex(option =>
|
||||||
|
option && option.content === "Open in MaskEditor"
|
||||||
|
);
|
||||||
|
if (maskEditorIndex !== -1) {
|
||||||
|
options.splice(maskEditorIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
const newOptions = [
|
const newOptions = [
|
||||||
|
{
|
||||||
|
content: "Open in MaskEditor",
|
||||||
|
callback: async () => {
|
||||||
|
try {
|
||||||
|
log.info("Opening LayerForge canvas in MaskEditor");
|
||||||
|
if (self.canvasWidget && self.canvasWidget.startMaskEditor) {
|
||||||
|
await self.canvasWidget.startMaskEditor();
|
||||||
|
} else {
|
||||||
|
log.error("Canvas widget not available");
|
||||||
|
alert("Canvas not ready. Please try again.");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Error opening MaskEditor:", e);
|
||||||
|
alert(`Failed to open MaskEditor: ${e.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
content: "Open Image",
|
content: "Open Image",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
@@ -1220,6 +1345,19 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
content: "Open Image with Mask Alpha",
|
||||||
|
callback: async () => {
|
||||||
|
try {
|
||||||
|
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Error opening image with mask:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
content: "Copy Image",
|
content: "Copy Image",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
@@ -1234,6 +1372,20 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
content: "Copy Image with Mask Alpha",
|
||||||
|
callback: async () => {
|
||||||
|
try {
|
||||||
|
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
const item = new ClipboardItem({'image/png': blob});
|
||||||
|
await navigator.clipboard.write([item]);
|
||||||
|
log.info("Image with mask alpha copied to clipboard.");
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Error copying image with mask:", e);
|
||||||
|
alert("Failed to copy image with mask to clipboard.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
content: "Save Image",
|
content: "Save Image",
|
||||||
callback: async () => {
|
callback: async () => {
|
||||||
@@ -1252,6 +1404,24 @@ app.registerExtension({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
content: "Save Image with Mask Alpha",
|
||||||
|
callback: async () => {
|
||||||
|
try {
|
||||||
|
const blob = await self.canvasWidget.getFlattenedCanvasWithMaskAsBlob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'canvas_output_with_mask.png';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Error saving image with mask:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
if (options.length > 0) {
|
if (options.length > 0) {
|
||||||
options.unshift({content: "___", disabled: true});
|
options.unshift({content: "___", disabled: true});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export class MaskTool {
|
|||||||
this.mainCanvas = canvasInstance.canvas;
|
this.mainCanvas = canvasInstance.canvas;
|
||||||
this.onStateChange = callbacks.onStateChange || null;
|
this.onStateChange = callbacks.onStateChange || null;
|
||||||
this.maskCanvas = document.createElement('canvas');
|
this.maskCanvas = document.createElement('canvas');
|
||||||
this.maskCtx = this.maskCanvas.getContext('2d');
|
this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
this.x = 0;
|
this.x = 0;
|
||||||
this.y = 0;
|
this.y = 0;
|
||||||
@@ -21,7 +21,7 @@ export class MaskTool {
|
|||||||
this.lastPosition = null;
|
this.lastPosition = null;
|
||||||
|
|
||||||
this.previewCanvas = document.createElement('canvas');
|
this.previewCanvas = document.createElement('canvas');
|
||||||
this.previewCtx = this.previewCanvas.getContext('2d');
|
this.previewCtx = this.previewCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
this.previewVisible = false;
|
this.previewVisible = false;
|
||||||
this.previewCanvasInitialized = false;
|
this.previewCanvasInitialized = false;
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ export class MaskTool {
|
|||||||
if (this.brushHardness === 1) {
|
if (this.brushHardness === 1) {
|
||||||
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
this.maskCtx.strokeStyle = `rgba(255, 255, 255, ${this.brushStrength})`;
|
||||||
} else {
|
} else {
|
||||||
// hardness: 1 = hard edge, 0 = soft edge
|
|
||||||
const innerRadius = gradientRadius * this.brushHardness;
|
const innerRadius = gradientRadius * this.brushHardness;
|
||||||
const gradient = this.maskCtx.createRadialGradient(
|
const gradient = this.maskCtx.createRadialGradient(
|
||||||
canvasX, canvasY, innerRadius,
|
canvasX, canvasY, innerRadius,
|
||||||
@@ -220,7 +220,7 @@ export class MaskTool {
|
|||||||
const tempCanvas = document.createElement('canvas');
|
const tempCanvas = document.createElement('canvas');
|
||||||
tempCanvas.width = this.maskCanvas.width;
|
tempCanvas.width = this.maskCanvas.width;
|
||||||
tempCanvas.height = this.maskCanvas.height;
|
tempCanvas.height = this.maskCanvas.height;
|
||||||
const tempCtx = tempCanvas.getContext('2d');
|
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
tempCtx.drawImage(this.maskCanvas, 0, 0);
|
tempCtx.drawImage(this.maskCanvas, 0, 0);
|
||||||
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
const imageData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
|
||||||
const data = imageData.data;
|
const data = imageData.data;
|
||||||
@@ -258,7 +258,7 @@ export class MaskTool {
|
|||||||
|
|
||||||
this.maskCanvas.width = newWidth;
|
this.maskCanvas.width = newWidth;
|
||||||
this.maskCanvas.height = newHeight;
|
this.maskCanvas.height = newHeight;
|
||||||
this.maskCtx = this.maskCanvas.getContext('2d');
|
this.maskCtx = this.maskCanvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
if (oldMask.width > 0 && oldMask.height > 0) {
|
if (oldMask.width > 0 && oldMask.height > 0) {
|
||||||
|
|
||||||
@@ -279,4 +279,23 @@ export class MaskTool {
|
|||||||
this.y += dy;
|
this.y += dy;
|
||||||
log.info(`Mask position updated to (${this.x}, ${this.y})`);
|
log.info(`Mask position updated to (${this.x}, ${this.y})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMask(image) {
|
||||||
|
|
||||||
|
|
||||||
|
const destX = -this.x;
|
||||||
|
const destY = -this.y;
|
||||||
|
|
||||||
|
|
||||||
|
this.maskCtx.clearRect(destX, destY, this.canvasInstance.width, this.canvasInstance.height);
|
||||||
|
|
||||||
|
|
||||||
|
this.maskCtx.drawImage(image, destX, destY);
|
||||||
|
|
||||||
|
if (this.onStateChange) {
|
||||||
|
this.onStateChange();
|
||||||
|
}
|
||||||
|
this.canvasInstance.render(); // Wymuś odświeżenie, aby zobaczyć zmianę
|
||||||
|
log.info(`MaskTool updated with a new mask image at correct canvas position (${destX}, ${destY}).`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
510
js/utils/ClipboardManager.js
Normal file
510
js/utils/ClipboardManager.js
Normal file
@@ -0,0 +1,510 @@
|
|||||||
|
import {createModuleLogger} from "./LoggerUtils.js";
|
||||||
|
import {api} from "../../../scripts/api.js";
|
||||||
|
import {ComfyApp} from "../../../scripts/app.js";
|
||||||
|
|
||||||
|
const log = createModuleLogger('ClipboardManager');
|
||||||
|
|
||||||
|
export class ClipboardManager {
|
||||||
|
constructor(canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.clipboardPreference = 'system'; // 'system', 'clipspace'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main paste handler that delegates to appropriate methods
|
||||||
|
* @param {string} addMode - The mode for adding the layer
|
||||||
|
* @param {string} preference - Clipboard preference ('system' or 'clipspace')
|
||||||
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async handlePaste(addMode = 'mouse', preference = 'system') {
|
||||||
|
try {
|
||||||
|
log.info(`ClipboardManager handling paste with preference: ${preference}`);
|
||||||
|
|
||||||
|
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||||
|
log.info("Found layers in internal clipboard, pasting layers");
|
||||||
|
this.canvas.canvasLayers.pasteLayers();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preference === 'clipspace') {
|
||||||
|
log.info("Attempting paste from ComfyUI Clipspace");
|
||||||
|
const success = await this.tryClipspacePaste(addMode);
|
||||||
|
if (success) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
log.info("No image found in ComfyUI Clipspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Attempting paste from system clipboard");
|
||||||
|
return await this.trySystemClipboardPaste(addMode);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
log.error("ClipboardManager paste operation failed:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to paste from ComfyUI Clipspace
|
||||||
|
* @param {string} addMode - The mode for adding the layer
|
||||||
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async tryClipspacePaste(addMode) {
|
||||||
|
try {
|
||||||
|
log.info("Attempting to paste from ComfyUI Clipspace");
|
||||||
|
const clipspaceResult = ComfyApp.pasteFromClipspace(this.canvas.node);
|
||||||
|
|
||||||
|
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
||||||
|
const clipspaceImage = this.canvas.node.imgs[0];
|
||||||
|
if (clipspaceImage && clipspaceImage.src) {
|
||||||
|
log.info("Successfully got image from ComfyUI Clipspace");
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
};
|
||||||
|
img.src = clipspaceImage.src;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (clipspaceError) {
|
||||||
|
log.warn("ComfyUI Clipspace paste failed:", clipspaceError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System clipboard paste - handles both image data and text paths
|
||||||
|
* @param {string} addMode - The mode for adding the layer
|
||||||
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async trySystemClipboardPaste(addMode) {
|
||||||
|
log.info("ClipboardManager: Checking system clipboard for images and paths");
|
||||||
|
|
||||||
|
if (navigator.clipboard?.read) {
|
||||||
|
try {
|
||||||
|
const clipboardItems = await navigator.clipboard.read();
|
||||||
|
|
||||||
|
for (const item of clipboardItems) {
|
||||||
|
log.debug("Clipboard item types:", item.types);
|
||||||
|
|
||||||
|
const imageType = item.types.find(type => type.startsWith('image/'));
|
||||||
|
if (imageType) {
|
||||||
|
try {
|
||||||
|
const blob = await item.getType(imageType);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
log.info("Successfully loaded image from system clipboard");
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
};
|
||||||
|
img.src = event.target.result;
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
log.info("Found image data in system clipboard");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log.debug("Error reading image data:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textTypes = ['text/plain', 'text/uri-list'];
|
||||||
|
for (const textType of textTypes) {
|
||||||
|
if (item.types.includes(textType)) {
|
||||||
|
try {
|
||||||
|
const textBlob = await item.getType(textType);
|
||||||
|
const text = await textBlob.text();
|
||||||
|
|
||||||
|
if (this.isValidImagePath(text)) {
|
||||||
|
log.info("Found image path in clipboard:", text);
|
||||||
|
const success = await this.loadImageFromPath(text, addMode);
|
||||||
|
if (success) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.debug(`Error reading ${textType}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.debug("Modern clipboard API failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.clipboard?.readText) {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
log.debug("Found text in clipboard:", text);
|
||||||
|
|
||||||
|
if (text && this.isValidImagePath(text)) {
|
||||||
|
log.info("Found valid image path in clipboard:", text);
|
||||||
|
const success = await this.loadImageFromPath(text, addMode);
|
||||||
|
if (success) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.debug("Could not read text from clipboard:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("No images or valid image paths found in system clipboard");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates if a text string is a valid image file path or URL
|
||||||
|
* @param {string} text - The text to validate
|
||||||
|
* @returns {boolean} - True if the text appears to be a valid image file path or URL
|
||||||
|
*/
|
||||||
|
isValidImagePath(text) {
|
||||||
|
if (!text || typeof text !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
text = text.trim();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
new URL(text);
|
||||||
|
log.debug("Detected valid URL:", text);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
log.debug("Invalid URL format:", text);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageExtensions = [
|
||||||
|
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp',
|
||||||
|
'.svg', '.tiff', '.tif', '.ico', '.avif'
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasImageExtension = imageExtensions.some(ext =>
|
||||||
|
text.toLowerCase().endsWith(ext)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasImageExtension) {
|
||||||
|
log.debug("No valid image extension found in:", text);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const pathPatterns = [
|
||||||
|
/^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...)
|
||||||
|
/^[\\\/]/, // Unix absolute path (/...)
|
||||||
|
/^\.{1,2}[\\\/]/, // Relative path (./... or ../...)
|
||||||
|
/^[^\\\/]*[\\\/]/ // Contains path separators
|
||||||
|
];
|
||||||
|
|
||||||
|
const isValidPath = pathPatterns.some(pattern => pattern.test(text)) ||
|
||||||
|
(!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename
|
||||||
|
|
||||||
|
if (isValidPath) {
|
||||||
|
log.debug("Detected valid local file path:", text);
|
||||||
|
} else {
|
||||||
|
log.debug("Invalid local file path format:", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValidPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to load an image from a file path using simplified methods
|
||||||
|
* @param {string} filePath - The file path to load
|
||||||
|
* @param {string} addMode - The mode for adding the layer
|
||||||
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async loadImageFromPath(filePath, addMode) {
|
||||||
|
|
||||||
|
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||||
|
try {
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
img.onload = async () => {
|
||||||
|
log.info("Successfully loaded image from URL");
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
log.warn("Failed to load image from URL:", filePath);
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
img.src = filePath;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("Error loading image from URL:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("Attempting to load local file via backend");
|
||||||
|
const success = await this.loadFileViaBackend(filePath, addMode);
|
||||||
|
if (success) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("Backend loading failed:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info("Falling back to file picker");
|
||||||
|
const success = await this.promptUserForFile(filePath, addMode);
|
||||||
|
if (success) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("File picker failed:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showFilePathMessage(filePath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a local file via the ComfyUI backend endpoint
|
||||||
|
* @param {string} filePath - The file path to load
|
||||||
|
* @param {string} addMode - The mode for adding the layer
|
||||||
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async loadFileViaBackend(filePath, addMode) {
|
||||||
|
try {
|
||||||
|
log.info("Loading file via ComfyUI backend:", filePath);
|
||||||
|
|
||||||
|
const response = await api.fetchApi("/ycnode/load_image_from_path", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
file_path: filePath
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
log.debug("Backend failed to load image:", errorData.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
log.debug("Backend returned error:", data.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Successfully loaded image via ComfyUI backend:", filePath);
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
const success = await new Promise((resolve) => {
|
||||||
|
img.onload = async () => {
|
||||||
|
log.info("Successfully loaded image from backend response");
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
log.warn("Failed to load image from backend response");
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = data.image_data;
|
||||||
|
});
|
||||||
|
|
||||||
|
return success;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log.debug("Error loading file via ComfyUI backend:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts the user to select a file when a local path is detected
|
||||||
|
* @param {string} originalPath - The original file path from clipboard
|
||||||
|
* @param {string} addMode - The mode for adding the layer
|
||||||
|
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||||
|
*/
|
||||||
|
async promptUserForFile(originalPath, addMode) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.accept = 'image/*';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
|
||||||
|
const fileName = originalPath.split(/[\\\/]/).pop();
|
||||||
|
|
||||||
|
fileInput.onchange = async (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file && file.type.startsWith('image/')) {
|
||||||
|
try {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
log.info("Successfully loaded image from file picker");
|
||||||
|
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
log.warn("Failed to load selected image");
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
img.src = e.target.result;
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
log.warn("Failed to read selected file");
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("Error processing selected file:", error);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn("Selected file is not an image");
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(fileInput);
|
||||||
|
};
|
||||||
|
|
||||||
|
fileInput.oncancel = () => {
|
||||||
|
log.info("File selection cancelled by user");
|
||||||
|
document.body.removeChild(fileInput);
|
||||||
|
resolve(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
|
||||||
|
|
||||||
|
document.body.appendChild(fileInput);
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a message to the user about file path limitations
|
||||||
|
* @param {string} filePath - The file path that couldn't be loaded
|
||||||
|
*/
|
||||||
|
showFilePathMessage(filePath) {
|
||||||
|
const fileName = filePath.split(/[\\\/]/).pop();
|
||||||
|
const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`;
|
||||||
|
this.showNotification(message, 5000);
|
||||||
|
log.info("Showed file path limitation message to user");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a helpful message when clipboard appears empty and offers file picker
|
||||||
|
* @param {string} addMode - The mode for adding the layer
|
||||||
|
*/
|
||||||
|
showEmptyClipboardMessage(addMode) {
|
||||||
|
const message = `Copied a file? Browser can't access file paths for security. Click here to select the file manually.`;
|
||||||
|
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: #2d5aa0;
|
||||||
|
color: white;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||||
|
z-index: 10001;
|
||||||
|
max-width: 320px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #4a7bc8;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
`;
|
||||||
|
notification.innerHTML = `
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span style="font-size: 18px;">📁</span>
|
||||||
|
<span>${message}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; opacity: 0.9; margin-top: 4px;">
|
||||||
|
💡 Tip: You can also drag & drop files directly onto the canvas
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
notification.onmouseenter = () => {
|
||||||
|
notification.style.backgroundColor = '#3d6bb0';
|
||||||
|
notification.style.borderColor = '#5a8bd8';
|
||||||
|
notification.style.transform = 'translateY(-1px)';
|
||||||
|
};
|
||||||
|
notification.onmouseleave = () => {
|
||||||
|
notification.style.backgroundColor = '#2d5aa0';
|
||||||
|
notification.style.borderColor = '#4a7bc8';
|
||||||
|
notification.style.transform = 'translateY(0)';
|
||||||
|
};
|
||||||
|
|
||||||
|
notification.onclick = async () => {
|
||||||
|
document.body.removeChild(notification);
|
||||||
|
try {
|
||||||
|
const success = await this.promptUserForFile('image_file.jpg', addMode);
|
||||||
|
if (success) {
|
||||||
|
log.info("Successfully loaded image via empty clipboard file picker");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("Error with empty clipboard file picker:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (notification.parentNode) {
|
||||||
|
notification.parentNode.removeChild(notification);
|
||||||
|
}
|
||||||
|
}, 12000);
|
||||||
|
|
||||||
|
log.info("Showed enhanced empty clipboard message with file picker option");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a temporary notification to the user
|
||||||
|
* @param {string} message - The message to show
|
||||||
|
* @param {number} duration - Duration in milliseconds
|
||||||
|
*/
|
||||||
|
showNotification(message, duration = 3000) {
|
||||||
|
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
z-index: 10001;
|
||||||
|
max-width: 300px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
`;
|
||||||
|
notification.textContent = message;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (notification.parentNode) {
|
||||||
|
notification.parentNode.removeChild(notification);
|
||||||
|
}
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import {createModuleLogger} from "./LoggerUtils.js";
|
import {createModuleLogger} from "./LoggerUtils.js";
|
||||||
import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
|
import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
|
||||||
|
|
||||||
const log = createModuleLogger('ImageUtils');
|
const log = createModuleLogger('ImageUtils');
|
||||||
|
|
||||||
export function validateImageData(data) {
|
export function validateImageData(data) {
|
||||||
@@ -167,7 +168,7 @@ export const imageToTensor = withErrorHandling(async function(image) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
canvas.width = image.width || image.naturalWidth;
|
canvas.width = image.width || image.naturalWidth;
|
||||||
canvas.height = image.height || image.naturalHeight;
|
canvas.height = image.height || image.naturalHeight;
|
||||||
@@ -204,7 +205,7 @@ export const tensorToImage = withErrorHandling(async function(tensor) {
|
|||||||
|
|
||||||
const [, height, width, channels] = tensor.shape;
|
const [, height, width, channels] = tensor.shape;
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
@@ -245,7 +246,7 @@ export const resizeImage = withErrorHandling(async function(image, maxWidth, max
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
const originalWidth = image.width || image.naturalWidth;
|
const originalWidth = image.width || image.naturalWidth;
|
||||||
const originalHeight = image.height || image.naturalHeight;
|
const originalHeight = image.height || image.naturalHeight;
|
||||||
@@ -291,7 +292,7 @@ export const imageToBase64 = withErrorHandling(function(image, format = 'png', q
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
canvas.width = image.width || image.naturalWidth;
|
canvas.width = image.width || image.naturalWidth;
|
||||||
canvas.height = image.height || image.naturalHeight;
|
canvas.height = image.height || image.naturalHeight;
|
||||||
@@ -373,7 +374,7 @@ export function createImageFromSource(source) {
|
|||||||
*/
|
*/
|
||||||
export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') {
|
export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
|
||||||
canvas.width = width;
|
canvas.width = width;
|
||||||
canvas.height = height;
|
canvas.height = height;
|
||||||
|
|||||||
@@ -130,7 +130,6 @@ class WebSocketManager {
|
|||||||
log.warn("WebSocket not open. Queuing message.");
|
log.warn("WebSocket not open. Queuing message.");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.messageQueue.push(message);
|
this.messageQueue.push(message);
|
||||||
if (!this.isConnecting) {
|
if (!this.isConnecting) {
|
||||||
this.connect();
|
this.connect();
|
||||||
@@ -147,7 +146,6 @@ class WebSocketManager {
|
|||||||
log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
|
log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
while (this.messageQueue.length > 0) {
|
while (this.messageQueue.length > 0) {
|
||||||
const message = this.messageQueue.shift();
|
const message = this.messageQueue.shift();
|
||||||
this.socket.send(message);
|
this.socket.send(message);
|
||||||
|
|||||||
174
js/utils/mask_utils.js
Normal file
174
js/utils/mask_utils.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import {createModuleLogger} from "./LoggerUtils.js";
|
||||||
|
|
||||||
|
const log = createModuleLogger('MaskUtils');
|
||||||
|
|
||||||
|
export function new_editor(app) {
|
||||||
|
if (!app) return false;
|
||||||
|
return app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor')
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_mask_editor_element(app) {
|
||||||
|
return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mask_editor_showing(app) {
|
||||||
|
const editor = get_mask_editor_element(app);
|
||||||
|
return editor && editor.style.display !== "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hide_mask_editor() {
|
||||||
|
if (mask_editor_showing()) document.getElementById('maskEditor').style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_mask_editor_cancel_button(app) {
|
||||||
|
|
||||||
|
const cancelButton = document.getElementById("maskEditor_topBarCancelButton");
|
||||||
|
if (cancelButton) {
|
||||||
|
log.debug("Found cancel button by ID: maskEditor_topBarCancelButton");
|
||||||
|
return cancelButton;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelSelectors = [
|
||||||
|
'button[onclick*="cancel"]',
|
||||||
|
'button[onclick*="Cancel"]',
|
||||||
|
'input[value="Cancel"]'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of cancelSelectors) {
|
||||||
|
try {
|
||||||
|
const button = document.querySelector(selector);
|
||||||
|
if (button) {
|
||||||
|
log.debug("Found cancel button with selector:", selector);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.warn("Invalid selector:", selector, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allButtons = document.querySelectorAll('button, input[type="button"]');
|
||||||
|
for (const button of allButtons) {
|
||||||
|
const text = button.textContent || button.value || '';
|
||||||
|
if (text.toLowerCase().includes('cancel')) {
|
||||||
|
log.debug("Found cancel button by text content:", text);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorElement = get_mask_editor_element(app);
|
||||||
|
if (editorElement) {
|
||||||
|
return editorElement?.parentElement?.lastChild?.childNodes[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function get_mask_editor_save_button(app) {
|
||||||
|
if (document.getElementById("maskEditor_topBarSaveButton")) return document.getElementById("maskEditor_topBarSaveButton")
|
||||||
|
return get_mask_editor_element(app)?.parentElement?.lastChild?.childNodes[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mask_editor_listen_for_cancel(app, callback) {
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 50; // 5 sekund
|
||||||
|
|
||||||
|
const findAndAttachListener = () => {
|
||||||
|
attempts++;
|
||||||
|
const cancel_button = get_mask_editor_cancel_button(app);
|
||||||
|
|
||||||
|
if (cancel_button && !cancel_button.filter_listener_added) {
|
||||||
|
log.info("Cancel button found, attaching listener");
|
||||||
|
cancel_button.addEventListener('click', callback);
|
||||||
|
cancel_button.filter_listener_added = true;
|
||||||
|
return true; // Znaleziono i podłączono
|
||||||
|
} else if (attempts < maxAttempts) {
|
||||||
|
|
||||||
|
setTimeout(findAndAttachListener, 100);
|
||||||
|
} else {
|
||||||
|
log.warn("Could not find cancel button after", maxAttempts, "attempts");
|
||||||
|
|
||||||
|
const globalClickHandler = (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
const text = target.textContent || target.value || '';
|
||||||
|
if (text.toLowerCase().includes('cancel') ||
|
||||||
|
target.id.toLowerCase().includes('cancel') ||
|
||||||
|
target.className.toLowerCase().includes('cancel')) {
|
||||||
|
log.info("Cancel detected via global click handler");
|
||||||
|
callback();
|
||||||
|
document.removeEventListener('click', globalClickHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', globalClickHandler);
|
||||||
|
log.debug("Added global click handler for cancel detection");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
findAndAttachListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function press_maskeditor_save(app) {
|
||||||
|
get_mask_editor_save_button(app)?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function press_maskeditor_cancel(app) {
|
||||||
|
get_mask_editor_cancel_button(app)?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uruchamia mask editor z predefiniowaną maską
|
||||||
|
* @param {Object} canvasInstance - Instancja Canvas
|
||||||
|
* @param {Image|HTMLCanvasElement} maskImage - Obraz maski do nałożenia
|
||||||
|
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
|
||||||
|
*/
|
||||||
|
export function start_mask_editor_with_predefined_mask(canvasInstance, maskImage, sendCleanImage = true) {
|
||||||
|
if (!canvasInstance || !maskImage) {
|
||||||
|
log.error('Canvas instance and mask image are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasInstance.startMaskEditor(maskImage, sendCleanImage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska)
|
||||||
|
* @param {Object} canvasInstance - Instancja Canvas
|
||||||
|
*/
|
||||||
|
export function start_mask_editor_auto(canvasInstance) {
|
||||||
|
if (!canvasInstance) {
|
||||||
|
log.error('Canvas instance is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
canvasInstance.startMaskEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tworzy maskę z obrazu dla użycia w mask editorze
|
||||||
|
* @param {string} imageSrc - Źródło obrazu (URL lub data URL)
|
||||||
|
* @returns {Promise<Image>} Promise zwracający obiekt Image
|
||||||
|
*/
|
||||||
|
export function create_mask_from_image_src(imageSrc) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = imageSrc;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konwertuje canvas do Image dla użycia jako maska
|
||||||
|
* @param {HTMLCanvasElement} canvas - Canvas do konwersji
|
||||||
|
* @returns {Promise<Image>} Promise zwracający obiekt Image
|
||||||
|
*/
|
||||||
|
export function canvas_to_mask_image(canvas) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = reject;
|
||||||
|
img.src = canvas.toDataURL();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user