- React 适配器子包:@metona-team/metona-toast/react — useToast() hook + 声明式 <Toast /> 组件,组件卸载自动清理;react 为 optional peerDependency,主包保持零依赖 - loading 链式转换改原地 update:id 稳定不重建 DOM,转换后 duration 恢复默认自动关闭;update() 支持 duration 变更动态重启计时器 - _emit 幽灵实例修复:beforeShow 拦截后的 toast 不再注册进 _toasts - dragThreshold 配置项(默认 120px,原硬编码);RTL 容器 dir 属性 + 进度条/side 条/色条镜像 - dedupe 预设插件:相同 type+message 自动去重,支持 uninstall - jest 环境隔离测试独立成 tests/ssr.test.ts(resetModules 不再污染共享模块状态);新增 tests/setup.ts 补 TextEncoder - 文档:README/docs.html 补 React 适配器、dedupe、dragThreshold;CHANGELOG 更新;328 测试全过
252 lines
8.1 KiB
TypeScript
252 lines
8.1 KiB
TypeScript
/**
|
|
* MetonaToast Plugins — 插件系统
|
|
* @module plugins
|
|
* @version 0.4.0
|
|
*/
|
|
|
|
import { t } from './i18n.js';
|
|
import { Toast } from './toast.js';
|
|
import type { Plugin, PluginManager as IPluginManager, PluginUtils, ToastInstance } from './types.js';
|
|
|
|
/**
|
|
* 插件管理器
|
|
*/
|
|
class PluginManager implements IPluginManager {
|
|
plugins: Map<string, Plugin>;
|
|
initialized: boolean;
|
|
|
|
constructor() {
|
|
this.plugins = new Map();
|
|
this.initialized = false;
|
|
}
|
|
|
|
register(name: string, plugin: Plugin): PluginManager {
|
|
if (this.plugins.has(name)) {
|
|
console.warn(`Plugin "${name}" is already registered`);
|
|
return this;
|
|
}
|
|
if (!plugin || typeof plugin !== 'object' || !plugin.name) {
|
|
console.error(`Invalid plugin "${name}": must be an object with a "name" property`);
|
|
return this;
|
|
}
|
|
|
|
const stored: Plugin = { ...plugin, installed: false, enabled: true };
|
|
if (!stored.name) stored.name = name;
|
|
this.plugins.set(name, stored);
|
|
|
|
if (stored.install) {
|
|
try {
|
|
stored.install(this);
|
|
stored.installed = true;
|
|
} catch (e) {
|
|
console.error(`Failed to install plugin "${name}":`, e);
|
|
}
|
|
}
|
|
return this;
|
|
}
|
|
|
|
unregister(name: string): PluginManager {
|
|
const plugin = this.plugins.get(name);
|
|
if (!plugin) return this;
|
|
if (plugin.uninstall) {
|
|
try { plugin.uninstall(this); } catch (e) { console.error(`Failed to uninstall plugin "${name}":`, e); }
|
|
}
|
|
this.plugins.delete(name);
|
|
return this;
|
|
}
|
|
|
|
get(name: string): Plugin | null { return this.plugins.get(name) || null; }
|
|
has(name: string): boolean { return this.plugins.has(name); }
|
|
getAll(): Plugin[] { return Array.from(this.plugins.values()); }
|
|
getNames(): string[] { return Array.from(this.plugins.keys()); }
|
|
|
|
enable(name: string): PluginManager {
|
|
const p = this.plugins.get(name);
|
|
if (p) p.enabled = true;
|
|
return this;
|
|
}
|
|
disable(name: string): PluginManager {
|
|
const p = this.plugins.get(name);
|
|
if (p) p.enabled = false;
|
|
return this;
|
|
}
|
|
isEnabled(name: string): boolean {
|
|
const p = this.plugins.get(name);
|
|
return p ? !!p.enabled : false;
|
|
}
|
|
|
|
destroy(): void {
|
|
this.plugins.forEach((p) => {
|
|
if (p.destroy) { try { p.destroy(this); } catch (_e) { /* noop */ } }
|
|
});
|
|
this.plugins.clear();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 预设插件
|
|
*/
|
|
const presetPlugins: Record<string, Plugin> = {
|
|
/**
|
|
* 键盘快捷键插件 — ESC 关闭所有 Toast
|
|
*/
|
|
keyboard: {
|
|
name: 'keyboard',
|
|
version: '1.0.0',
|
|
description: 'ESC 关闭所有 Toast',
|
|
_handler: null as ((e: KeyboardEvent) => void) | null,
|
|
|
|
install(this: Plugin) {
|
|
if (typeof document === 'undefined') return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
document.querySelectorAll('.met-toast .met-close').forEach(btn => {
|
|
try { (btn as HTMLElement).click(); } catch (_e) { /* noop */ }
|
|
});
|
|
}
|
|
};
|
|
(this as Record<string, unknown>)._handler = handler;
|
|
document.addEventListener('keydown', handler);
|
|
},
|
|
|
|
uninstall(this: Plugin) {
|
|
const handler = (this as Record<string, unknown>)._handler as ((e: KeyboardEvent) => void) | null;
|
|
if (handler && typeof document !== 'undefined') {
|
|
document.removeEventListener('keydown', handler);
|
|
(this as Record<string, unknown>)._handler = null;
|
|
}
|
|
},
|
|
},
|
|
|
|
/**
|
|
* 持久化插件 — 自动保存/加载配置到 localStorage
|
|
*/
|
|
persistence: {
|
|
name: 'persistence',
|
|
version: '1.0.0',
|
|
description: '自动持久化配置到 localStorage',
|
|
storageKey: 'metona-toast-config',
|
|
|
|
install(this: Plugin) {
|
|
if (typeof localStorage !== 'undefined') {
|
|
try {
|
|
const key = (this as Record<string, unknown>).storageKey as string;
|
|
const saved = localStorage.getItem(key);
|
|
return saved ? JSON.parse(saved) : null;
|
|
} catch (_e) { return null; }
|
|
}
|
|
return null;
|
|
},
|
|
|
|
save(this: Plugin, config: Record<string, unknown>) {
|
|
if (typeof localStorage !== 'undefined') {
|
|
try {
|
|
const key = (this as Record<string, unknown>).storageKey as string;
|
|
localStorage.setItem(key, JSON.stringify(config));
|
|
} catch (_e) { /* noop */ }
|
|
}
|
|
},
|
|
|
|
uninstall(this: Plugin) {
|
|
if (typeof localStorage !== 'undefined') {
|
|
try {
|
|
const key = (this as Record<string, unknown>).storageKey as string;
|
|
localStorage.removeItem(key);
|
|
} catch (_e) { /* noop */ }
|
|
}
|
|
},
|
|
},
|
|
|
|
/**
|
|
* 无障碍插件 — 屏幕阅读器公告
|
|
*/
|
|
accessibility: {
|
|
name: 'accessibility',
|
|
version: '1.0.0',
|
|
description: '通过屏幕阅读器朗读 Toast 内容',
|
|
|
|
announce(toast: { type: string; title?: string; message?: string }) {
|
|
if (typeof document === 'undefined') return;
|
|
const el = document.createElement('div');
|
|
el.setAttribute('aria-live', 'assertive');
|
|
el.setAttribute('aria-atomic', 'true');
|
|
el.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
|
|
const typeName = t(toast.type) || toast.type;
|
|
el.textContent = `${typeName}: ${toast.title || ''} ${toast.message || ''}`;
|
|
document.body.appendChild(el);
|
|
setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el); }, 3000);
|
|
},
|
|
},
|
|
|
|
/**
|
|
* 去重插件 — 相同 type + message 的 toast 自动合并(更新已有的一条)
|
|
*/
|
|
dedupe: {
|
|
name: 'dedupe',
|
|
version: '1.0.0',
|
|
description: '相同类型和消息的 Toast 自动去重,更新已有实例而非重复弹出',
|
|
_off: null as (() => void) | null,
|
|
|
|
install(this: Plugin) {
|
|
const off = Toast.on('beforeShow', (toast: ToastInstance) => {
|
|
const existing = Array.from(Toast._registry.values()).find(
|
|
t => !t.closing && t.type === toast.type && t.message === toast.message
|
|
);
|
|
if (existing) {
|
|
existing.update({ title: toast.title || existing.title });
|
|
return false;
|
|
}
|
|
return undefined;
|
|
});
|
|
(this as Record<string, unknown>)._off = off;
|
|
},
|
|
|
|
uninstall(this: Plugin) {
|
|
const off = (this as Record<string, unknown>)._off as (() => void) | null;
|
|
if (off) off();
|
|
(this as Record<string, unknown>)._off = null;
|
|
},
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 插件工具
|
|
*/
|
|
const pluginUtils: PluginUtils = {
|
|
createManager(): PluginManager { return new PluginManager(); },
|
|
register(name: string, plugin: Plugin): PluginManager { return defaultPluginManager.register(name, plugin); },
|
|
unregister(name: string): PluginManager { return defaultPluginManager.unregister(name); },
|
|
get(name: string): Plugin | null { return defaultPluginManager.get(name); },
|
|
has(name: string): boolean { return defaultPluginManager.has(name); },
|
|
getAll(): Plugin[] { return defaultPluginManager.getAll(); },
|
|
getNames(): string[] { return defaultPluginManager.getNames(); },
|
|
enable(name: string): PluginManager { return defaultPluginManager.enable(name); },
|
|
disable(name: string): PluginManager { return defaultPluginManager.disable(name); },
|
|
isEnabled(name: string): boolean { return defaultPluginManager.isEnabled(name); },
|
|
getPreset(name: string): Plugin | null { return presetPlugins[name] || null; },
|
|
getAllPresets(): Record<string, Plugin> { return { ...presetPlugins }; },
|
|
|
|
createPlugin(config: Partial<Plugin>): Plugin {
|
|
return {
|
|
name: config.name || 'custom',
|
|
version: config.version || '1.0.0',
|
|
description: config.description || '',
|
|
hooks: config.hooks || {},
|
|
install: config.install || undefined,
|
|
uninstall: config.uninstall || undefined,
|
|
...config,
|
|
};
|
|
},
|
|
|
|
validatePlugin(plugin: Partial<Plugin>): { valid: boolean; errors: string[] } {
|
|
const errors: string[] = [];
|
|
if (!plugin || typeof plugin !== 'object') errors.push('Plugin must be an object');
|
|
if (!plugin.name && !plugin.version) errors.push('Plugin must have a name or version');
|
|
return { valid: errors.length === 0, errors };
|
|
},
|
|
};
|
|
|
|
const defaultPluginManager = new PluginManager();
|
|
|
|
export { presetPlugins, pluginUtils, defaultPluginManager, PluginManager };
|