mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 21:12:12 -03:00
project migration to typescript
Project migration to typescript
This commit is contained in:
524
src/utils/ClipboardManager.ts
Normal file
524
src/utils/ClipboardManager.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
|
||||
// @ts-ignore
|
||||
import {api} from "../../../scripts/api.js";
|
||||
// @ts-ignore
|
||||
import {app} from "../../../scripts/app.js";
|
||||
// @ts-ignore
|
||||
import {ComfyApp} from "../../../scripts/app.js";
|
||||
|
||||
import type { AddMode, CanvasForClipboard, ClipboardPreference } from "../types.js";
|
||||
|
||||
const log = createModuleLogger('ClipboardManager');
|
||||
|
||||
export class ClipboardManager {
|
||||
canvas: CanvasForClipboard;
|
||||
clipboardPreference: ClipboardPreference;
|
||||
constructor(canvas: CanvasForClipboard) {
|
||||
this.canvas = canvas;
|
||||
this.clipboardPreference = 'system'; // 'system', 'clipspace'
|
||||
}
|
||||
|
||||
/**
|
||||
* Main paste handler that delegates to appropriate methods
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @param {ClipboardPreference} preference - Clipboard preference ('system' or 'clipspace')
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async handlePaste(addMode: AddMode = 'mouse', preference: ClipboardPreference = 'system'): Promise<boolean> {
|
||||
try {
|
||||
log.info(`ClipboardManager handling paste with preference: ${preference}`);
|
||||
|
||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||
log.info("Found layers in internal clipboard, pasting layers");
|
||||
this.canvas.canvasLayers.pasteLayers();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (preference === 'clipspace') {
|
||||
log.info("Attempting paste from ComfyUI Clipspace");
|
||||
const success = await this.tryClipspacePaste(addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
log.info("No image found in ComfyUI Clipspace");
|
||||
}
|
||||
|
||||
log.info("Attempting paste from system clipboard");
|
||||
return await this.trySystemClipboardPaste(addMode);
|
||||
|
||||
} catch (err) {
|
||||
log.error("ClipboardManager paste operation failed:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to paste from ComfyUI Clipspace
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async tryClipspacePaste(addMode: AddMode): Promise<boolean> {
|
||||
try {
|
||||
log.info("Attempting to paste from ComfyUI Clipspace");
|
||||
ComfyApp.pasteFromClipspace(this.canvas.node);
|
||||
|
||||
if (this.canvas.node.imgs && this.canvas.node.imgs.length > 0) {
|
||||
const clipspaceImage = this.canvas.node.imgs[0];
|
||||
if (clipspaceImage && clipspaceImage.src) {
|
||||
log.info("Successfully got image from ComfyUI Clipspace");
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
};
|
||||
img.src = clipspaceImage.src;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (clipspaceError) {
|
||||
log.warn("ComfyUI Clipspace paste failed:", clipspaceError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* System clipboard paste - handles both image data and text paths
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async trySystemClipboardPaste(addMode: AddMode): Promise<boolean> {
|
||||
log.info("ClipboardManager: Checking system clipboard for images and paths");
|
||||
|
||||
if (navigator.clipboard?.read) {
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
|
||||
for (const item of clipboardItems) {
|
||||
log.debug("Clipboard item types:", item.types);
|
||||
|
||||
const imageType = item.types.find(type => type.startsWith('image/'));
|
||||
if (imageType) {
|
||||
try {
|
||||
const blob = await item.getType(imageType);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from system clipboard");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
};
|
||||
if (event.target?.result) {
|
||||
img.src = event.target.result as string;
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
log.info("Found image data in system clipboard");
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.debug("Error reading image data:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const textTypes = ['text/plain', 'text/uri-list'];
|
||||
for (const textType of textTypes) {
|
||||
if (item.types.includes(textType)) {
|
||||
try {
|
||||
const textBlob = await item.getType(textType);
|
||||
const text = await textBlob.text();
|
||||
|
||||
if (this.isValidImagePath(text)) {
|
||||
log.info("Found image path in clipboard:", text);
|
||||
const success = await this.loadImageFromPath(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug(`Error reading ${textType}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug("Modern clipboard API failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.readText) {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
log.debug("Found text in clipboard:", text);
|
||||
|
||||
if (text && this.isValidImagePath(text)) {
|
||||
log.info("Found valid image path in clipboard:", text);
|
||||
const success = await this.loadImageFromPath(text, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug("Could not read text from clipboard:", error);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("No images or valid image paths found in system clipboard");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validates if a text string is a valid image file path or URL
|
||||
* @param {string} text - The text to validate
|
||||
* @returns {boolean} - True if the text appears to be a valid image file path or URL
|
||||
*/
|
||||
isValidImagePath(text: string): boolean {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
text = text.trim();
|
||||
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (text.startsWith('http://') || text.startsWith('https://') || text.startsWith('file://')) {
|
||||
|
||||
try {
|
||||
new URL(text);
|
||||
log.debug("Detected valid URL:", text);
|
||||
return true;
|
||||
} catch (e) {
|
||||
log.debug("Invalid URL format:", text);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const imageExtensions = [
|
||||
'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp',
|
||||
'.svg', '.tiff', '.tif', '.ico', '.avif'
|
||||
];
|
||||
|
||||
const hasImageExtension = imageExtensions.some(ext =>
|
||||
text.toLowerCase().endsWith(ext)
|
||||
);
|
||||
|
||||
if (!hasImageExtension) {
|
||||
log.debug("No valid image extension found in:", text);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
const pathPatterns = [
|
||||
/^[a-zA-Z]:[\\\/]/, // Windows absolute path (C:\... or C:/...)
|
||||
/^[\\\/]/, // Unix absolute path (/...)
|
||||
/^\.{1,2}[\\\/]/, // Relative path (./... or ../...)
|
||||
/^[^\\\/]*[\\\/]/ // Contains path separators
|
||||
];
|
||||
|
||||
const isValidPath = pathPatterns.some(pattern => pattern.test(text)) ||
|
||||
(!text.includes('/') && !text.includes('\\') && text.includes('.')); // Simple filename
|
||||
|
||||
if (isValidPath) {
|
||||
log.debug("Detected valid local file path:", text);
|
||||
} else {
|
||||
log.debug("Invalid local file path format:", text);
|
||||
}
|
||||
|
||||
return isValidPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load an image from a file path using simplified methods
|
||||
* @param {string} filePath - The file path to load
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async loadImageFromPath(filePath: string, addMode: AddMode): Promise<boolean> {
|
||||
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
return new Promise((resolve) => {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from URL");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load image from URL:", filePath);
|
||||
resolve(false);
|
||||
};
|
||||
img.src = filePath;
|
||||
});
|
||||
} catch (error) {
|
||||
log.warn("Error loading image from URL:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("Attempting to load local file via backend");
|
||||
const success = await this.loadFileViaBackend(filePath, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Backend loading failed:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("Falling back to file picker");
|
||||
const success = await this.promptUserForFile(filePath, addMode);
|
||||
if (success) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("File picker failed:", error);
|
||||
}
|
||||
|
||||
this.showFilePathMessage(filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a local file via the ComfyUI backend endpoint
|
||||
* @param {string} filePath - The file path to load
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async loadFileViaBackend(filePath: string, addMode: AddMode): Promise<boolean> {
|
||||
try {
|
||||
log.info("Loading file via ComfyUI backend:", filePath);
|
||||
|
||||
const response = await api.fetchApi("/ycnode/load_image_from_path", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
log.debug("Backend failed to load image:", errorData.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
log.debug("Backend returned error:", data.error);
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info("Successfully loaded image via ComfyUI backend:", filePath);
|
||||
|
||||
const img = new Image();
|
||||
const success: boolean = await new Promise((resolve) => {
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from backend response");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load image from backend response");
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
img.src = data.image_data;
|
||||
});
|
||||
|
||||
return success;
|
||||
|
||||
} catch (error) {
|
||||
log.debug("Error loading file via ComfyUI backend:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to select a file when a local path is detected
|
||||
* @param {string} originalPath - The original file path from clipboard
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
* @returns {Promise<boolean>} - True if successful, false otherwise
|
||||
*/
|
||||
async promptUserForFile(originalPath: string, addMode: AddMode): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/*';
|
||||
fileInput.style.display = 'none';
|
||||
|
||||
const fileName = originalPath.split(/[\\\/]/).pop();
|
||||
|
||||
fileInput.onchange = async (event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
try {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
log.info("Successfully loaded image from file picker");
|
||||
await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode);
|
||||
resolve(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
log.warn("Failed to load selected image");
|
||||
resolve(false);
|
||||
};
|
||||
if (e.target?.result) {
|
||||
img.src = e.target.result as string;
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
log.warn("Failed to read selected file");
|
||||
resolve(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
log.warn("Error processing selected file:", error);
|
||||
resolve(false);
|
||||
}
|
||||
} else {
|
||||
log.warn("Selected file is not an image");
|
||||
resolve(false);
|
||||
}
|
||||
|
||||
document.body.removeChild(fileInput);
|
||||
};
|
||||
|
||||
fileInput.oncancel = () => {
|
||||
log.info("File selection cancelled by user");
|
||||
document.body.removeChild(fileInput);
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
this.showNotification(`Detected image path: ${fileName}. Please select the file to load it.`, 3000);
|
||||
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a message to the user about file path limitations
|
||||
* @param {string} filePath - The file path that couldn't be loaded
|
||||
*/
|
||||
showFilePathMessage(filePath: string): void {
|
||||
const fileName = filePath.split(/[\\\/]/).pop();
|
||||
const message = `Cannot load local file directly due to browser security restrictions. File detected: ${fileName}`;
|
||||
this.showNotification(message, 5000);
|
||||
log.info("Showed file path limitation message to user");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a helpful message when clipboard appears empty and offers file picker
|
||||
* @param {AddMode} addMode - The mode for adding the layer
|
||||
*/
|
||||
showEmptyClipboardMessage(addMode: AddMode): void {
|
||||
const message = `Copied a file? Browser can't access file paths for security. Click here to select the file manually.`;
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #2d5aa0;
|
||||
color: white;
|
||||
padding: 14px 18px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
z-index: 10001;
|
||||
max-width: 320px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
border: 2px solid #4a7bc8;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
`;
|
||||
notification.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 18px;">📁</span>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; opacity: 0.9; margin-top: 4px;">
|
||||
💡 Tip: You can also drag & drop files directly onto the canvas
|
||||
</div>
|
||||
`;
|
||||
|
||||
notification.onmouseenter = () => {
|
||||
notification.style.backgroundColor = '#3d6bb0';
|
||||
notification.style.borderColor = '#5a8bd8';
|
||||
notification.style.transform = 'translateY(-1px)';
|
||||
};
|
||||
notification.onmouseleave = () => {
|
||||
notification.style.backgroundColor = '#2d5aa0';
|
||||
notification.style.borderColor = '#4a7bc8';
|
||||
notification.style.transform = 'translateY(0)';
|
||||
};
|
||||
|
||||
notification.onclick = async () => {
|
||||
document.body.removeChild(notification);
|
||||
try {
|
||||
const success = await this.promptUserForFile('image_file.jpg', addMode);
|
||||
if (success) {
|
||||
log.info("Successfully loaded image via empty clipboard file picker");
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("Error with empty clipboard file picker:", error);
|
||||
}
|
||||
};
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, 12000);
|
||||
|
||||
log.info("Showed enhanced empty clipboard message with file picker option");
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a temporary notification to the user
|
||||
* @param {string} message - The message to show
|
||||
* @param {number} duration - Duration in milliseconds
|
||||
*/
|
||||
showNotification(message: string, duration = 3000): void {
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
z-index: 10001;
|
||||
max-width: 300px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
notification.textContent = message;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.parentNode.removeChild(notification);
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
289
src/utils/CommonUtils.ts
Normal file
289
src/utils/CommonUtils.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import type { Layer } from '../types';
|
||||
|
||||
/**
|
||||
* CommonUtils - Wspólne funkcje pomocnicze
|
||||
* Eliminuje duplikację funkcji używanych w różnych modułach
|
||||
*/
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generuje unikalny identyfikator UUID
|
||||
* @returns {string} UUID w formacie xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
||||
*/
|
||||
export function generateUUID(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Funkcja snap do siatki
|
||||
* @param {number} value - Wartość do przyciągnięcia
|
||||
* @param {number} gridSize - Rozmiar siatki (domyślnie 64)
|
||||
* @returns {number} Wartość przyciągnięta do siatki
|
||||
*/
|
||||
export function snapToGrid(value: number, gridSize = 64): number {
|
||||
return Math.round(value / gridSize) * gridSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Oblicza dostosowanie snap dla warstwy
|
||||
* @param {Object} layer - Obiekt warstwy
|
||||
* @param {number} gridSize - Rozmiar siatki
|
||||
* @param {number} snapThreshold - Próg przyciągania
|
||||
* @returns {Point} Obiekt z dx i dy
|
||||
*/
|
||||
export function getSnapAdjustment(layer: Layer, gridSize = 64, snapThreshold = 10): Point {
|
||||
if (!layer) {
|
||||
return {x: 0, y: 0};
|
||||
}
|
||||
|
||||
const layerEdges = {
|
||||
left: layer.x,
|
||||
right: layer.x + layer.width,
|
||||
top: layer.y,
|
||||
bottom: layer.y + layer.height
|
||||
};
|
||||
|
||||
const x_adjustments = [
|
||||
{type: 'x', delta: snapToGrid(layerEdges.left, gridSize) - layerEdges.left},
|
||||
{type: 'x', delta: snapToGrid(layerEdges.right, gridSize) - layerEdges.right}
|
||||
].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
|
||||
|
||||
const y_adjustments = [
|
||||
{type: 'y', delta: snapToGrid(layerEdges.top, gridSize) - layerEdges.top},
|
||||
{type: 'y', delta: snapToGrid(layerEdges.bottom, gridSize) - layerEdges.bottom}
|
||||
].map(adj => ({ ...adj, abs: Math.abs(adj.delta) }));
|
||||
|
||||
const bestXSnap = x_adjustments
|
||||
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
||||
.sort((a, b) => a.abs - b.abs)[0];
|
||||
const bestYSnap = y_adjustments
|
||||
.filter(adj => adj.abs < snapThreshold && adj.abs > 1e-9)
|
||||
.sort((a, b) => a.abs - b.abs)[0];
|
||||
|
||||
return {
|
||||
x: bestXSnap ? bestXSnap.delta : 0,
|
||||
y: bestYSnap ? bestYSnap.delta : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje współrzędne świata na lokalne
|
||||
* @param {number} worldX - Współrzędna X w świecie
|
||||
* @param {number} worldY - Współrzędna Y w świecie
|
||||
* @param {any} layerProps - Właściwości warstwy
|
||||
* @returns {Point} Lokalne współrzędne {x, y}
|
||||
*/
|
||||
export function worldToLocal(worldX: number, worldY: number, layerProps: { centerX: number, centerY: number, rotation: number }): Point {
|
||||
const dx = worldX - layerProps.centerX;
|
||||
const dy = worldY - layerProps.centerY;
|
||||
const rad = -layerProps.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
return {
|
||||
x: dx * cos - dy * sin,
|
||||
y: dx * sin + dy * cos
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje współrzędne lokalne na świat
|
||||
* @param {number} localX - Lokalna współrzędna X
|
||||
* @param {number} localY - Lokalna współrzędna Y
|
||||
* @param {any} layerProps - Właściwości warstwy
|
||||
* @returns {Point} Współrzędne świata {x, y}
|
||||
*/
|
||||
export function localToWorld(localX: number, localY: number, layerProps: { centerX: number, centerY: number, rotation: number }): Point {
|
||||
const rad = layerProps.rotation * Math.PI / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
return {
|
||||
x: layerProps.centerX + localX * cos - localY * sin,
|
||||
y: layerProps.centerY + localX * sin + localY * cos
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Klonuje warstwy (bez klonowania obiektów Image dla oszczędności pamięci)
|
||||
* @param {Layer[]} layers - Tablica warstw do sklonowania
|
||||
* @returns {Layer[]} Sklonowane warstwy
|
||||
*/
|
||||
export function cloneLayers(layers: Layer[]): Layer[] {
|
||||
return layers.map(layer => ({ ...layer }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy sygnaturę stanu warstw (dla porównań)
|
||||
* @param {Layer[]} layers - Tablica warstw
|
||||
* @returns {string} Sygnatura JSON
|
||||
*/
|
||||
export function getStateSignature(layers: Layer[]): string {
|
||||
return JSON.stringify(layers.map((layer, index) => {
|
||||
const sig: any = {
|
||||
index: index,
|
||||
x: Math.round(layer.x * 100) / 100, // Round to avoid floating point precision issues
|
||||
y: Math.round(layer.y * 100) / 100,
|
||||
width: Math.round(layer.width * 100) / 100,
|
||||
height: Math.round(layer.height * 100) / 100,
|
||||
rotation: Math.round((layer.rotation || 0) * 100) / 100,
|
||||
zIndex: layer.zIndex,
|
||||
blendMode: layer.blendMode || 'normal',
|
||||
opacity: layer.opacity !== undefined ? Math.round(layer.opacity * 100) / 100 : 1
|
||||
};
|
||||
|
||||
if (layer.imageId) {
|
||||
sig.imageId = layer.imageId;
|
||||
}
|
||||
|
||||
if (layer.image && layer.image.src) {
|
||||
sig.imageSrc = layer.image.src.substring(0, 100); // First 100 chars to avoid huge signatures
|
||||
}
|
||||
|
||||
return sig;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce funkcja - opóźnia wykonanie funkcji
|
||||
* @param {Function} func - Funkcja do wykonania
|
||||
* @param {number} wait - Czas oczekiwania w ms
|
||||
* @param {boolean} immediate - Czy wykonać natychmiast
|
||||
* @returns {(...args: any[]) => void} Funkcja z debounce
|
||||
*/
|
||||
export function debounce(func: (...args: any[]) => void, wait: number, immediate?: boolean): (...args: any[]) => void {
|
||||
let timeout: number | null;
|
||||
return function executedFunction(this: any, ...args: any[]) {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
if (!immediate) func.apply(this, args);
|
||||
};
|
||||
const callNow = immediate && !timeout;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = window.setTimeout(later, wait);
|
||||
if (callNow) func.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle funkcja - ogranicza częstotliwość wykonania
|
||||
* @param {Function} func - Funkcja do wykonania
|
||||
* @param {number} limit - Limit czasu w ms
|
||||
* @returns {(...args: any[]) => void} Funkcja z throttle
|
||||
*/
|
||||
export function throttle(func: (...args: any[]) => void, limit: number): (...args: any[]) => void {
|
||||
let inThrottle: boolean;
|
||||
return function(this: any, ...args: any[]) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ogranicza wartość do zakresu
|
||||
* @param {number} value - Wartość do ograniczenia
|
||||
* @param {number} min - Minimalna wartość
|
||||
* @param {number} max - Maksymalna wartość
|
||||
* @returns {number} Ograniczona wartość
|
||||
*/
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolacja liniowa między dwoma wartościami
|
||||
* @param {number} start - Wartość początkowa
|
||||
* @param {number} end - Wartość końcowa
|
||||
* @param {number} factor - Współczynnik interpolacji (0-1)
|
||||
* @returns {number} Interpolowana wartość
|
||||
*/
|
||||
export function lerp(start: number, end: number, factor: number): number {
|
||||
return start + (end - start) * factor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje stopnie na radiany
|
||||
* @param {number} degrees - Stopnie
|
||||
* @returns {number} Radiany
|
||||
*/
|
||||
export function degreesToRadians(degrees: number): number {
|
||||
return degrees * Math.PI / 180;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje radiany na stopnie
|
||||
* @param {number} radians - Radiany
|
||||
* @returns {number} Stopnie
|
||||
*/
|
||||
export function radiansToDegrees(radians: number): number {
|
||||
return radians * 180 / Math.PI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy canvas z kontekstem - eliminuje duplikaty w kodzie
|
||||
* @param {number} width - Szerokość canvas
|
||||
* @param {number} height - Wysokość canvas
|
||||
* @param {string} contextType - Typ kontekstu (domyślnie '2d')
|
||||
* @param {object} contextOptions - Opcje kontekstu
|
||||
* @returns {{canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null}} Obiekt z canvas i ctx
|
||||
*/
|
||||
export function createCanvas(width: number, height: number, contextType = '2d', contextOptions: any = {}): { canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D | null } {
|
||||
const canvas = document.createElement('canvas');
|
||||
if (width) canvas.width = width;
|
||||
if (height) canvas.height = height;
|
||||
const ctx = canvas.getContext(contextType, contextOptions) as CanvasRenderingContext2D | null;
|
||||
return { canvas, ctx };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizuje wartość do zakresu Uint8 (0-255)
|
||||
* @param {number} value - Wartość do znormalizowania (0-1)
|
||||
* @returns {number} Wartość w zakresie 0-255
|
||||
*/
|
||||
export function normalizeToUint8(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generuje unikalną nazwę pliku z identyfikatorem node-a
|
||||
* @param {string} baseName - Podstawowa nazwa pliku
|
||||
* @param {string | number} nodeId - Identyfikator node-a
|
||||
* @returns {string} Unikalna nazwa pliku
|
||||
*/
|
||||
export function generateUniqueFileName(baseName: string, nodeId: string | number): string {
|
||||
const nodePattern = new RegExp(`_node_${nodeId}(?:_node_\\d+)*`);
|
||||
if (nodePattern.test(baseName)) {
|
||||
const cleanName = baseName.replace(/_node_\d+/g, '');
|
||||
const extension = cleanName.split('.').pop();
|
||||
const nameWithoutExt = cleanName.replace(`.${extension}`, '');
|
||||
return `${nameWithoutExt}_node_${nodeId}.${extension}`;
|
||||
}
|
||||
const extension = baseName.split('.').pop();
|
||||
const nameWithoutExt = baseName.replace(`.${extension}`, '');
|
||||
return `${nameWithoutExt}_node_${nodeId}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sprawdza czy punkt jest w prostokącie
|
||||
* @param {number} pointX - X punktu
|
||||
* @param {number} pointY - Y punktu
|
||||
* @param {number} rectX - X prostokąta
|
||||
* @param {number} rectY - Y prostokąta
|
||||
* @param {number} rectWidth - Szerokość prostokąta
|
||||
* @param {number} rectHeight - Wysokość prostokąta
|
||||
* @returns {boolean} Czy punkt jest w prostokącie
|
||||
*/
|
||||
export function isPointInRect(pointX: number, pointY: number, rectX: number, rectY: number, rectWidth: number, rectHeight: number): boolean {
|
||||
return pointX >= rectX && pointX <= rectX + rectWidth &&
|
||||
pointY >= rectY && pointY <= rectY + rectHeight;
|
||||
}
|
||||
353
src/utils/ImageUtils.ts
Normal file
353
src/utils/ImageUtils.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import {withErrorHandling, createValidationError} from "../ErrorHandler.js";
|
||||
import type { Tensor, ImageDataPixel } from '../types';
|
||||
|
||||
const log = createModuleLogger('ImageUtils');
|
||||
|
||||
export function validateImageData(data: any): boolean {
|
||||
log.debug("Validating data structure:", {
|
||||
hasData: !!data,
|
||||
type: typeof data,
|
||||
isArray: Array.isArray(data),
|
||||
keys: data ? Object.keys(data) : null,
|
||||
shape: data?.shape,
|
||||
dataType: data?.data ? data.data.constructor.name : null,
|
||||
fullData: data
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
log.info("Data is null or undefined");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
log.debug("Data is array, getting first element");
|
||||
data = data[0];
|
||||
}
|
||||
|
||||
if (!data || typeof data !== 'object') {
|
||||
log.info("Invalid data type");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!data.data) {
|
||||
log.info("Missing data property");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(data.data instanceof Float32Array)) {
|
||||
try {
|
||||
data.data = new Float32Array(data.data);
|
||||
} catch (e) {
|
||||
log.error("Failed to convert data to Float32Array:", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function convertImageData(data: any): ImageDataPixel {
|
||||
log.info("Converting image data:", data);
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
data = data[0];
|
||||
}
|
||||
|
||||
const shape = data.shape;
|
||||
const height = shape[1];
|
||||
const width = shape[2];
|
||||
const channels = shape[3];
|
||||
const floatData = new Float32Array(data.data);
|
||||
|
||||
log.debug("Processing dimensions:", {height, width, channels});
|
||||
|
||||
const rgbaData = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
for (let h = 0; h < height; h++) {
|
||||
for (let w = 0; w < width; w++) {
|
||||
const pixelIndex = (h * width + w) * 4;
|
||||
const tensorIndex = (h * width + w) * channels;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const value = floatData[tensorIndex + c];
|
||||
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
rgbaData[pixelIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: rgbaData,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
|
||||
export function applyMaskToImageData(imageData: ImageDataPixel, maskData: Tensor): ImageDataPixel {
|
||||
log.info("Applying mask to image data");
|
||||
|
||||
const rgbaData = new Uint8ClampedArray(imageData.data);
|
||||
const width = imageData.width;
|
||||
const height = imageData.height;
|
||||
|
||||
const maskShape = maskData.shape;
|
||||
const maskFloatData = new Float32Array(maskData.data);
|
||||
|
||||
log.debug(`Applying mask of shape: ${maskShape}`);
|
||||
|
||||
for (let h = 0; h < height; h++) {
|
||||
for (let w = 0; w < width; w++) {
|
||||
const pixelIndex = (h * width + w) * 4;
|
||||
const maskIndex = h * width + w;
|
||||
|
||||
const alpha = maskFloatData[maskIndex];
|
||||
rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255)));
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Mask application completed");
|
||||
|
||||
return {
|
||||
data: rgbaData,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}
|
||||
|
||||
export const prepareImageForCanvas = withErrorHandling(function (inputImage: any): ImageDataPixel {
|
||||
log.info("Preparing image for canvas:", inputImage);
|
||||
|
||||
if (Array.isArray(inputImage)) {
|
||||
inputImage = inputImage[0];
|
||||
}
|
||||
|
||||
if (!inputImage || !inputImage.shape || !inputImage.data) {
|
||||
throw createValidationError("Invalid input image format", {inputImage});
|
||||
}
|
||||
|
||||
const shape = inputImage.shape;
|
||||
const height = shape[1];
|
||||
const width = shape[2];
|
||||
const channels = shape[3];
|
||||
const floatData = new Float32Array(inputImage.data);
|
||||
|
||||
log.debug("Image dimensions:", {height, width, channels});
|
||||
|
||||
const rgbaData = new Uint8ClampedArray(width * height * 4);
|
||||
|
||||
for (let h = 0; h < height; h++) {
|
||||
for (let w = 0; w < width; w++) {
|
||||
const pixelIndex = (h * width + w) * 4;
|
||||
const tensorIndex = (h * width + w) * channels;
|
||||
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const value = floatData[tensorIndex + c];
|
||||
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
|
||||
}
|
||||
|
||||
rgbaData[pixelIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: rgbaData,
|
||||
width: width,
|
||||
height: height
|
||||
};
|
||||
}, 'prepareImageForCanvas');
|
||||
|
||||
export const imageToTensor = withErrorHandling(async function (image: HTMLImageElement | HTMLCanvasElement): Promise<Tensor> {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = new Float32Array(canvas.width * canvas.height * 3);
|
||||
|
||||
for (let i = 0; i < imageData.data.length; i += 4) {
|
||||
const pixelIndex = i / 4;
|
||||
data[pixelIndex * 3] = imageData.data[i] / 255;
|
||||
data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255;
|
||||
data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255;
|
||||
}
|
||||
|
||||
return {
|
||||
data: data,
|
||||
shape: [1, canvas.height, canvas.width, 3],
|
||||
width: canvas.width,
|
||||
height: canvas.height
|
||||
};
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'imageToTensor');
|
||||
|
||||
export const tensorToImage = withErrorHandling(async function (tensor: Tensor): Promise<HTMLImageElement> {
|
||||
if (!tensor || !tensor.data || !tensor.shape) {
|
||||
throw createValidationError("Invalid tensor format", {tensor});
|
||||
}
|
||||
|
||||
const [, height, width, channels] = tensor.shape;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(width, height);
|
||||
const data = tensor.data;
|
||||
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const pixelIndex = i * 4;
|
||||
const tensorIndex = i * channels;
|
||||
|
||||
imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255);
|
||||
imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255);
|
||||
imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255);
|
||||
imageData.data[pixelIndex + 3] = 255;
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'tensorToImage');
|
||||
|
||||
export const resizeImage = withErrorHandling(async function (image: HTMLImageElement, maxWidth: number, maxHeight: number): Promise<HTMLImageElement> {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
const originalWidth = image.width;
|
||||
const originalHeight = image.height;
|
||||
const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight);
|
||||
const newWidth = Math.round(originalWidth * scale);
|
||||
const newHeight = Math.round(originalHeight * scale);
|
||||
|
||||
canvas.width = newWidth;
|
||||
canvas.height = newHeight;
|
||||
|
||||
if (ctx) {
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(image, 0, 0, newWidth, newHeight);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'resizeImage');
|
||||
|
||||
export const createThumbnail = withErrorHandling(async function (image: HTMLImageElement, size = 128): Promise<HTMLImageElement> {
|
||||
return resizeImage(image, size, size);
|
||||
}, 'createThumbnail');
|
||||
|
||||
export const imageToBase64 = withErrorHandling(function (image: HTMLImageElement | HTMLCanvasElement, format = 'png', quality = 0.9): string {
|
||||
if (!image) {
|
||||
throw createValidationError("Image is required");
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
canvas.width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
|
||||
canvas.height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
|
||||
|
||||
if (ctx) {
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const mimeType = `image/${format}`;
|
||||
return canvas.toDataURL(mimeType, quality);
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'imageToBase64');
|
||||
|
||||
export const base64ToImage = withErrorHandling(function (base64: string): Promise<HTMLImageElement> {
|
||||
if (!base64) {
|
||||
throw createValidationError("Base64 string is required");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Failed to load image from base64"));
|
||||
img.src = base64;
|
||||
});
|
||||
}, 'base64ToImage');
|
||||
|
||||
export function isValidImage(image: any): image is HTMLImageElement | HTMLCanvasElement {
|
||||
return image &&
|
||||
(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) &&
|
||||
image.width > 0 &&
|
||||
image.height > 0;
|
||||
}
|
||||
|
||||
export function getImageInfo(image: HTMLImageElement | HTMLCanvasElement): {width: number, height: number, aspectRatio: number, area: number} | null {
|
||||
if (!isValidImage(image)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width;
|
||||
const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
aspectRatio: width / height,
|
||||
area: width * height
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageFromSource(source: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
export const createEmptyImage = withErrorHandling(function (width: number, height: number, color = 'transparent'): Promise<HTMLImageElement> {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
if (ctx) {
|
||||
if (color !== 'transparent') {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
throw new Error("Canvas context not available");
|
||||
}, 'createEmptyImage');
|
||||
92
src/utils/LoggerUtils.ts
Normal file
92
src/utils/LoggerUtils.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* LoggerUtils - Centralizacja inicjalizacji loggerów
|
||||
* Eliminuje powtarzalny kod inicjalizacji loggera w każdym module
|
||||
*/
|
||||
|
||||
import {logger, LogLevel} from "../logger.js";
|
||||
import { LOG_LEVEL } from '../config.js';
|
||||
|
||||
export interface Logger {
|
||||
debug: (...args: any[]) => void;
|
||||
info: (...args: any[]) => void;
|
||||
warn: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy obiekt loggera dla modułu z predefiniowanymi metodami
|
||||
* @param {string} moduleName - Nazwa modułu
|
||||
* @returns {Logger} Obiekt z metodami logowania
|
||||
*/
|
||||
export function createModuleLogger(moduleName: string): Logger {
|
||||
logger.setModuleLevel(moduleName, LogLevel[LOG_LEVEL as keyof typeof LogLevel]);
|
||||
|
||||
return {
|
||||
debug: (...args: any[]) => logger.debug(moduleName, ...args),
|
||||
info: (...args: any[]) => logger.info(moduleName, ...args),
|
||||
warn: (...args: any[]) => logger.warn(moduleName, ...args),
|
||||
error: (...args: any[]) => logger.error(moduleName, ...args)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy logger z automatycznym wykrywaniem nazwy modułu z URL
|
||||
* @returns {Logger} Obiekt z metodami logowania
|
||||
*/
|
||||
export function createAutoLogger(): Logger {
|
||||
const stack = new Error().stack;
|
||||
const match = stack?.match(/\/([^\/]+)\.js/);
|
||||
const moduleName = match ? match[1] : 'Unknown';
|
||||
|
||||
return createModuleLogger(moduleName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper dla operacji z automatycznym logowaniem błędów
|
||||
* @param {Function} operation - Operacja do wykonania
|
||||
* @param {Logger} log - Obiekt loggera
|
||||
* @param {string} operationName - Nazwa operacji (dla logów)
|
||||
* @returns {Function} Opakowana funkcja
|
||||
*/
|
||||
export function withErrorLogging<T extends (...args: any[]) => any>(
|
||||
operation: T,
|
||||
log: Logger,
|
||||
operationName: string
|
||||
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
|
||||
return async function(this: any, ...args: Parameters<T>): Promise<ReturnType<T>> {
|
||||
try {
|
||||
log.debug(`Starting ${operationName}`);
|
||||
const result = await operation.apply(this, args);
|
||||
log.debug(`Completed ${operationName}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
log.error(`Error in ${operationName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator dla metod klasy z automatycznym logowaniem
|
||||
* @param {Logger} log - Obiekt loggera
|
||||
* @param {string} methodName - Nazwa metody
|
||||
*/
|
||||
export function logMethod(log: Logger, methodName?: string) {
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
try {
|
||||
log.debug(`${methodName || propertyKey} started`);
|
||||
const result = await originalMethod.apply(this, args);
|
||||
log.debug(`${methodName || propertyKey} completed`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
log.error(`${methodName || propertyKey} failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
32
src/utils/ResourceManager.ts
Normal file
32
src/utils/ResourceManager.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// @ts-ignore
|
||||
import { $el } from "../../../scripts/ui.js";
|
||||
|
||||
export function addStylesheet(url: string): void {
|
||||
if (url.endsWith(".js")) {
|
||||
url = url.substr(0, url.length - 2) + "css";
|
||||
}
|
||||
$el("link", {
|
||||
parent: document.head,
|
||||
rel: "stylesheet",
|
||||
type: "text/css",
|
||||
href: url.startsWith("http") ? url : getUrl(url),
|
||||
});
|
||||
}
|
||||
|
||||
export function getUrl(path: string, baseUrl?: string | URL): string {
|
||||
if (baseUrl) {
|
||||
return new URL(path, baseUrl).toString();
|
||||
} else {
|
||||
// @ts-ignore
|
||||
return new URL("../" + path, import.meta.url).toString();
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadTemplate(path: string, baseUrl?: string | URL): Promise<string> {
|
||||
const url = getUrl(path, baseUrl);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load template: ${url}`);
|
||||
}
|
||||
return await response.text();
|
||||
}
|
||||
166
src/utils/WebSocketManager.ts
Normal file
166
src/utils/WebSocketManager.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import type { WebSocketMessage, AckCallbacks } from "../types.js";
|
||||
|
||||
const log = createModuleLogger('WebSocketManager');
|
||||
|
||||
class WebSocketManager {
|
||||
private socket: WebSocket | null;
|
||||
private messageQueue: string[];
|
||||
private isConnecting: boolean;
|
||||
private reconnectAttempts: number;
|
||||
private readonly maxReconnectAttempts: number;
|
||||
private readonly reconnectInterval: number;
|
||||
private ackCallbacks: AckCallbacks;
|
||||
private messageIdCounter: number;
|
||||
|
||||
constructor(private url: string) {
|
||||
this.socket = null;
|
||||
this.messageQueue = [];
|
||||
this.isConnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectInterval = 5000; // 5 seconds
|
||||
this.ackCallbacks = new Map();
|
||||
this.messageIdCounter = 0;
|
||||
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
log.debug("WebSocket is already open.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isConnecting) {
|
||||
log.debug("Connection attempt already in progress.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
log.info(`Connecting to WebSocket at ${this.url}...`);
|
||||
|
||||
try {
|
||||
this.socket = new WebSocket(this.url);
|
||||
|
||||
this.socket.onopen = () => {
|
||||
this.isConnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
log.info("WebSocket connection established.");
|
||||
this.flushMessageQueue();
|
||||
};
|
||||
|
||||
this.socket.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const data: WebSocketMessage = JSON.parse(event.data);
|
||||
log.debug("Received message:", data);
|
||||
|
||||
if (data.type === 'ack' && data.nodeId) {
|
||||
const callback = this.ackCallbacks.get(data.nodeId);
|
||||
if (callback) {
|
||||
log.debug(`ACK received for nodeId: ${data.nodeId}, resolving promise.`);
|
||||
callback.resolve(data);
|
||||
this.ackCallbacks.delete(data.nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log.error("Error parsing incoming WebSocket message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onclose = (event: CloseEvent) => {
|
||||
this.isConnecting = false;
|
||||
if (event.wasClean) {
|
||||
log.info(`WebSocket closed cleanly, code=${event.code}, reason=${event.reason}`);
|
||||
} else {
|
||||
log.warn("WebSocket connection died. Attempting to reconnect...");
|
||||
this.handleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onerror = (error: Event) => {
|
||||
this.isConnecting = false;
|
||||
log.error("WebSocket error:", error);
|
||||
};
|
||||
} catch (error) {
|
||||
this.isConnecting = false;
|
||||
log.error("Failed to create WebSocket connection:", error);
|
||||
this.handleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
handleReconnect() {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
log.info(`Reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts}...`);
|
||||
setTimeout(() => this.connect(), this.reconnectInterval);
|
||||
} else {
|
||||
log.error("Max reconnect attempts reached. Giving up.");
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(data: WebSocketMessage, requiresAck = false): Promise<WebSocketMessage | void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const nodeId = data.nodeId;
|
||||
if (requiresAck && !nodeId) {
|
||||
return reject(new Error("A nodeId is required for messages that need acknowledgment."));
|
||||
}
|
||||
|
||||
const message = JSON.stringify(data);
|
||||
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(message);
|
||||
log.debug("Sent message:", data);
|
||||
if (requiresAck && nodeId) {
|
||||
log.debug(`Message for nodeId ${nodeId} requires ACK. Setting up callback.`);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.ackCallbacks.delete(nodeId);
|
||||
reject(new Error(`ACK timeout for nodeId ${nodeId}`));
|
||||
log.warn(`ACK timeout for nodeId ${nodeId}.`);
|
||||
}, 10000); // 10-second timeout
|
||||
|
||||
this.ackCallbacks.set(nodeId, {
|
||||
resolve: (responseData: WebSocketMessage | PromiseLike<WebSocketMessage>) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(responseData);
|
||||
},
|
||||
reject: (error: any) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve(); // Resolve immediately if no ACK is needed
|
||||
}
|
||||
} else {
|
||||
log.warn("WebSocket not open. Queuing message.");
|
||||
this.messageQueue.push(message);
|
||||
if (!this.isConnecting) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
if (requiresAck) {
|
||||
reject(new Error("Cannot send message with ACK required while disconnected."));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
flushMessageQueue() {
|
||||
log.debug(`Flushing ${this.messageQueue.length} queued messages.`);
|
||||
|
||||
while (this.messageQueue.length > 0) {
|
||||
const message = this.messageQueue.shift();
|
||||
if (this.socket && message) {
|
||||
this.socket.send(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`;
|
||||
export const webSocketManager = new WebSocketManager(wsUrl);
|
||||
196
src/utils/mask_utils.ts
Normal file
196
src/utils/mask_utils.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import {createModuleLogger} from "./LoggerUtils.js";
|
||||
import type { Canvas } from '../Canvas.js';
|
||||
// @ts-ignore
|
||||
import {ComfyApp} from "../../../scripts/app.js";
|
||||
|
||||
const log = createModuleLogger('MaskUtils');
|
||||
|
||||
export function new_editor(app: ComfyApp): boolean {
|
||||
if (!app) return false;
|
||||
return !!app.ui.settings.getSettingValue('Comfy.MaskEditor.UseNewEditor');
|
||||
}
|
||||
|
||||
function get_mask_editor_element(app: ComfyApp): HTMLElement | null {
|
||||
return new_editor(app) ? document.getElementById('maskEditor') : document.getElementById('maskCanvas')?.parentElement ?? null;
|
||||
}
|
||||
|
||||
export function mask_editor_showing(app: ComfyApp): boolean {
|
||||
const editor = get_mask_editor_element(app);
|
||||
return !!editor && editor.style.display !== "none";
|
||||
}
|
||||
|
||||
export function hide_mask_editor(app: ComfyApp): void {
|
||||
if (mask_editor_showing(app)) {
|
||||
const editor = document.getElementById('maskEditor');
|
||||
if (editor) {
|
||||
editor.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function get_mask_editor_cancel_button(app: ComfyApp): HTMLElement | null {
|
||||
const cancelButton = document.getElementById("maskEditor_topBarCancelButton");
|
||||
if (cancelButton) {
|
||||
log.debug("Found cancel button by ID: maskEditor_topBarCancelButton");
|
||||
return cancelButton;
|
||||
}
|
||||
|
||||
const cancelSelectors = [
|
||||
'button[onclick*="cancel"]',
|
||||
'button[onclick*="Cancel"]',
|
||||
'input[value="Cancel"]'
|
||||
];
|
||||
|
||||
for (const selector of cancelSelectors) {
|
||||
try {
|
||||
const button = document.querySelector<HTMLElement>(selector);
|
||||
if (button) {
|
||||
log.debug("Found cancel button with selector:", selector);
|
||||
return button;
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn("Invalid selector:", selector, e);
|
||||
}
|
||||
}
|
||||
|
||||
const allButtons = document.querySelectorAll('button, input[type="button"]');
|
||||
for (const button of allButtons) {
|
||||
const text = (button as HTMLElement).textContent || (button as HTMLInputElement).value || '';
|
||||
if (text.toLowerCase().includes('cancel')) {
|
||||
log.debug("Found cancel button by text content:", text);
|
||||
return button as HTMLElement;
|
||||
}
|
||||
}
|
||||
|
||||
const editorElement = get_mask_editor_element(app);
|
||||
if (editorElement) {
|
||||
const childNodes = editorElement?.parentElement?.lastChild?.childNodes;
|
||||
if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) {
|
||||
return childNodes[2];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function get_mask_editor_save_button(app: ComfyApp): HTMLElement | null {
|
||||
const saveButton = document.getElementById("maskEditor_topBarSaveButton");
|
||||
if (saveButton) {
|
||||
return saveButton;
|
||||
}
|
||||
const editorElement = get_mask_editor_element(app);
|
||||
if (editorElement) {
|
||||
const childNodes = editorElement?.parentElement?.lastChild?.childNodes;
|
||||
if (childNodes && childNodes.length > 2 && childNodes[2] instanceof HTMLElement) {
|
||||
return childNodes[2];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mask_editor_listen_for_cancel(app: ComfyApp, callback: () => void): void {
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 sekund
|
||||
|
||||
const findAndAttachListener = () => {
|
||||
attempts++;
|
||||
const cancel_button = get_mask_editor_cancel_button(app);
|
||||
|
||||
if (cancel_button instanceof HTMLElement && !(cancel_button as any).filter_listener_added) {
|
||||
log.info("Cancel button found, attaching listener");
|
||||
cancel_button.addEventListener('click', callback);
|
||||
(cancel_button as any).filter_listener_added = true;
|
||||
} else if (attempts < maxAttempts) {
|
||||
|
||||
setTimeout(findAndAttachListener, 100);
|
||||
} else {
|
||||
log.warn("Could not find cancel button after", maxAttempts, "attempts");
|
||||
|
||||
const globalClickHandler = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const text = target.textContent || (target as HTMLInputElement).value || '';
|
||||
if (target && (text.toLowerCase().includes('cancel') ||
|
||||
target.id.toLowerCase().includes('cancel') ||
|
||||
target.className.toLowerCase().includes('cancel'))) {
|
||||
log.info("Cancel detected via global click handler");
|
||||
callback();
|
||||
document.removeEventListener('click', globalClickHandler);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', globalClickHandler);
|
||||
log.debug("Added global click handler for cancel detection");
|
||||
}
|
||||
};
|
||||
|
||||
findAndAttachListener();
|
||||
}
|
||||
|
||||
export function press_maskeditor_save(app: ComfyApp): void {
|
||||
const button = get_mask_editor_save_button(app);
|
||||
if (button instanceof HTMLElement) {
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
|
||||
export function press_maskeditor_cancel(app: ComfyApp): void {
|
||||
const button = get_mask_editor_cancel_button(app);
|
||||
if (button instanceof HTMLElement) {
|
||||
button.click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uruchamia mask editor z predefiniowaną maską
|
||||
* @param {Canvas} canvasInstance - Instancja Canvas
|
||||
* @param {HTMLImageElement | HTMLCanvasElement} maskImage - Obraz maski do nałożenia
|
||||
* @param {boolean} sendCleanImage - Czy wysłać czysty obraz (bez istniejącej maski)
|
||||
*/
|
||||
export function start_mask_editor_with_predefined_mask(canvasInstance: Canvas, maskImage: HTMLImageElement | HTMLCanvasElement, sendCleanImage = true): void {
|
||||
if (!canvasInstance || !maskImage) {
|
||||
log.error('Canvas instance and mask image are required');
|
||||
return;
|
||||
}
|
||||
|
||||
canvasInstance.startMaskEditor(maskImage, sendCleanImage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uruchamia mask editor z automatycznym zachowaniem (czysty obraz + istniejąca maska)
|
||||
* @param {Canvas} canvasInstance - Instancja Canvas
|
||||
*/
|
||||
export function start_mask_editor_auto(canvasInstance: Canvas): void {
|
||||
if (!canvasInstance) {
|
||||
log.error('Canvas instance is required');
|
||||
return;
|
||||
}
|
||||
canvasInstance.startMaskEditor(null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tworzy maskę z obrazu dla użycia w mask editorze
|
||||
* @param {string} imageSrc - Źródło obrazu (URL lub data URL)
|
||||
* @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
|
||||
*/
|
||||
export function create_mask_from_image_src(imageSrc: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = imageSrc;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Konwertuje canvas do Image dla użycia jako maska
|
||||
* @param {HTMLCanvasElement} canvas - Canvas do konwersji
|
||||
* @returns {Promise<HTMLImageElement>} Promise zwracający obiekt Image
|
||||
*/
|
||||
export function canvas_to_mask_image(canvas: HTMLCanvasElement): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
img.src = canvas.toDataURL();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user