/** * MetonaToast API — meToast 核心 API 对象 * @module api * @version 0.5.0 */ import { Toast, _containerCache } from './toast.js'; import { DEFAULTS, VERSION } from './constants.js'; import { t, setCurrentLocale } from './i18n.js'; import { applyTheme } from './themes.js'; import { confirmHTML, promptHTML, progressHTML, actionHTML } from './templates.js'; import { presetPlugins } from './plugins.js'; import type { ToastConfig, ToastOptions, ToastInstance, LoadingControl, ProgressControl, CountdownControl, ActionControl, QueueControl, GroupAPI, ActionButton, PromiseOptions, ConfirmOptions, PromptOptions, ProgressOptions, CountdownOptions, QueueOptions, StackOptions, MeToast, ErrorInfo, Plugin, StatusInfo, InitOptions, } from './types.js'; /** * 参数标准化工具 */ const normalizeArgs = (args: unknown[], defaultType = 'default'): ToastOptions => { const [first, second] = args; if (typeof first === 'string') { return { ...((second as Record) || {}), type: (second as Record)?.type as string || defaultType, message: first, }; } if (first && typeof first === 'object') { return { ...(first as Record), type: (first as Record).type as string || defaultType, } as ToastOptions; } return { type: defaultType, message: '', }; }; /** * meToast API 对象 */ const meToast: MeToast = { _toasts: new Map(), _config: { ...DEFAULTS } as unknown as ToastConfig, version: VERSION, configure(opts: Partial): MeToast { if (!opts || typeof opts !== 'object') return this; Object.assign(this._config, opts); if (opts.theme) { applyTheme(opts.theme); } if (opts.locale) { setCurrentLocale(opts.locale); } Toast.trigger('configChange'); return this; }, _emit(opts: ToastOptions): Toast { const merged: ToastOptions = { ...this._config, ...opts }; if (merged.content && !merged.message) merged.message = merged.content; const t = new Toast(merged); t.create(); // beforeShow/onBeforeShow 拦截(返回 false)时 el 为 null,不注册幽灵实例 if (t.el !== null) { this._toasts.set(t.id, t); } return t; }, _remove(id: string): void { this._toasts.delete(id); }, find(id: string): ToastInstance | undefined { if (!id) return undefined; return this._toasts.get(id); }, show(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance { const normalized = normalizeArgs([messageOrOpts, opts], 'default'); return this._emit(normalized); }, success(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance { const normalized = normalizeArgs([messageOrOpts, opts], 'success'); return this._emit(normalized); }, error(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance { const normalized = normalizeArgs([messageOrOpts, opts], 'error'); return this._emit(normalized); }, warning(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance { const normalized = normalizeArgs([messageOrOpts, opts], 'warning'); return this._emit(normalized); }, info(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): ToastInstance { const normalized = normalizeArgs([messageOrOpts, opts], 'info'); return this._emit(normalized); }, loading(messageOrOpts: string | ToastOptions, opts: ToastOptions = {}): LoadingControl { const normalized = normalizeArgs([messageOrOpts, opts], 'loading'); normalized.duration = 0; normalized.closeButton = false; normalized.showProgress = false; const toast = this._emit(normalized); return { id: toast.id, success: (msg?: string, o: ToastOptions = {}) => this._resolve(toast, 'success', msg || t('success'), o), error: (msg?: string, o: ToastOptions = {}) => this._resolve(toast, 'error', msg || t('error'), o), info: (msg?: string, o: ToastOptions = {}) => this._resolve(toast, 'info', msg || t('info'), o), warning: (msg?: string, o: ToastOptions = {}) => this._resolve(toast, 'warning', msg || t('warning'), o), update: (p: Partial) => { toast.update(p); return this; }, dismiss: () => toast.close(), }; }, promise(promise: Promise, opts: PromiseOptions = {}): Promise { if (!promise || typeof (promise as unknown as { then?: unknown }).then !== 'function') { console.error('MeToast.promise: first argument must be a Promise'); return Promise.reject(new Error('Invalid promise')); } const loadingMsg = opts.loading || t('loading'); const successMsg = opts.success || t('success'); const errorMsg = opts.error || t('error'); const ctrl = this.loading(loadingMsg); return Promise.resolve(promise) .then((data: T) => { ctrl.success(successMsg); return data; }) .catch((err: unknown) => { ctrl.error(errorMsg); throw err; }); }, /** * loading 链式转换 — 原地 update 同一实例(id 稳定,不重建 DOM) * duration 从 0 恢复为默认值,使转换后的 toast 自动关闭 */ _resolve(loadingToast: ToastInstance, type: string, message: string, opts: ToastOptions): ToastInstance | null { const old = this._toasts.get(loadingToast.id); if (!old) return null; old.update({ ...opts, type, message, duration: opts.duration ?? (old.config.duration || this._config.duration), } as ToastOptions); return old; }, confirm(message: string, opts: ConfirmOptions = {}): Promise { if (typeof message !== 'string') { console.error('MeToast.confirm: message must be a string'); return Promise.resolve(false); } return new Promise((resolve) => { let resolved = false; const safeResolve = (val: boolean) => { if (!resolved) { resolved = true; clearTimeout(safetyTimeout); resolve(val); } }; const safetyTimeout = setTimeout(() => safeResolve(false), 10000); const toast = this._emit({ ...opts, type: opts.type || 'warning', message, duration: 0, closeButton: false, closeOnClick: false, draggable: false, html: confirmHTML(opts), }); setTimeout(() => { const confirmBtn = toast.el?.querySelector('.met-confirm-btn') as HTMLElement | null; const cancelBtn = toast.el?.querySelector('.met-cancel-btn') as HTMLElement | null; if (confirmBtn) { confirmBtn.addEventListener('click', () => { toast.close(); safeResolve(true); }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { toast.close(); safeResolve(false); }); } }, 0); }); }, prompt(message: string, opts: PromptOptions = {}): Promise { if (typeof message !== 'string') { console.error('MeToast.prompt: message must be a string'); return Promise.resolve(null); } return new Promise((resolve) => { let resolved = false; const safeResolve = (val: string | null) => { if (!resolved) { resolved = true; clearTimeout(safetyTimeout); resolve(val); } }; const safetyTimeout = setTimeout(() => safeResolve(null), 10000); const toast = this._emit({ ...opts, type: opts.type || 'info', message, duration: 0, closeButton: false, closeOnClick: false, draggable: false, html: promptHTML(opts), }); setTimeout(() => { const input = toast.el?.querySelector('.met-input') as HTMLInputElement | null; const submitBtn = toast.el?.querySelector('.met-submit-btn') as HTMLElement | null; const cancelBtn = toast.el?.querySelector('.met-cancel-btn') as HTMLElement | null; if (input) { input.focus(); input.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { toast.close(); safeResolve(input.value); } }); } if (submitBtn) { submitBtn.addEventListener('click', () => { toast.close(); safeResolve(input?.value || null); }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { toast.close(); safeResolve(null); }); } }, 0); }); }, progress(messageOrOpts: string | ToastOptions, opts: ProgressOptions = {}): ProgressControl { const normalized = normalizeArgs([messageOrOpts, opts], 'info'); normalized.duration = 0; normalized.closeButton = false; normalized.showProgress = false; const toast = this._emit({ ...normalized, html: progressHTML(opts), }); return { id: toast.id, setProgress(percent: number) { const fill = toast.el?.querySelector('.met-progress-fill') as HTMLElement | null; const text = toast.el?.querySelector('.met-progress-text') as HTMLElement | null; if (fill) { fill.style.width = `${Math.min(100, Math.max(0, percent))}%`; } if (text) { text.textContent = `${Math.round(percent)}%`; } }, complete(message?: string) { this.setProgress(100); setTimeout(() => { toast.update({ type: 'success', message: message || t('success'), html: '', }); setTimeout(() => toast.close(), 1000); }, 300); }, error(message?: string) { toast.update({ type: 'error', message: message || t('error'), html: '', }); setTimeout(() => toast.close(), 2000); }, dismiss() { toast.close(); }, }; }, countdown(message: string, seconds = 10, opts: CountdownOptions = {}): CountdownControl { if (typeof message !== 'string') { console.error('MeToast.countdown: message must be a string'); return { id: '', cancel: () => {}, pause: () => {}, resume: () => {} }; } let remaining = Math.max(1, parseInt(String(seconds)) || 10); let timer: ReturnType | null = null; const toast = this._emit({ ...opts, type: opts.type || 'warning', message: message.replace(/\{seconds\}/g, String(remaining)), duration: 0, closeButton: true, showProgress: false, }); const tick = (): void => { remaining--; if (remaining <= 0) { if (timer !== null) clearInterval(timer); toast.close(); if (typeof opts.onComplete === 'function') { try { opts.onComplete(); } catch (e) { console.error('countdown onComplete error:', e); } } return; } toast.update({ message: message.replace(/\{seconds\}/g, String(remaining)), }); }; timer = setInterval(tick, 1000); return { id: toast.id, cancel() { if (timer !== null) clearInterval(timer); toast.close(); }, pause() { if (timer !== null) clearInterval(timer); }, resume() { if (timer !== null) clearInterval(timer); timer = setInterval(tick, 1000); }, }; }, action(messageOrOpts: string | ToastOptions, actions: ActionButton[] = [], opts: ToastOptions = {}): ActionControl { const normalized = normalizeArgs([messageOrOpts, opts], 'info'); normalized.duration = opts.duration ?? 0; normalized.closeButton = opts.closeButton ?? true; const toast = this._emit({ ...normalized, html: (normalized.html || '') + actionHTML(actions), }); if (Array.isArray(actions)) { setTimeout(() => { actions.forEach((a, i) => { const btn = toast.el?.querySelector(`.met-action-btn-${i}`) as HTMLElement | null; if (btn && typeof a.onClick === 'function') { btn.addEventListener('click', (e: Event) => { // 阻止冒泡到 toast 的 closeOnClick 处理器,由 close 选项控制是否关闭 e.stopPropagation(); try { a.onClick(toast); } catch (err) { console.error('Action onClick error:', err); } if (a.close !== false) toast.close(); }); } }); }, 0); } return { id: toast.id, toast, dismiss: () => toast.close() }; }, queue(messages: Array, opts: QueueOptions = {}): QueueControl { if (!Array.isArray(messages)) { console.error('MeToast.queue: messages must be an array'); const p = Promise.resolve(); return { then: (fn) => p.then(fn), catch: (rj) => p.catch(rj), cancel: () => {} }; } let cancelled = false; const promise = new Promise((resolve) => { let index = 0; const delay = opts.delay || 1000; const userOnClose = opts.onClose; const showNext = (): void => { if (cancelled || index >= messages.length) { resolve(); return; } const message = messages[index]; index++; const msg = typeof message === 'string' ? message : ((message as ToastOptions)?.message || ''); const msgObj: ToastOptions = (typeof message === 'object' && message !== null) ? message as ToastOptions : {}; const msgOnClose = msgObj.onClose; this._emit({ ...opts, ...msgObj, message: msg, duration: msgObj.duration || opts.duration || 3000, onClose: (toast: ToastInstance) => { if (typeof msgOnClose === 'function') { try { msgOnClose(toast); } catch (_e) { /* noop */ } } if (typeof userOnClose === 'function') { try { userOnClose(toast); } catch (_e) { /* noop */ } } setTimeout(showNext, delay); }, } as ToastOptions); }; showNext(); }); return { then: (fn, rj) => promise.then(fn, rj), catch: (rj) => promise.catch(rj), cancel: () => { cancelled = true; }, }; }, stack(messages: Array, opts: StackOptions = {}): void { if (!Array.isArray(messages)) { console.error('MeToast.stack: messages must be an array'); return; } messages.forEach((message, index) => { setTimeout(() => { const msg = typeof message === 'string' ? message : ((message as ToastOptions)?.message || ''); this._emit({ ...opts, ...((typeof message === 'object' && message !== null) ? message as ToastOptions : {}), message: msg, } as ToastOptions); }, index * (opts.stagger || 100)); }); }, dismiss(id?: string): void { if (id) { const t = this._toasts.get(id); if (t) t.close(); return; } this._toasts.forEach(t => t.close()); }, /** * 立即移除指定 Toast(不触发离场动画) */ removeToast(id: string): void { if (!id) return; const t = this._toasts.get(id); if (t) t.remove(); }, clear(position?: string): void { this._toasts.forEach(t => { if (!position || t.config.position === position) t.close(); }); }, getAll(): Map { return new Map(this._toasts); }, count(): number { return this._toasts.size; }, group(name: string): GroupAPI { const self = this; const methods = ['show', 'success', 'error', 'warning', 'info', 'loading', 'action']; const g: GroupAPI = { _group: name } as unknown as GroupAPI; methods.forEach(m => { (g as unknown as Record unknown>)[m] = (...args: unknown[]) => { const lastArg = args[args.length - 1]; const isObj = typeof lastArg === 'object' && lastArg !== null && !Array.isArray(lastArg); const selfMethods = self as unknown as Record unknown>; if (isObj && args.length === 1) { return selfMethods[m]({ ...(lastArg as Record), group: name }); } if (isObj) { args.pop(); return selfMethods[m](...args, { ...(lastArg as Record), group: name }); } return selfMethods[m](...args, { group: name }); }; }); g.dismiss = () => self.dismissGroup(name); g.count = () => self._groupCount(name); return g; }, dismissGroup(name: string): void { this._toasts.forEach(t => { if (t.group === name) t.close(); }); }, _groupCount(name: string): number { let c = 0; this._toasts.forEach(t => { if (t.group === name) c++; }); return c; }, getToasts(): ToastInstance[] { return Array.from(this._toasts.values()); }, hasToasts(): boolean { return this._toasts.size > 0; }, getToast(id: string): ToastInstance | null { if (!id) return null; return this._toasts.get(id) || null; }, closeAll(): void { this._toasts.forEach(t => t.close()); }, clearAll(): void { this._toasts.forEach(t => t.close()); }, pauseAll(): void { this._toasts.forEach(t => t._pause()); }, resumeAll(): void { this._toasts.forEach(t => t._resume()); }, updateAll(partial: Partial): void { if (partial && typeof partial === 'object') { this._toasts.forEach(t => t.update(partial)); } }, findToasts(predicate: (toast: ToastInstance) => boolean): ToastInstance[] { if (typeof predicate !== 'function') return []; return Array.from(this._toasts.values()).filter(predicate); }, findByType(type: string): ToastInstance[] { return this.findToasts(t => t.type === type); }, findByPosition(position: string): ToastInstance[] { return this.findToasts(t => t.config.position === position); }, // ====== 以下方法依赖子模块(themes/i18n/plugins/animations),由 index.ts 注入 ====== init(options: InitOptions = {}): MeToast { Toast.trigger('beforeInit'); this._destroyed = false; if (options.config) { this.configure(options.config); } if (options.theme && this.themes) { this.themes.switchTheme(options.theme); } if (options.locale && this.i18n) { this.i18n.switchLocale(options.locale); } if (options.plugins && Array.isArray(options.plugins)) { options.plugins.forEach((p) => { this.use(p); }); } Toast.trigger('afterInit'); return this; }, getStatus(): StatusInfo { return { version: VERSION, toasts: this._toasts.size, theme: this.themes?.getCurrentTheme?.() || 'auto', locale: this.i18n?.getCurrentLocale?.() || 'zh-CN', plugins: this.plugins?.getNames?.() || [], animations: this.animations?.getActiveCount?.() || 0, }; }, getConfig(): ToastConfig { return { ...this._config }; }, updateConfig(config: Partial): MeToast { if (config && typeof config === 'object') { Object.assign(this._config, config); Toast.trigger('configChange'); } return this; }, resetConfig(): MeToast { const keys = Object.keys(this._config); keys.forEach(k => delete (this._config as Record)[k]); Object.assign(this._config, DEFAULTS); Toast.trigger('configChange'); return this; }, use(plugin: string | Plugin, options: Record = {}): MeToast { if (typeof plugin === 'string') { const preset = presetPlugins[plugin]; if (!preset) { console.warn(`Preset plugin "${plugin}" not found`); return this; } this.plugins.register(plugin, { ...preset, ...options }); // 连接插件钩子(重复 use 前先卸载旧钩子,防止重复注册) // 注意:off 引用存在已注册的插件对象上(stored),uninstall 时 this 即该对象 const stored = this.plugins.get(plugin) as (Record & Plugin) | null; if (plugin === 'accessibility') { if (stored && typeof stored._off === 'function') { (stored._off as () => void)(); } const acc = preset as Record void>; if (stored) { stored._off = Toast.on('afterShow', (toast: ToastInstance) => { if (typeof acc.announce === 'function') acc.announce(toast); }); } } if (plugin === 'persistence') { if (stored && typeof stored._saveOff === 'function') { (stored._saveOff as () => void)(); } const saved = typeof preset.install === 'function' ? preset.install(this.plugins as unknown as import('./types.js').PluginManager) : null; if (saved) this.configure(saved as Partial); const p = preset as Record void>; if (stored) { stored._saveOff = Toast.on('afterClose', () => { if (typeof p.save === 'function') p.save(this.getConfig()); }); } } } else if (plugin && typeof plugin === 'object') { this.plugins.register(plugin.name || 'custom', { ...plugin, ...options } as Plugin); } return this; }, destroy(): void { if (this._destroyed) return; this._destroyed = true; Toast.trigger('beforeDestroy'); this.dismiss(); // 清理子模块 if (this.plugins && typeof (this.plugins as unknown as Record).destroy === 'function') { (this.plugins as unknown as Record void>).destroy(); } if (this.themes) { if (typeof this.themes.clearThemeListeners === 'function') { this.themes.clearThemeListeners(); } if (typeof this.themes.unwatchSystemTheme === 'function') { this.themes.unwatchSystemTheme(); } } if (this.i18n && typeof this.i18n.clearLocaleListeners === 'function') { this.i18n.clearLocaleListeners(); } if (this.animations && typeof this.animations.destroy === 'function') { this.animations.destroy(); } // 清理 DOM 与静态资源 if (typeof document !== 'undefined') { const containers = document.querySelectorAll('.met-container'); containers.forEach((c) => { if (c.parentNode) c.parentNode.removeChild(c); }); ['metona-toast-styles', 'metona-toast-theme-css', 'metona-toast-custom-styles', 'metona-toast-anim-styles'].forEach((id) => { const el = document.getElementById(id); if (el && el.parentNode) el.parentNode.removeChild(el); }); } this._toasts.clear(); _containerCache.clear(); Toast._registry.clear(); // afterDestroy 必须在清理 hooks 之前触发 Toast.trigger('afterDestroy'); Toast._hooks.clear(); }, // 子模块引用(由 index.ts 注入实际实现) animations: null as unknown as MeToast['animations'], themes: null as unknown as MeToast['themes'], i18n: null as unknown as MeToast['i18n'], plugins: null as unknown as MeToast['plugins'], presetPlugins: {} as MeToast['presetPlugins'], }; // 连接 Toast 静态回调到 meToast 实例 Toast.setCallbacks({ onError: (info: ErrorInfo) => { const onError = meToast._config.onError; if (typeof onError === 'function') { try { onError(info); } catch (_e) { /* noop */ } } }, removeToast: (id: string) => { meToast._toasts.delete(id); }, }); export { meToast as default, meToast };