From 868221b285a3a390e224a0d31638a1deb723468c Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Thu, 14 Aug 2025 14:30:51 +0200 Subject: [PATCH] feat: add notification system with deduplication Implemented a comprehensive notification system with smart deduplication for LayerForge's "Paste Image" operations. The system prevents duplicate error/warning notifications while providing clear feedback for all clipboard operations including success, failure, and edge cases. --- js/utils/ClipboardManager.js | 22 +++++- js/utils/NotificationUtils.js | 111 ++++++++++++++++++++++++++---- src/utils/ClipboardManager.ts | 41 ++++++++--- src/utils/NotificationUtils.ts | 122 +++++++++++++++++++++++++++++---- 4 files changed, 255 insertions(+), 41 deletions(-) diff --git a/js/utils/ClipboardManager.js b/js/utils/ClipboardManager.js index e7fb998..76d6e58 100644 --- a/js/utils/ClipboardManager.js +++ b/js/utils/ClipboardManager.js @@ -1,5 +1,5 @@ import { createModuleLogger } from "./LoggerUtils.js"; -import { showNotification, showInfoNotification } from "./NotificationUtils.js"; +import { showNotification, showInfoNotification, showErrorNotification, showWarningNotification } from "./NotificationUtils.js"; import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js"; import { safeClipspacePaste } from "./ClipspaceUtils.js"; // @ts-ignore @@ -18,6 +18,7 @@ export class ClipboardManager { if (this.canvas.canvasLayers.internalClipboard.length > 0) { log.info("Found layers in internal clipboard, pasting layers"); this.canvas.canvasLayers.pasteLayers(); + showInfoNotification("Layers pasted from internal clipboard"); return true; } if (preference === 'clipspace') { @@ -27,9 +28,20 @@ export class ClipboardManager { return true; } log.info("No image found in ComfyUI Clipspace"); + // Don't show error here, will try system clipboard next } log.info("Attempting paste from system clipboard"); - return await this.trySystemClipboardPaste(addMode); + const systemSuccess = await this.trySystemClipboardPaste(addMode); + if (!systemSuccess) { + // No valid image found in any clipboard + if (preference === 'clipspace') { + showWarningNotification("No valid image found in Clipspace or system clipboard"); + } + else { + showWarningNotification("No valid image found in clipboard"); + } + } + return systemSuccess; }, 'ClipboardManager.handlePaste'); /** * Attempts to paste from ComfyUI Clipspace @@ -51,6 +63,7 @@ export class ClipboardManager { const img = new Image(); img.onload = async () => { await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + showInfoNotification("Image pasted from Clipspace"); }; img.src = clipspaceImage.src; return true; @@ -96,6 +109,7 @@ export class ClipboardManager { img.onload = async () => { log.info("Successfully loaded image from backend response"); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + showInfoNotification("Image loaded from file path"); resolve(true); }; img.onerror = () => { @@ -131,6 +145,7 @@ export class ClipboardManager { img.onload = async () => { log.info("Successfully loaded image from system clipboard"); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + showInfoNotification("Image pasted from system clipboard"); }; if (event.target?.result) { img.src = event.target.result; @@ -252,10 +267,12 @@ export class ClipboardManager { img.onload = async () => { log.info("Successfully loaded image from URL"); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + showInfoNotification("Image loaded from URL"); resolve(true); }; img.onerror = () => { log.warn("Failed to load image from URL:", filePath); + showErrorNotification(`Failed to load image from URL\nThe link might be incorrect or may not point to an image file.: ${filePath}`, 5000, true); resolve(false); }; img.src = filePath; @@ -313,6 +330,7 @@ export class ClipboardManager { img.onload = async () => { log.info("Successfully loaded image from file picker"); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + showInfoNotification("Image loaded from selected file"); resolve(true); }; img.onerror = () => { diff --git a/js/utils/NotificationUtils.js b/js/utils/NotificationUtils.js index 1b3589f..04d4610 100644 --- a/js/utils/NotificationUtils.js +++ b/js/utils/NotificationUtils.js @@ -1,5 +1,7 @@ import { createModuleLogger } from "./LoggerUtils.js"; const log = createModuleLogger('NotificationUtils'); +// Store active notifications for deduplication +const activeNotifications = new Map(); /** * Utility functions for showing notifications to the user */ @@ -8,10 +10,50 @@ const log = createModuleLogger('NotificationUtils'); * @param message - The message to show * @param backgroundColor - Background color (default: #4a6cd4) * @param duration - Duration in milliseconds (default: 3000) + * @param type - Type of notification + * @param deduplicate - If true, will not show duplicate messages and will refresh existing ones (default: false) */ -export function showNotification(message, backgroundColor = "#4a6cd4", duration = 3000, type = "info") { +export function showNotification(message, backgroundColor = "#4a6cd4", duration = 3000, type = "info", deduplicate = false) { // Remove any existing prefix to avoid double prefixing message = message.replace(/^\[Layer Forge\]\s*/, ""); + // If deduplication is enabled, check if this message already exists + if (deduplicate) { + const existingNotification = activeNotifications.get(message); + if (existingNotification) { + log.debug(`Notification already exists, refreshing timer: ${message}`); + // Clear existing timeout + if (existingNotification.timeout !== null) { + clearTimeout(existingNotification.timeout); + } + // Find the progress bar and restart its animation + const progressBar = existingNotification.element.querySelector('div[style*="animation"]'); + if (progressBar) { + // Reset animation + progressBar.style.animation = 'none'; + // Force reflow + void progressBar.offsetHeight; + // Restart animation + progressBar.style.animation = `lf-progress ${duration / 1000}s linear`; + } + // Set new timeout + const newTimeout = window.setTimeout(() => { + const notification = existingNotification.element; + notification.style.animation = 'lf-fadeout 0.3s ease-out forwards'; + notification.addEventListener('animationend', () => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + activeNotifications.delete(message); + const container = document.getElementById('lf-notification-container'); + if (container && container.children.length === 0) { + container.remove(); + } + } + }); + }, duration); + existingNotification.timeout = newTimeout; + return; // Don't create a new notification + } + } // Type-specific config const config = { success: { icon: "✔️", title: "Success", bg: "#1fd18b" }, @@ -148,6 +190,10 @@ export function showNotification(message, backgroundColor = "#4a6cd4", duration body.classList.add('notification-scrollbar'); let dismissTimeout = null; const closeNotification = () => { + // Remove from active notifications map if deduplicate is enabled + if (deduplicate) { + activeNotifications.delete(message); + } notification.style.animation = 'lf-fadeout 0.3s ease-out forwards'; notification.addEventListener('animationend', () => { if (notification.parentNode) { @@ -171,40 +217,77 @@ export function showNotification(message, backgroundColor = "#4a6cd4", duration progressBar.style.transform = computedStyle.transform; progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards'; }; - notification.addEventListener('mouseenter', pauseAndRewindTimer); - notification.addEventListener('mouseleave', startDismissTimer); + notification.addEventListener('mouseenter', () => { + pauseAndRewindTimer(); + // Update stored timeout if deduplicate is enabled + if (deduplicate) { + const stored = activeNotifications.get(message); + if (stored) { + stored.timeout = null; + } + } + }); + notification.addEventListener('mouseleave', () => { + startDismissTimer(); + // Update stored timeout if deduplicate is enabled + if (deduplicate) { + const stored = activeNotifications.get(message); + if (stored) { + stored.timeout = dismissTimeout; + } + } + }); startDismissTimer(); + // Store notification if deduplicate is enabled + if (deduplicate) { + activeNotifications.set(message, { element: notification, timeout: dismissTimeout }); + } log.debug(`Notification shown: [Layer Forge] ${message}`); } /** * Shows a success notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 3000) + * @param deduplicate - If true, will not show duplicate messages (default: false) */ -export function showSuccessNotification(message, duration = 3000) { - showNotification(message, undefined, duration, "success"); +export function showSuccessNotification(message, duration = 3000, deduplicate = false) { + showNotification(message, undefined, duration, "success", deduplicate); } /** * Shows an error notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 5000) + * @param deduplicate - If true, will not show duplicate messages (default: false) */ -export function showErrorNotification(message, duration = 5000) { - showNotification(message, undefined, duration, "error"); +export function showErrorNotification(message, duration = 5000, deduplicate = false) { + showNotification(message, undefined, duration, "error", deduplicate); } /** * Shows an info notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 3000) + * @param deduplicate - If true, will not show duplicate messages (default: false) */ -export function showInfoNotification(message, duration = 3000) { - showNotification(message, undefined, duration, "info"); +export function showInfoNotification(message, duration = 3000, deduplicate = false) { + showNotification(message, undefined, duration, "info", deduplicate); } /** * Shows a warning notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 3000) + * @param deduplicate - If true, will not show duplicate messages (default: false) */ -export function showWarningNotification(message, duration = 3000) { - showNotification(message, undefined, duration, "warning"); +export function showWarningNotification(message, duration = 3000, deduplicate = false) { + showNotification(message, undefined, duration, "warning", deduplicate); } /** * Shows an alert notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 3000) + * @param deduplicate - If true, will not show duplicate messages (default: false) */ -export function showAlertNotification(message, duration = 3000) { - showNotification(message, undefined, duration, "alert"); +export function showAlertNotification(message, duration = 3000, deduplicate = false) { + showNotification(message, undefined, duration, "alert", deduplicate); } /** * Shows a sequence of all notification types for debugging purposes. @@ -214,7 +297,7 @@ export function showAllNotificationTypes(message) { types.forEach((type, index) => { const notificationMessage = message || `This is a '${type}' notification.`; setTimeout(() => { - showNotification(notificationMessage, undefined, 3000, type); + showNotification(notificationMessage, undefined, 3000, type, false); }, index * 400); // Stagger the notifications }); } diff --git a/src/utils/ClipboardManager.ts b/src/utils/ClipboardManager.ts index ffc49fb..74fed1c 100644 --- a/src/utils/ClipboardManager.ts +++ b/src/utils/ClipboardManager.ts @@ -1,5 +1,5 @@ import {createModuleLogger} from "./LoggerUtils.js"; -import { showNotification, showInfoNotification } from "./NotificationUtils.js"; +import { showNotification, showInfoNotification, showErrorNotification, showWarningNotification } from "./NotificationUtils.js"; import { withErrorHandling, createValidationError, createNetworkError, createFileError } from "../ErrorHandler.js"; import { safeClipspacePaste } from "./ClipspaceUtils.js"; @@ -34,6 +34,7 @@ export class ClipboardManager { if (this.canvas.canvasLayers.internalClipboard.length > 0) { log.info("Found layers in internal clipboard, pasting layers"); this.canvas.canvasLayers.pasteLayers(); + showInfoNotification("Layers pasted from internal clipboard"); return true; } @@ -44,10 +45,22 @@ export class ClipboardManager { return true; } log.info("No image found in ComfyUI Clipspace"); + // Don't show error here, will try system clipboard next } log.info("Attempting paste from system clipboard"); - return await this.trySystemClipboardPaste(addMode); + const systemSuccess = await this.trySystemClipboardPaste(addMode); + + if (!systemSuccess) { + // No valid image found in any clipboard + if (preference === 'clipspace') { + showWarningNotification("No valid image found in Clipspace or system clipboard"); + } else { + showWarningNotification("No valid image found in clipboard"); + } + } + + return systemSuccess; }, 'ClipboardManager.handlePaste'); /** @@ -72,6 +85,7 @@ export class ClipboardManager { const img = new Image(); img.onload = async () => { await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + showInfoNotification("Image pasted from Clipspace"); }; img.src = clipspaceImage.src; return true; @@ -105,6 +119,7 @@ export class ClipboardManager { img.onload = async () => { log.info("Successfully loaded image from system clipboard"); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + showInfoNotification("Image pasted from system clipboard"); }; if (event.target?.result) { img.src = event.target.result as string; @@ -240,15 +255,17 @@ export class ClipboardManager { 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.onload = async () => { + log.info("Successfully loaded image from URL"); + await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + showInfoNotification("Image loaded from URL"); + resolve(true); + }; + img.onerror = () => { + log.warn("Failed to load image from URL:", filePath); + showErrorNotification(`Failed to load image from URL\nThe link might be incorrect or may not point to an image file.: ${filePath}`, 5000, true); + resolve(false); + }; img.src = filePath; }); } catch (error) { @@ -326,6 +343,7 @@ export class ClipboardManager { img.onload = async () => { log.info("Successfully loaded image from backend response"); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + showInfoNotification("Image loaded from file path"); resolve(true); }; img.onerror = () => { @@ -366,6 +384,7 @@ export class ClipboardManager { img.onload = async () => { log.info("Successfully loaded image from file picker"); await this.canvas.canvasLayers.addLayerWithImage(img, {}, addMode); + showInfoNotification("Image loaded from selected file"); resolve(true); }; img.onerror = () => { diff --git a/src/utils/NotificationUtils.ts b/src/utils/NotificationUtils.ts index a10fd87..5b75208 100644 --- a/src/utils/NotificationUtils.ts +++ b/src/utils/NotificationUtils.ts @@ -2,6 +2,9 @@ import { createModuleLogger } from "./LoggerUtils.js"; const log = createModuleLogger('NotificationUtils'); +// Store active notifications for deduplication +const activeNotifications = new Map(); + /** * Utility functions for showing notifications to the user */ @@ -11,16 +14,62 @@ const log = createModuleLogger('NotificationUtils'); * @param message - The message to show * @param backgroundColor - Background color (default: #4a6cd4) * @param duration - Duration in milliseconds (default: 3000) + * @param type - Type of notification + * @param deduplicate - If true, will not show duplicate messages and will refresh existing ones (default: false) */ export function showNotification( message: string, backgroundColor: string = "#4a6cd4", duration: number = 3000, - type: "success" | "error" | "info" | "warning" | "alert" = "info" + type: "success" | "error" | "info" | "warning" | "alert" = "info", + deduplicate: boolean = false ): void { // Remove any existing prefix to avoid double prefixing message = message.replace(/^\[Layer Forge\]\s*/, ""); + // If deduplication is enabled, check if this message already exists + if (deduplicate) { + const existingNotification = activeNotifications.get(message); + if (existingNotification) { + log.debug(`Notification already exists, refreshing timer: ${message}`); + + // Clear existing timeout + if (existingNotification.timeout !== null) { + clearTimeout(existingNotification.timeout); + } + + // Find the progress bar and restart its animation + const progressBar = existingNotification.element.querySelector('div[style*="animation"]') as HTMLDivElement; + if (progressBar) { + // Reset animation + progressBar.style.animation = 'none'; + // Force reflow + void progressBar.offsetHeight; + // Restart animation + progressBar.style.animation = `lf-progress ${duration / 1000}s linear`; + } + + // Set new timeout + const newTimeout = window.setTimeout(() => { + const notification = existingNotification.element; + notification.style.animation = 'lf-fadeout 0.3s ease-out forwards'; + notification.addEventListener('animationend', () => { + if (notification.parentNode) { + notification.parentNode.removeChild(notification); + activeNotifications.delete(message); + const container = document.getElementById('lf-notification-container'); + if (container && container.children.length === 0) { + container.remove(); + } + } + }); + }, duration); + + existingNotification.timeout = newTimeout; + return; // Don't create a new notification + } + } + // Type-specific config const config = { success: { icon: "✔️", title: "Success", bg: "#1fd18b" }, @@ -172,6 +221,11 @@ export function showNotification( let dismissTimeout: number | null = null; const closeNotification = () => { + // Remove from active notifications map if deduplicate is enabled + if (deduplicate) { + activeNotifications.delete(message); + } + notification.style.animation = 'lf-fadeout 0.3s ease-out forwards'; notification.addEventListener('animationend', () => { if (notification.parentNode) { @@ -198,46 +252,86 @@ export function showNotification( progressBar.style.animation = 'lf-progress-rewind 0.5s ease-out forwards'; }; - notification.addEventListener('mouseenter', pauseAndRewindTimer); - notification.addEventListener('mouseleave', startDismissTimer); + notification.addEventListener('mouseenter', () => { + pauseAndRewindTimer(); + // Update stored timeout if deduplicate is enabled + if (deduplicate) { + const stored = activeNotifications.get(message); + if (stored) { + stored.timeout = null; + } + } + }); + + notification.addEventListener('mouseleave', () => { + startDismissTimer(); + // Update stored timeout if deduplicate is enabled + if (deduplicate) { + const stored = activeNotifications.get(message); + if (stored) { + stored.timeout = dismissTimeout; + } + } + }); startDismissTimer(); + + // Store notification if deduplicate is enabled + if (deduplicate) { + activeNotifications.set(message, { element: notification, timeout: dismissTimeout }); + } + log.debug(`Notification shown: [Layer Forge] ${message}`); } /** * Shows a success notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 3000) + * @param deduplicate - If true, will not show duplicate messages (default: false) */ -export function showSuccessNotification(message: string, duration: number = 3000): void { - showNotification(message, undefined, duration, "success"); +export function showSuccessNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void { + showNotification(message, undefined, duration, "success", deduplicate); } /** * Shows an error notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 5000) + * @param deduplicate - If true, will not show duplicate messages (default: false) */ -export function showErrorNotification(message: string, duration: number = 5000): void { - showNotification(message, undefined, duration, "error"); +export function showErrorNotification(message: string, duration: number = 5000, deduplicate: boolean = false): void { + showNotification(message, undefined, duration, "error", deduplicate); } /** * Shows an info notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 3000) + * @param deduplicate - If true, will not show duplicate messages (default: false) */ -export function showInfoNotification(message: string, duration: number = 3000): void { - showNotification(message, undefined, duration, "info"); +export function showInfoNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void { + showNotification(message, undefined, duration, "info", deduplicate); } /** * Shows a warning notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 3000) + * @param deduplicate - If true, will not show duplicate messages (default: false) */ -export function showWarningNotification(message: string, duration: number = 3000): void { - showNotification(message, undefined, duration, "warning"); +export function showWarningNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void { + showNotification(message, undefined, duration, "warning", deduplicate); } /** * Shows an alert notification + * @param message - The message to show + * @param duration - Duration in milliseconds (default: 3000) + * @param deduplicate - If true, will not show duplicate messages (default: false) */ -export function showAlertNotification(message: string, duration: number = 3000): void { - showNotification(message, undefined, duration, "alert"); +export function showAlertNotification(message: string, duration: number = 3000, deduplicate: boolean = false): void { + showNotification(message, undefined, duration, "alert", deduplicate); } /** @@ -248,7 +342,7 @@ export function showAllNotificationTypes(message?: string): void { types.forEach((type, index) => { const notificationMessage = message || `This is a '${type}' notification.`; setTimeout(() => { - showNotification(notificationMessage, undefined, 3000, type); + showNotification(notificationMessage, undefined, 3000, type, false); }, index * 400); // Stagger the notifications }); }