Files

536 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
author: Azornes
title: AzToast
version: 1.0.1
description: Notification Utilities (Toast System)
*/
// ==========================================
// 1. IMPORTS & DEPENDENCIES
// ==========================================
// Comment out or adjust this import if copying to a project that does not use AzLogs
import { createModuleLogger } from "../log_system/log_funcs.js";
// ==========================================
// 2. CONFIGURATION
// ==========================================
const CONFIG = {
// Project display name (used in notifications tag and message prefix cleaning)
PROJECT_NAME: "Model Resolver",
// Project icon / emoji (used in notifications tag)
PROJECT_ICON: "🔍",
// Unique prefix for CSS classes, IDs, and keyframes to prevent styling conflicts
CSS_PREFIX: "mr-",
// Logger module tag/name
LOGGER_NAME: "NotificationUtils",
// Shared global container ID to stack notifications across different custom nodes/projects
SHARED_CONTAINER_ID: "az-toast-container"
};
// ==========================================
// 3. LOGGER SETUP & FALLBACK
// ==========================================
let log;
try {
if (typeof createModuleLogger === 'function') {
log = createModuleLogger(CONFIG.LOGGER_NAME);
}
} catch (_e) {
// Fallback if import fails or is commented out
}
if (!log) {
log = {
debug: (...args) => console.debug(`[${CONFIG.LOGGER_NAME}]`, ...args),
info: (...args) => console.info(`[${CONFIG.LOGGER_NAME}]`, ...args),
warn: (...args) => console.warn(`[${CONFIG.LOGGER_NAME}]`, ...args),
error: (...args) => console.error(`[${CONFIG.LOGGER_NAME}]`, ...args)
};
}
// ==========================================
// 4. MAIN UTILITY IMPLEMENTATION
// ==========================================
// Store active notifications for deduplication
const activeNotifications = new Map();
/**
* Shows a temporary notification to the user
* Supports both signatures:
* 1. showNotification(message, type = "info", options = {})
* 2. showNotification(message, backgroundColor = "#4a6cd4", duration = 3000, type = "info", deduplicate = false)
*
* @param message - The message to show
* @returns Notification controller with updateProgress/updateMessage/close helpers.
*/
export function showNotification(message, typeOrBgColor = "info", durationOrOptions = 3000, typeArg = "info", deduplicateArg = false) {
// Clean any prefix matching the project name (e.g. "[Model Resolver]")
const escapeRegex = (str) => str.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const prefixRegex = new RegExp(`^\\[\\s*${escapeRegex(CONFIG.PROJECT_NAME)}\\s*\\]\\s*`, 'i');
message = message.replace(prefixRegex, "");
let type = "info";
let duration = 3000;
let deduplicate = false;
let options = {};
let customBg = null;
// Parse options/arguments based on signature type
if (typeof typeOrBgColor === "object" && typeOrBgColor !== null) {
options = typeOrBgColor;
type = options.type || "info";
duration = options.duration || 3000;
deduplicate = options.deduplicate || false;
} else if (typeof durationOrOptions === "object" && durationOrOptions !== null) {
options = durationOrOptions;
type = typeOrBgColor || "info";
duration = options.duration || 3000;
deduplicate = options.deduplicate || false;
} else {
if (typeof typeOrBgColor === "string") {
if (typeOrBgColor.startsWith("#")) {
customBg = typeOrBgColor;
type = typeArg || "info";
} else {
type = typeOrBgColor;
}
}
if (typeof durationOrOptions === "number") {
duration = durationOrOptions;
}
deduplicate = deduplicateArg;
}
const manualProgress = Boolean(options?.manualProgress);
// 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);
}
if (existingNotification.animationFrame !== null) {
cancelAnimationFrame(existingNotification.animationFrame);
existingNotification.animationFrame = null;
}
// Find the progress bar and restart its animation
const progressBar = existingNotification.element.querySelector('div[style*="scaleX"]');
if (progressBar) {
progressBar.style.animation = 'none';
progressBar.style.transition = 'none';
progressBar.style.transform = 'scaleX(1)';
const startedAt = performance.now();
const updateProgress = (now) => {
const progress = Math.max(0, 1 - ((now - startedAt) / duration));
progressBar.style.transform = `scaleX(${progress})`;
if (progress > 0) {
existingNotification.animationFrame = requestAnimationFrame(updateProgress);
}
};
existingNotification.animationFrame = requestAnimationFrame(updateProgress);
}
// Set new timeout
const newTimeout = window.setTimeout(() => {
const notification = existingNotification.element;
notification.style.animation = `${CONFIG.CSS_PREFIX}fadeout 0.3s ease-out forwards`;
notification.addEventListener('animationend', () => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
const stored = activeNotifications.get(message);
if (stored?.animationFrame !== null && stored?.animationFrame !== undefined) {
cancelAnimationFrame(stored.animationFrame);
}
activeNotifications.delete(message);
const container = document.getElementById(CONFIG.SHARED_CONTAINER_ID);
if (container && container.children.length === 0) {
container.remove();
}
}
});
}, duration);
existingNotification.timeout = newTimeout;
return existingNotification.controller || null; // Don't create a new notification
}
}
// Type-specific config
const config = {
success: { icon: "✔️", title: "Success", bg: "#1fd18b" },
error: { icon: "❌", title: "Error", bg: "#ff6f6f" },
info: { icon: "ℹ️", title: "Info", bg: "#4a6cd4" },
warning: { icon: "⚠️", title: "Warning", bg: "#ffd43b" },
alert: { icon: "⚠️", title: "Alert", bg: "#fff7cc" }
}[type] || { icon: "ℹ️", title: "Info", bg: "#4a6cd4" };
if (customBg) {
config.bg = customBg;
}
// --- Get or create the main notification container ---
let container = document.getElementById(CONFIG.SHARED_CONTAINER_ID);
if (!container) {
container = document.createElement('div');
container.id = CONFIG.SHARED_CONTAINER_ID;
container.style.cssText = `
position: fixed;
top: 24px;
right: 24px;
z-index: 100005; /* Above modal overlays but below context menus */
display: flex;
flex-direction: column;
gap: 16px;
align-items: flex-end;
pointer-events: none;
`;
document.body.appendChild(container);
}
// --- Dark, modern notification style ---
const notification = document.createElement('div');
notification.className = `${CONFIG.CSS_PREFIX}notification-item ${CONFIG.CSS_PREFIX}notification--${type}${options?.contextMenuModel ? ` ${CONFIG.CSS_PREFIX}download-folder-context` : ''}`;
notification.style.cssText = `
pointer-events: auto;
min-width: 380px;
max-width: 440px;
max-height: 80vh;
background: rgba(30, 32, 41, 0.9);
color: #fff;
border-radius: 12px;
box-shadow: 0 4px 32px rgba(0,0,0,0.25);
display: flex;
flex-direction: column;
padding: 0;
font-family: 'Segoe UI', 'Arial', sans-serif;
overflow: hidden;
border: 1px solid rgba(80, 80, 80, 0.5);
backdrop-filter: blur(8px);
animation: ${CONFIG.CSS_PREFIX}fadein 0.3s ease-out;
`;
// Context menu binding if provided
const contextMenuModel = options?.contextMenuModel || null;
if (contextMenuModel) {
try {
const data = btoa(unescape(encodeURIComponent(JSON.stringify(contextMenuModel))));
notification.dataset.model = data;
notification.oncontextmenu = (event) => {
window.MLOpenContextMenu?.(event, event.currentTarget);
};
if (options?.contextMenuTooltip) {
notification.dataset.tooltip = options.contextMenuTooltip;
}
} catch (e) {
console.error('Failed to encode context menu model:', e);
}
}
// --- Header (non-scrollable) ---
const header = document.createElement('div');
header.style.cssText = `display: flex; align-items: flex-start; padding: 16px 20px; position: relative; flex-shrink: 0;`;
const leftBar = document.createElement('div');
leftBar.style.cssText = `position: absolute; left: 0; top: 0; bottom: 0; width: 6px; background: ${config.bg}; box-shadow: 0 0 12px ${config.bg}; border-radius: 3px 0 0 3px;`;
const iconContainer = document.createElement('div');
iconContainer.style.cssText = `width: 48px; height: 48px; min-width: 48px; min-height: 48px; display: flex; align-items: center; justify-content: center; margin-left: 18px; margin-right: 18px;`;
iconContainer.innerHTML = {
success: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-succ"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 4 L44 14 L44 34 L24 44 L4 34 L4 14 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/><g filter="url(#f-succ)"><path d="M16 24 L22 30 L34 18" stroke="#fff" stroke-width="3" fill="none"/></g></svg>`,
error: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-err"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M14 14 L34 34 M34 14 L14 34" fill="none" stroke="#fff" stroke-width="3"/><g filter="url(#f-err)"><path d="M24,4 L42,12 L42,36 L24,44 L6,36 L6,12 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
info: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-info"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 16 M24 22 L24 34" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-info)"><path d="M12,4 L36,4 L44,12 L44,36 L36,44 L12,44 L4,36 L4,12 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
warning: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-warn"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 28 M24 34 L24 36" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-warn)"><path d="M24,4 L46,24 L24,44 L2,24 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`,
alert: `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-alert"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 28 M24 34 L24 36" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-alert)"><path d="M24,4 L46,24 L24,44 L2,24 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`
}[type] || `<svg width="48" height="48" viewBox="0 0 48 48"><defs><filter id="f-info"><feDropShadow dx="0" dy="0" stdDeviation="3" flood-color="${config.bg}"/></filter></defs><path d="M24 14 L24 16 M24 22 L24 34" stroke="#fff" stroke-width="3" fill="none"/><g filter="url(#f-info)"><path d="M12,4 L36,4 L44,12 L44,36 L36,44 L12,44 L4,36 L4,12 Z" fill="rgba(255,255,255,0.08)" stroke="${config.bg}" stroke-width="2"/></g></svg>`;
const headerTextContent = document.createElement('div');
headerTextContent.style.cssText = `display: flex; flex-direction: column; justify-content: center; flex: 1; min-width: 0;`;
const titleSpan = document.createElement('div');
titleSpan.style.cssText = `font-weight: 700; font-size: 16px; margin-bottom: 4px; color: #fff; text-transform: uppercase; letter-spacing: 0.5px;`;
titleSpan.textContent = config.title;
headerTextContent.appendChild(titleSpan);
const topRightContainer = document.createElement('div');
topRightContainer.style.cssText = `position: absolute; top: 14px; right: 18px; display: flex; align-items: center; gap: 12px;`;
const tag = document.createElement('span');
tag.style.cssText = `font-size: 11px; font-weight: 600; color: #fff; background: ${config.bg}; border-radius: 4px; padding: 2px 8px; box-shadow: 0 0 8px ${config.bg};`;
tag.innerHTML = `${CONFIG.PROJECT_ICON} ${CONFIG.PROJECT_NAME}`;
const getTextColorForBg = (hexColor) => {
const r = parseInt(hexColor.slice(1, 3), 16), g = parseInt(hexColor.slice(3, 5), 16), b = parseInt(hexColor.slice(5, 7), 16);
return ((0.299 * r + 0.587 * g + 0.114 * b) / 255) > 0.5 ? '#000' : '#fff';
};
tag.style.color = getTextColorForBg(config.bg);
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '&times;';
closeBtn.setAttribute("aria-label", "Close notification");
closeBtn.style.cssText = `background: none; border: none; color: #ccc; font-size: 22px; font-weight: bold; cursor: pointer; padding: 0; opacity: 0.7; transition: opacity 0.15s; line-height: 1;`;
topRightContainer.appendChild(tag);
topRightContainer.appendChild(closeBtn);
header.appendChild(iconContainer);
header.appendChild(headerTextContent);
header.appendChild(topRightContainer);
// --- Scrollable Body ---
const body = document.createElement('div');
body.style.cssText = `padding: 0px 20px 16px 20px; overflow-y: auto; flex: 1;`;
const msgSpan = document.createElement('div');
msgSpan.style.cssText = `font-size: 14px; color: #ccc; line-height: 1.5; white-space: pre-wrap; word-break: break-word;`;
msgSpan.textContent = message;
body.appendChild(msgSpan);
// --- Progress Bar ---
const progressBar = document.createElement('div');
progressBar.style.cssText = `height: 4px; width: 100%; background: ${config.bg}; box-shadow: 0 0 12px ${config.bg}; transform-origin: left; transform: scaleX(1); transition: none; flex-shrink: 0;`;
if (manualProgress) {
progressBar.style.transform = 'scaleX(0)';
progressBar.style.transition = 'transform 0.18s ease-out';
}
// --- Assemble Notification ---
notification.appendChild(leftBar);
notification.appendChild(header);
notification.appendChild(body);
if (type === 'error') {
const footer = document.createElement('div');
footer.style.cssText = `padding: 0 20px 12px 86px; flex-shrink: 0;`;
const copyButton = document.createElement('button');
copyButton.textContent = 'Copy Error';
copyButton.style.cssText = `background: rgba(255, 111, 111, 0.2); border: 1px solid #ff6f6f; color: #ffafaf; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; transition: background 0.2s;`;
copyButton.onmouseenter = () => copyButton.style.background = 'rgba(255, 111, 111, 0.3)';
copyButton.onmouseleave = () => copyButton.style.background = 'rgba(255, 111, 111, 0.2)';
copyButton.onclick = () => {
navigator.clipboard.writeText(message)
.then(() => showSuccessNotification("Error message copied!", 2000))
.catch(err => console.error('Failed to copy error message: ', err));
};
footer.appendChild(copyButton);
notification.appendChild(footer);
}
notification.appendChild(progressBar);
// Add to DOM
container.appendChild(notification);
// --- Keyframes and Timer Logic ---
const styleSheet = document.getElementById(`${CONFIG.CSS_PREFIX}notification-styles`);
if (!styleSheet) {
const newStyleSheet = document.createElement("style");
newStyleSheet.id = `${CONFIG.CSS_PREFIX}notification-styles`;
newStyleSheet.innerText = `
@keyframes ${CONFIG.CSS_PREFIX}fadein { from { opacity: 0; transform: scale(0.95) translateX(20px); } to { opacity: 1; transform: scale(1) translateX(0); } }
@keyframes ${CONFIG.CSS_PREFIX}fadeout { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } }
.notification-scrollbar::-webkit-scrollbar { width: 8px; }
.notification-scrollbar::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); border-radius: 4px; }
.notification-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.25); border-radius: 4px; }
.notification-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.4); }
`;
document.head.appendChild(newStyleSheet);
}
body.classList.add('notification-scrollbar');
let dismissTimeout = null;
let progressAnimationFrame = null;
let closed = false;
const closeNotification = () => {
if (closed) return;
closed = true;
if (dismissTimeout !== null) {
clearTimeout(dismissTimeout);
dismissTimeout = null;
}
if (progressAnimationFrame !== null) {
cancelAnimationFrame(progressAnimationFrame);
progressAnimationFrame = null;
}
// Remove from active notifications map if deduplicate is enabled
if (deduplicate) {
activeNotifications.delete(message);
}
notification.style.animation = `${CONFIG.CSS_PREFIX}fadeout 0.3s ease-out forwards`;
notification.addEventListener('animationend', () => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
if (container && container.children.length === 0) {
container.remove();
}
}
});
};
const controller = {
element: notification,
progressBar,
messageElement: msgSpan,
updateMessage(nextMessage = '') {
msgSpan.textContent = String(nextMessage || '');
},
updateProgress(percent = 0, nextMessage = undefined) {
const numericPercent = Number(percent);
const safePercent = Math.max(0, Math.min(100, Number.isFinite(numericPercent) ? numericPercent : 0));
progressBar.style.transition = 'transform 0.18s ease-out';
progressBar.style.transform = `scaleX(${safePercent / 100})`;
if (nextMessage !== undefined) {
msgSpan.textContent = String(nextMessage || '');
}
},
close: closeNotification
};
closeBtn.onclick = closeNotification;
const startDismissTimer = () => {
if (dismissTimeout !== null)
clearTimeout(dismissTimeout);
if (progressAnimationFrame !== null)
cancelAnimationFrame(progressAnimationFrame);
dismissTimeout = window.setTimeout(closeNotification, duration);
progressBar.style.animation = 'none';
progressBar.style.transition = 'none';
progressBar.style.transform = 'scaleX(1)';
const startedAt = performance.now();
const updateProgress = (now) => {
const progress = Math.max(0, 1 - ((now - startedAt) / duration));
progressBar.style.transform = `scaleX(${progress})`;
if (progress > 0) {
progressAnimationFrame = requestAnimationFrame(updateProgress);
}
};
progressAnimationFrame = requestAnimationFrame(updateProgress);
};
const pauseAndRewindTimer = () => {
if (manualProgress) return;
if (dismissTimeout !== null)
clearTimeout(dismissTimeout);
dismissTimeout = null;
if (progressAnimationFrame !== null)
cancelAnimationFrame(progressAnimationFrame);
progressAnimationFrame = null;
const computedStyle = window.getComputedStyle(progressBar);
progressBar.style.transition = 'none';
progressBar.style.transform = computedStyle.transform;
void progressBar.offsetHeight;
progressBar.style.transition = 'transform 0.5s ease-out';
progressBar.style.transform = 'scaleX(1)';
};
notification.addEventListener('mouseenter', () => {
pauseAndRewindTimer();
// Update stored timeout if deduplicate is enabled
if (deduplicate) {
const stored = activeNotifications.get(message);
if (stored) {
stored.timeout = null;
stored.animationFrame = null;
}
}
});
notification.addEventListener('mouseleave', () => {
if (manualProgress) return;
startDismissTimer();
// Update stored timeout if deduplicate is enabled
if (deduplicate) {
const stored = activeNotifications.get(message);
if (stored) {
stored.timeout = dismissTimeout;
stored.animationFrame = progressAnimationFrame;
}
}
});
if (!manualProgress) {
startDismissTimer();
}
// Store notification if deduplicate is enabled
if (deduplicate) {
activeNotifications.set(message, { element: notification, timeout: dismissTimeout, animationFrame: progressAnimationFrame, controller });
}
log.debug(`Notification shown: [${CONFIG.PROJECT_NAME}] ${message}`);
return controller;
}
/**
* Shows a success notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 3000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showSuccessNotification(message, duration = 3000, deduplicate = false) {
showNotification(message, "success", duration, deduplicate);
}
/**
* Shows an error notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 5000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showErrorNotification(message, duration = 5000, deduplicate = false) {
showNotification(message, "error", duration, deduplicate);
}
/**
* Shows an info notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 3000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showInfoNotification(message, duration = 3000, deduplicate = false) {
showNotification(message, "info", duration, deduplicate);
}
/**
* Shows a warning notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 3000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showWarningNotification(message, duration = 3000, deduplicate = false) {
showNotification(message, "warning", duration, deduplicate);
}
/**
* Shows an alert notification
* @param message - The message to show
* @param duration - Duration in milliseconds (default: 3000)
* @param deduplicate - If true, will not show duplicate messages (default: false)
*/
export function showAlertNotification(message, duration = 3000, deduplicate = false) {
showNotification(message, "alert", duration, deduplicate);
}
/**
* Shows a sequence of all notification types for debugging purposes.
*/
export function showAllNotificationTypes(message) {
const types = ["success", "error", "info", "warning", "alert"];
types.forEach((type, index) => {
const notificationMessage = message || `This is a '${type}' notification.`;
setTimeout(() => {
showNotification(notificationMessage, type, 3000, false);
}, index * 400); // Stagger the notifications
});
}