/** * MetonaToast Toast — Toast 类 * @module toast * @version 0.2.0 */ import { generateId, escapeHTML } from './utils.js'; import { DEFAULTS, ICONS, TYPE_COLORS, THEMES } from './constants.js'; import { injectStyles } from './styles.js'; import { getTheme } from './themes.js'; import { t, getLocaleDirection, getCurrentLocale } from './i18n.js'; import type { ToastConfig, ToastOptions, ToastInstance, ErrorInfo, TypeColor, ThemeConfig } from './types.js'; /** * Toast 类 — 核心通知组件 */ export class Toast implements ToastInstance { // 静态钩子系统 static _hooks: Map void>> = new Map(); // 由 api.ts 注入的回调,避免循环依赖 static _onError: ((errorInfo: ErrorInfo) => void) | null = null; static _removeToast: ((id: string) => void) | null = null; static on(name: string, fn: (toast: Toast) => void): () => void { if (!this._hooks.has(name)) this._hooks.set(name, []); this._hooks.get(name)!.push(fn); return () => this.off(name, fn); } static off(name: string, fn: (toast: Toast) => void): void { const list = this._hooks.get(name); if (list) this._hooks.set(name, list.filter(f => f !== fn)); } static trigger(name: string, toast: Toast): void { const list = this._hooks.get(name); if (list) { list.forEach(fn => { try { fn(toast); } catch (e) { console.error('Hook error:', name, e); if (Toast._onError) { try { Toast._onError({ hook: name, error: e as Error, toast }); } catch (_e) { /* noop */ } } } }); } } id: string; type: string; title: string; message: string; html: string; iconHTML: string; config: ToastConfig; el: HTMLElement | null = null; barEl: HTMLElement | null = null; rafId: number | null = null; remaining: number; startedAt: number = 0; paused: boolean = false; closing: boolean = false; group: string | null = null; _cleanups: Array<() => void> = []; constructor(opts: ToastOptions) { this.id = opts.id || generateId(); this.type = opts.type || 'default'; this.title = opts.title || ''; this.message = opts.message ?? opts.content ?? ''; this.html = opts.html || ''; this.iconHTML = opts.iconHTML || ''; this.config = { ...DEFAULTS as unknown as ToastConfig, ...opts }; // 防止嵌套对象被多个 toast 实例共享引用 if (opts.style && typeof opts.style === 'object') { this.config.style = { ...opts.style }; } this.remaining = this.config.duration || 0; this.group = opts.group || null; } _palette(): { theme: string; c: TypeColor; t: Partial } { const theme = getTheme(this.config.theme || 'auto'); const c = (TYPE_COLORS[this.type] as TypeColor) || (TYPE_COLORS.default as TypeColor); const tc = (THEMES[theme] as ThemeConfig) || (THEMES.light as ThemeConfig); const t: Partial = { bg: tc.bg, border: tc.border, shadow: tc.shadow }; return { theme, c, t }; } create(): this { if (typeof window === 'undefined' || typeof document === 'undefined') return this; Toast.trigger('beforeShow', this); injectStyles(); const container = this._getContainer(); const { theme, c, t } = this._palette(); this._limitToasts(container); const el = document.createElement('div'); el.className = this._buildClassName(theme); el.setAttribute('role', (this.type === 'error' || this.type === 'warning') ? 'alert' : 'status'); el.setAttribute('aria-live', this.type === 'error' ? 'assertive' : 'polite'); el.dataset.id = this.id; this._applyStyles(el, theme, t); this._buildContent(el, c); this.el = el; // Chrome bug: column-reverse + appendChild 会导致重叠。改用 column + insertBefore const pos = this.config.position || 'top-right'; if (pos.startsWith('top')) { container.insertBefore(el, container.firstChild); } else { container.appendChild(el); } this._bindEvents(el); this._startTimer(); // 进入动画 requestAnimationFrame(() => { requestAnimationFrame(() => { el.classList.add('met-show'); }); }); if (typeof this.config.onShow === 'function') { try { this.config.onShow(this); } catch (e) { console.error('onShow callback error:', e); } } Toast.trigger('afterShow', this); return this; } _getContainer(): HTMLElement { const position = this.config.position || 'top-right'; const zIndex = this.config.zIndex || 9999; // 从模块级缓存获取 (由 api.ts 管理) const cached = _containerCache.get(document.body); if (cached && cached.has(position)) { return cached.get(position)!; } // 缓存未命中时创建新容器 if (!_containerCache.has(document.body)) { _containerCache.set(document.body, new Map()); } const el = document.createElement('div'); el.className = `met-container ${position}`; el.setAttribute('aria-label', 'Notifications'); el.setAttribute('role', 'region'); const posStyles: Record = { 'top-left': 'top:0;left:0;align-items:flex-start', 'top-center': 'top:0;left:0;right:0;align-items:center', 'top-right': 'top:0;right:0;align-items:flex-end', 'bottom-left': 'bottom:0;left:0;align-items:flex-start', 'bottom-center': 'bottom:0;left:0;right:0;align-items:center', 'bottom-right': 'bottom:0;right:0;align-items:flex-end', }; el.style.cssText = ` display:flex; flex-direction:column; gap:${this.config.gap || 12}px; box-sizing:border-box; padding:${this.config.offset || 24}px; max-width:100vw; position:fixed; z-index:${zIndex}; pointer-events:none; ${posStyles[position] || 'top:0;right:0;align-items:flex-end'} `; document.body.appendChild(el); _containerCache.get(document.body)!.set(position, el); return el; } _limitToasts(container: HTMLElement): void { const max = this.config.max || 6; const list = Array.from(container.querySelectorAll('.met-toast')); if (list.length >= max) { const first = list[0] as HTMLElement; const id = first?.dataset.id; if (id && Toast._removeToast) { Toast._removeToast(id); } } } _buildClassName(theme: string): string { const CSS_ANIMS = ['slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom', 'slideUp', 'slideDown', 'slideLeft', 'slideRight']; const anim = CSS_ANIMS.includes(this.config.animation || '') ? this.config.animation : 'slide'; return [ 'met-toast', `met-${this.type}`, `met-theme-${theme}`, `met-anim-${anim}`, (this.config.closeOnClick || this.config.draggable) ? 'met-clickable' : '', this.config.className || '', ].filter(Boolean).join(' '); } _applyStyles(el: HTMLElement, _theme: string, _t: Partial): void { const widthValue = typeof this.config.width === 'number' ? `${this.config.width}px` : (typeof this.config.width === 'string' ? this.config.width : '360px'); const set = (p: string, v: string) => el.style.setProperty(p, v, 'important'); set('position', 'relative'); set('flex-shrink', '0'); set('min-width', '240px'); set('max-width', 'calc(100vw - 48px)'); set('width', widthValue); // 用户自定义样式最后应用 if (this.config.style && Object.keys(this.config.style).length > 0) { Object.entries(this.config.style).forEach(([k, v]) => { (el.style as unknown as Record)[k] = v; }); } } _buildContent(el: HTMLElement, c: TypeColor): void { // 自定义渲染函数 — 完全接管 DOM 构建 if (typeof this.config.render === 'function') { el.innerHTML = this.config.render(this); this.barEl = el.querySelector('.met-bar, .met-bar-v'); return; } const showIcon = this.config.icon !== false && (this.iconHTML || ICONS[this.type]); const showClose = this.config.closeButton !== false; const showProgress = this.config.showProgress !== false && (this.config.duration || 0) > 0; const showSide = (!showIcon && this.type !== 'default'); const safeTitle = this.title ? `
${escapeHTML(this.title)}
` : ''; const safeMessage = this.html ? `
${this.html}
` : (this.message ? `
${escapeHTML(this.message)}
` : ''); const iconHTML = showIcon ? `
${this.iconHTML || ICONS[this.type]}
` : ''; const closeHTML = showClose ? `` : ''; const progressHTML = showProgress ? (this.config.progressDirection === 'vertical' ? `
` : `
`) : ''; const sideHTML = showSide ? `
` : ''; el.innerHTML = ` ${iconHTML} ${sideHTML}
${safeTitle} ${safeMessage}
${closeHTML} ${progressHTML} `; this.barEl = el.querySelector('.met-bar, .met-bar-v'); } _bindEvents(el: HTMLElement): void { const eventHandler = (e: Event) => { const target = e.target as HTMLElement; if (target.closest('.met-close')) { e.stopPropagation(); this.close(); return; } if (this.config.closeOnClick && !target.closest('.met-close')) { if (typeof this.config.onClick === 'function') { try { this.config.onClick(this); } catch (err) { console.error('onClick callback error:', err); } } this.close(); return; } }; el.addEventListener('click', eventHandler); this._cleanups.push(() => el.removeEventListener('click', eventHandler)); if (this.config.pauseOnHover && (this.config.duration || 0) > 0) { const mouseEnter = () => this._pause(); const mouseLeave = () => this._resume(); el.addEventListener('mouseenter', mouseEnter); el.addEventListener('mouseleave', mouseLeave); this._cleanups.push(() => { el.removeEventListener('mouseenter', mouseEnter); el.removeEventListener('mouseleave', mouseLeave); }); } if (this.config.draggable) { this._bindDrag(el); } } _bindDrag(el: HTMLElement): void { let sx = 0, sy = 0, dx = 0, dy = 0, dragging = false; const down = (e: PointerEvent) => { if ((e.target as HTMLElement).closest('.met-close')) return; dragging = true; sx = e.clientX; sy = e.clientY; el.setPointerCapture(e.pointerId); el.style.transition = 'none'; this._pause(); }; const move = (e: PointerEvent) => { if (!dragging) return; dx = e.clientX - sx; dy = e.clientY - sy; el.style.transform = `translate(${dx}px, ${dy}px) rotate(${dx * 0.1}deg)`; el.style.opacity = String(Math.max(0, 1 - Math.abs(dx) / 200)); }; const up = (e: PointerEvent) => { if (!dragging) return; dragging = false; el.releasePointerCapture(e.pointerId); el.style.transition = ''; if (Math.abs(dx) > 120) { el.style.transform = `translate(${dx * 2}px, ${dy}px) rotate(${dx * 0.2}deg)`; el.style.opacity = '0'; setTimeout(() => this.close(true), 250); } else { el.style.transform = ''; el.style.opacity = ''; this._resume(); } dx = dy = 0; }; el.addEventListener('pointerdown', down); el.addEventListener('pointermove', move); el.addEventListener('pointerup', up); el.addEventListener('pointercancel', up); this._cleanups.push(() => { el.removeEventListener('pointerdown', down); el.removeEventListener('pointermove', move); el.removeEventListener('pointerup', up); el.removeEventListener('pointercancel', up); }); } _startTimer(resuming = false): void { if ((this.config.duration || 0) <= 0) return; if (!resuming) { this.startedAt = Date.now(); this.remaining = this.config.duration || 0; } const tick = (): void => { if (this.paused || this.closing) return; try { const elapsed = Date.now() - this.startedAt; this.remaining = Math.max(0, (this.config.duration || 0) - elapsed); if (this.barEl) { const ratio = this.remaining / (this.config.duration || 1); const t = this.config.progressDirection === 'vertical' ? `scaleY(${ratio})` : `scaleX(${ratio})`; this.barEl.style.transform = t; } if (this.remaining <= 0) { this.close(); return; } } catch (e) { console.error('Timer tick error:', e); if (Toast._onError) { try { Toast._onError({ source: 'timer', error: e as Error, toast: this }); } catch (_e) { /* noop */ } } } this.rafId = requestAnimationFrame(tick); }; this.rafId = requestAnimationFrame(tick); } _pause(): void { if (this.paused || (this.config.duration || 0) <= 0) return; this.paused = true; if (this.rafId !== null) cancelAnimationFrame(this.rafId); this.remaining = Math.max(0, (this.config.duration || 0) - (Date.now() - this.startedAt)); } _resume(): void { if (!this.paused) return; this.paused = false; this.startedAt = Date.now() - ((this.config.duration || 0) - this.remaining); this._startTimer(true); } update(partial: Partial): this { Toast.trigger('beforeUpdate', this); const typeChanged = partial.type && partial.type !== this.type; if (partial.type) this.type = partial.type; if (partial.title !== undefined) this.title = partial.title; if (partial.message !== undefined) this.message = partial.message; if (partial.html !== undefined) this.html = partial.html; if (!this.el) { Toast.trigger('afterUpdate', this); return this; } // 类型变更时同步更新 DOM 类名、边框颜色和进度条颜色 if (typeChanged) { const typeClasses = ['met-success', 'met-error', 'met-warning', 'met-info', 'met-loading', 'met-default']; typeClasses.forEach(c => this.el!.classList.remove(c)); this.el.classList.add(`met-${this.type}`); if (this.barEl) { const c = (TYPE_COLORS[this.type] as TypeColor) || (TYPE_COLORS.default as TypeColor); this.barEl.style.background = c.fg; } const side = this.el.querySelector('.met-side') as HTMLElement | null; if (side) { const c = (TYPE_COLORS[this.type] as TypeColor) || (TYPE_COLORS.default as TypeColor); side.style.background = c.fg; } } const content = this.el.querySelector('.met-content'); if (content) { const safeTitle = this.title ? `
${escapeHTML(this.title)}
` : ''; const safeMessage = this.html ? `
${this.html}
` : (this.message ? `
${escapeHTML(this.message)}
` : ''); content.innerHTML = `${safeTitle}${safeMessage}`; } // resetTimerOnUpdate: 更新内容后重置计时器 if (this.config.resetTimerOnUpdate && (this.config.duration || 0) > 0) { if (this.rafId !== null) cancelAnimationFrame(this.rafId); this.startedAt = Date.now(); this.remaining = this.config.duration || 0; this._startTimer(); } if (typeof this.config.onUpdate === 'function') { try { this.config.onUpdate(this); } catch (e) { console.error('onUpdate callback error:', e); } } Toast.trigger('afterUpdate', this); return this; } close(immediate = false): void { if (this.closing) return; this.closing = true; Toast.trigger('beforeClose', this); if (this.rafId !== null) cancelAnimationFrame(this.rafId); this._cleanups.forEach(fn => { try { fn(); } catch (_e) { /* noop */ } }); this._cleanups = []; const el = this.el; if (!el) { if (Toast._removeToast) Toast._removeToast(this.id); return; } // 立即脱离文档流 const container = el.parentNode; if (container && !immediate) { const elRect = el.getBoundingClientRect(); const containerRect = (container as HTMLElement).getBoundingClientRect(); el.style.position = 'absolute'; el.style.top = (elRect.top - containerRect.top) + 'px'; el.style.left = (elRect.left - containerRect.left) + 'px'; el.style.width = elRect.width + 'px'; el.style.margin = '0'; } el.classList.add('met-leaving'); el.classList.remove('met-show'); const pos = this.config.position || 'top-right'; const isRTL = getLocaleDirection(getCurrentLocale()) === 'rtl'; let transform = 'scale(.96)'; if (pos.includes('right')) transform = isRTL ? 'translateX(-120%)' : 'translateX(120%)'; else if (pos.includes('left')) transform = isRTL ? 'translateX(120%)' : 'translateX(-120%)'; else if (pos.startsWith('top')) transform = 'translateY(-20px)'; else transform = 'translateY(20px)'; el.style.transform = transform; el.style.opacity = '0'; setTimeout(() => this._destroy(), immediate ? 0 : 300); } _destroy(): void { if (this.el && this.el.parentNode) { this.el.parentNode.removeChild(this.el); } this.el = null; if (typeof this.config.onClose === 'function') { try { this.config.onClose(this); } catch (e) { console.error('onClose callback error:', e); } } Toast.trigger('afterClose', this); if (Toast._removeToast) Toast._removeToast(this.id); } } /** * 模块级共享容器缓存,所有 Toast 实例共用 */ export const _containerCache: Map> = new Map();