Initial commit: MetonaEditor v0.1.0
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* MetonaEditor Animations - 动画管理
|
||||
* @module animations
|
||||
* @version 0.1.0
|
||||
* @description 动画注册与管理
|
||||
*/
|
||||
|
||||
import { ANIMATIONS } from './constants.js';
|
||||
|
||||
const animationMap = new Map();
|
||||
|
||||
// 注册默认动画
|
||||
Object.entries(ANIMATIONS).forEach(([name, config]) => {
|
||||
animationMap.set(name, config);
|
||||
});
|
||||
|
||||
/**
|
||||
* 动画工具函数
|
||||
*/
|
||||
export const animationUtils = {
|
||||
register(name, config) {
|
||||
animationMap.set(name, {
|
||||
name,
|
||||
enter: config.enter || {},
|
||||
leave: config.leave || {},
|
||||
duration: config.duration || 300,
|
||||
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
});
|
||||
},
|
||||
|
||||
unregister(name) {
|
||||
animationMap.delete(name);
|
||||
},
|
||||
|
||||
get(name) {
|
||||
return animationMap.get(name) || ANIMATIONS[name] || null;
|
||||
},
|
||||
|
||||
getAnimationNames() {
|
||||
return Array.from(animationMap.keys());
|
||||
},
|
||||
|
||||
getActiveCount() {
|
||||
return 0;
|
||||
},
|
||||
|
||||
cancelAll() {
|
||||
// CSS动画由浏览器原生管理
|
||||
},
|
||||
|
||||
reset() {
|
||||
animationMap.clear();
|
||||
Object.entries(ANIMATIONS).forEach(([name, config]) => {
|
||||
animationMap.set(name, config);
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
animationMap.clear();
|
||||
},
|
||||
};
|
||||
|
||||
export const animationPresets = {};
|
||||
|
||||
export const createAnimation = (config = {}) => ({
|
||||
enter: config.enter || {},
|
||||
leave: config.leave || {},
|
||||
duration: config.duration || 300,
|
||||
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* MetonaEditor Constants - 常量定义
|
||||
* @module constants
|
||||
* @version 0.1.0
|
||||
* @description 默认配置、主题、动画、工具栏等常量
|
||||
*/
|
||||
|
||||
import { ICONS } from './icons.js';
|
||||
import { LOCALES } from './locales.js';
|
||||
|
||||
/**
|
||||
* 默认工具栏按钮序列
|
||||
* '|' 表示分组分隔符
|
||||
*/
|
||||
export const DEFAULT_TOOLBAR = [
|
||||
'bold', 'italic', 'strikethrough', 'code', '|',
|
||||
'h1', 'h2', 'h3', '|',
|
||||
'quote', 'ul', 'ol', '|',
|
||||
'link', 'image', 'table', 'hr', '|',
|
||||
'undo', 'redo', '|',
|
||||
'edit', 'split', 'preview', 'fullscreen',
|
||||
];
|
||||
|
||||
/**
|
||||
* 默认配置
|
||||
*/
|
||||
export const DEFAULTS = Object.freeze({
|
||||
// 内容
|
||||
value: '',
|
||||
placeholder: '',
|
||||
// 视图模式:edit / split / preview
|
||||
mode: 'split',
|
||||
// 高度:数字为 px,字符串原样使用
|
||||
height: 400,
|
||||
// 工具栏:数组或 false(隐藏)
|
||||
toolbar: DEFAULT_TOOLBAR,
|
||||
// 字数统计状态栏
|
||||
wordCount: true,
|
||||
// 自动聚焦
|
||||
autofocus: false,
|
||||
// 拼写检查
|
||||
spellcheck: false,
|
||||
// 历史栈上限
|
||||
historyLimit: 100,
|
||||
// 同步滚动(分屏模式)
|
||||
syncScroll: true,
|
||||
// 制表符插入的空格数(0 表示插入 \t)
|
||||
tabSize: 2,
|
||||
// 主题:light / dark / auto / warm / 自定义
|
||||
theme: 'auto',
|
||||
// 国际化
|
||||
locale: 'zh-CN',
|
||||
// 自定义 Markdown 渲染函数 (md, env) => html,覆盖内置解析器
|
||||
render: null,
|
||||
// 自定义代码高亮函数 (code, lang) => html
|
||||
highlight: null,
|
||||
// 自定义渲染前的 HTML 净化函数(接收渲染后的 HTML,返回安全 HTML)
|
||||
sanitize: null,
|
||||
// 外观
|
||||
className: '',
|
||||
style: {},
|
||||
// 插件
|
||||
plugins: [],
|
||||
// 生命周期回调
|
||||
onChange: null,
|
||||
onInput: null,
|
||||
onFocus: null,
|
||||
onBlur: null,
|
||||
onSave: null,
|
||||
onModeChange: null,
|
||||
onFullscreen: null,
|
||||
onCreate: null,
|
||||
onDestroy: null,
|
||||
});
|
||||
|
||||
// Icons - 从 icons.js 导入并重新导出(保持单一来源)
|
||||
export { ICONS };
|
||||
|
||||
/**
|
||||
* 类型颜色配置(保留用于状态提示与按钮强调)
|
||||
*/
|
||||
export const TYPE_COLORS = {
|
||||
success: { fg: '#10b981', bg: 'rgba(16, 185, 129, 0.1)' },
|
||||
error: { fg: '#ef4444', bg: 'rgba(239, 68, 68, 0.1)' },
|
||||
warning: { fg: '#f59e0b', bg: 'rgba(245, 158, 11, 0.1)' },
|
||||
info: { fg: '#3b82f6', bg: 'rgba(59, 130, 246, 0.1)' },
|
||||
loading: { fg: '#6366f1', bg: 'rgba(99, 102, 241, 0.1)' },
|
||||
default: { fg: '#6b7280', bg: 'rgba(107, 114, 128, 0.1)' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 主题配置
|
||||
*/
|
||||
export const THEMES = {
|
||||
light: {
|
||||
bg: 'rgba(255, 255, 255, 0.96)',
|
||||
text: '#1f2937',
|
||||
border: 'rgba(0, 0, 0, 0.08)',
|
||||
shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.18), 0 4px 14px -4px rgba(0, 0, 0, 0.08)',
|
||||
hoverShadow: '0 14px 48px -10px rgba(0, 0, 0, 0.22), 0 6px 18px -4px rgba(0, 0, 0, 0.10)',
|
||||
// 编辑器专属
|
||||
toolbarBg: 'rgba(248, 249, 250, 0.92)',
|
||||
textareaBg: '#ffffff',
|
||||
previewBg: '#ffffff',
|
||||
codeBg: 'rgba(243, 244, 246, 1)',
|
||||
codeText: '#1f2937',
|
||||
accent: '#3b82f6',
|
||||
muted: '#6b7280',
|
||||
},
|
||||
dark: {
|
||||
bg: 'rgba(28, 32, 40, 0.94)',
|
||||
text: '#e6e8eb',
|
||||
border: 'rgba(255, 255, 255, 0.1)',
|
||||
shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.6), 0 4px 14px -4px rgba(0, 0, 0, 0.4)',
|
||||
hoverShadow: '0 14px 48px -8px rgba(0, 0, 0, 0.6), 0 6px 18px -4px rgba(0, 0, 0, 0.4)',
|
||||
toolbarBg: 'rgba(22, 26, 33, 0.92)',
|
||||
textareaBg: '#1c2028',
|
||||
previewBg: '#1c2028',
|
||||
codeBg: 'rgba(15, 18, 24, 1)',
|
||||
codeText: '#e6e8eb',
|
||||
accent: '#60a5fa',
|
||||
muted: '#9ca3af',
|
||||
},
|
||||
auto: 'auto',
|
||||
warm: {
|
||||
bg: 'rgba(255, 251, 235, 0.96)',
|
||||
text: '#78350f',
|
||||
border: 'rgba(245, 158, 11, 0.2)',
|
||||
shadow: '0 10px 36px -10px rgba(245, 158, 11, 0.18), 0 4px 14px -4px rgba(245, 158, 11, 0.08)',
|
||||
hoverShadow: '0 14px 48px -10px rgba(245, 158, 11, 0.22), 0 6px 18px -4px rgba(245, 158, 11, 0.10)',
|
||||
toolbarBg: 'rgba(254, 243, 199, 0.92)',
|
||||
textareaBg: '#fffbeb',
|
||||
previewBg: '#fffbeb',
|
||||
codeBg: 'rgba(254, 215, 170, 0.6)',
|
||||
codeText: '#78350f',
|
||||
accent: '#d97706',
|
||||
muted: '#a16207',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 位置配置(保留,预览面板提示等场景可用)
|
||||
*/
|
||||
export const POSITIONS = [
|
||||
'top-left', 'top-center', 'top-right',
|
||||
'bottom-left', 'bottom-center', 'bottom-right',
|
||||
];
|
||||
|
||||
/**
|
||||
* 动画配置(保留,DOM 过渡与未来扩展使用)
|
||||
*/
|
||||
export const ANIMATIONS = {
|
||||
slide: { enter: { transform: 'translateX(80px)', opacity: 0 }, leave: { transform: 'translateX(120%)', opacity: 0 }, duration: 400, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
|
||||
fade: { enter: { opacity: 0, filter: 'blur(3px)' }, leave: { opacity: 0, filter: 'blur(3px)' }, duration: 500, easing: 'ease' },
|
||||
scale: { enter: { transform: 'scale(0.55)', opacity: 0 }, leave: { transform: 'scale(0.55)', opacity: 0 }, duration: 450, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
|
||||
bounce: { enter: { transform: 'translateY(-80px)', opacity: 0 }, leave: { transform: 'translateY(20px)', opacity: 0 }, duration: 650, easing: 'ease' },
|
||||
flip: { enter: { transform: 'perspective(500px) rotateX(-90deg)', opacity: 0 }, leave: { transform: 'perspective(500px) rotateX(90deg)', opacity: 0 }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
|
||||
rotate: { enter: { transform: 'rotate(-25deg) scale(0.6)', opacity: 0 }, leave: { transform: 'rotate(25deg) scale(0.6)', opacity: 0 }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
|
||||
zoom: { enter: { transform: 'scale(0.1)', opacity: 0 }, leave: { transform: 'scale(0.1)', opacity: 0 }, duration: 500, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 主题类型
|
||||
*/
|
||||
export const THEME_TYPES = ['light', 'dark', 'auto'];
|
||||
|
||||
/**
|
||||
* 动画类型
|
||||
*/
|
||||
export const ANIMATION_TYPES = ['slide', 'fade', 'scale', 'bounce', 'flip', 'rotate', 'zoom'];
|
||||
|
||||
/**
|
||||
* 编辑器视图模式
|
||||
*/
|
||||
export const EDIT_MODES = ['edit', 'split', 'preview'];
|
||||
|
||||
/**
|
||||
* 内置工具栏动作清单
|
||||
*/
|
||||
export const TOOLBAR_ACTIONS = [
|
||||
'bold', 'italic', 'strikethrough', 'underline', 'code',
|
||||
'h1', 'h2', 'h3', 'quote', 'ul', 'ol', 'indent', 'outdent', 'hr',
|
||||
'link', 'image', 'table',
|
||||
'undo', 'redo',
|
||||
'edit', 'split', 'preview', 'fullscreen',
|
||||
];
|
||||
|
||||
// Locales - 从 locales.js 导入并重新导出
|
||||
export { LOCALES };
|
||||
|
||||
/**
|
||||
* 进度条方向(保留兼容)
|
||||
*/
|
||||
export const PROGRESS_DIRECTIONS = ['horizontal', 'vertical'];
|
||||
+945
@@ -0,0 +1,945 @@
|
||||
/**
|
||||
* MetonaEditor Core - 编辑器核心
|
||||
* @module core
|
||||
* @version 0.1.0
|
||||
* @description MarkdownEditor 类实现:DOM 构建、事件、渲染、历史栈、选区操作、模式切换
|
||||
*/
|
||||
|
||||
import { generateId, escapeHTML, isBrowser } from './utils.js';
|
||||
import { DEFAULTS, ICONS, THEMES } from './constants.js';
|
||||
import { injectStyles } from './styles.js';
|
||||
import { t as i18nT, getCurrentLocale, getLocaleDirection } from './i18n.js';
|
||||
import { parseMarkdown } from './parser.js';
|
||||
import { presetPlugins } from './plugins.js';
|
||||
import { getTheme } from './themes.js';
|
||||
|
||||
const MODES = ['edit', 'split', 'preview'];
|
||||
|
||||
/**
|
||||
* 解析主题名称为实际主题(auto -> light/dark)
|
||||
*/
|
||||
const resolveThemeName = (theme) => {
|
||||
if (theme && theme !== 'auto') return theme;
|
||||
return getTheme('auto');
|
||||
};
|
||||
|
||||
/**
|
||||
* MarkdownEditor 编辑器类
|
||||
*/
|
||||
class MarkdownEditor {
|
||||
/**
|
||||
* 静态事件钩子(全局,所有实例共享,供插件使用)
|
||||
*/
|
||||
static _hooks = new Map();
|
||||
|
||||
static on(name, fn) {
|
||||
if (!this._hooks.has(name)) this._hooks.set(name, []);
|
||||
this._hooks.get(name).push(fn);
|
||||
return () => this.off(name, fn);
|
||||
}
|
||||
|
||||
static off(name, fn) {
|
||||
const list = this._hooks.get(name);
|
||||
if (list) this._hooks.set(name, list.filter((f) => f !== fn));
|
||||
}
|
||||
|
||||
static trigger(name, instance) {
|
||||
const list = this._hooks.get(name);
|
||||
if (list) list.forEach((fn) => {
|
||||
try { fn(instance); } catch (e) { console.error(`MeEditor hook "${name}" error:`, e); }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|HTMLElement} container - 容器选择器或元素
|
||||
* @param {Object} options - 配置项(见 DEFAULTS)
|
||||
*/
|
||||
constructor(container, options = {}) {
|
||||
if (!isBrowser()) {
|
||||
this._destroyed = true;
|
||||
this._value = options.value || '';
|
||||
return this;
|
||||
}
|
||||
|
||||
this.id = options.id || generateId();
|
||||
this.container = typeof container === 'string'
|
||||
? document.querySelector(container)
|
||||
: container;
|
||||
|
||||
if (!this.container) {
|
||||
console.error('MeEditor: container not found:', container);
|
||||
this._destroyed = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
this.config = { ...DEFAULTS, ...options };
|
||||
if (options.style && typeof options.style === 'object') {
|
||||
this.config.style = { ...options.style };
|
||||
}
|
||||
|
||||
this._value = this.config.value || '';
|
||||
this._mode = MODES.includes(this.config.mode) ? this.config.mode : 'split';
|
||||
this._history = [];
|
||||
this._historyIndex = -1;
|
||||
this._cleanups = [];
|
||||
this._plugins = [];
|
||||
this._listeners = {};
|
||||
this._renderRaf = null;
|
||||
this._historyTimer = null;
|
||||
this._fullscreen = false;
|
||||
this._destroyed = false;
|
||||
|
||||
// 渲染函数:自定义覆盖内置解析器
|
||||
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
|
||||
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
|
||||
|
||||
MarkdownEditor.trigger('beforeCreate', this);
|
||||
|
||||
injectStyles();
|
||||
this._buildDOM();
|
||||
this._buildToolbar();
|
||||
this._bindEvents();
|
||||
|
||||
// 初始内容
|
||||
this.textarea.value = this._value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
|
||||
// 安装配置中的插件
|
||||
if (Array.isArray(this.config.plugins)) {
|
||||
this.config.plugins.forEach((p) => this.use(p));
|
||||
}
|
||||
|
||||
if (this.config.autofocus) this.focus();
|
||||
|
||||
if (typeof this.config.onCreate === 'function') {
|
||||
try { this.config.onCreate(this); } catch (e) { console.error('onCreate error:', e); }
|
||||
}
|
||||
|
||||
MarkdownEditor.trigger('afterCreate', this);
|
||||
}
|
||||
|
||||
// ============ DOM 构建 ============
|
||||
|
||||
_buildDOM() {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = `me-wrapper me-theme-${resolveThemeName(this.config.theme)}`;
|
||||
wrapper.dataset.id = this.id;
|
||||
wrapper.setAttribute('role', 'application');
|
||||
wrapper.setAttribute('aria-label', i18nT('edit'));
|
||||
|
||||
if (this.config.className) {
|
||||
this.config.className.split(/\s+/).filter(Boolean).forEach((c) => wrapper.classList.add(c));
|
||||
}
|
||||
|
||||
// 高度
|
||||
const h = this.config.height;
|
||||
wrapper.style.height = typeof h === 'number' ? `${h}px` : (h || '400px');
|
||||
|
||||
// 自定义样式
|
||||
if (this.config.style && typeof this.config.style === 'object') {
|
||||
Object.entries(this.config.style).forEach(([k, v]) => { wrapper.style[k] = v; });
|
||||
}
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'me-toolbar';
|
||||
toolbar.setAttribute('role', 'toolbar');
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = `me-body me-mode-${this._mode}`;
|
||||
|
||||
const editorPane = document.createElement('div');
|
||||
editorPane.className = 'me-editor-pane';
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'me-textarea';
|
||||
textarea.spellcheck = !!this.config.spellcheck;
|
||||
textarea.placeholder = this.config.placeholder || i18nT('placeholder');
|
||||
textarea.setAttribute('aria-label', i18nT('edit'));
|
||||
editorPane.appendChild(textarea);
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'me-divider';
|
||||
divider.setAttribute('role', 'separator');
|
||||
|
||||
const previewPane = document.createElement('div');
|
||||
previewPane.className = 'me-preview-pane';
|
||||
previewPane.setAttribute('role', 'region');
|
||||
previewPane.setAttribute('aria-label', i18nT('preview'));
|
||||
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'me-preview';
|
||||
previewPane.appendChild(preview);
|
||||
|
||||
body.appendChild(editorPane);
|
||||
body.appendChild(divider);
|
||||
body.appendChild(previewPane);
|
||||
|
||||
wrapper.appendChild(toolbar);
|
||||
wrapper.appendChild(body);
|
||||
|
||||
// 状态栏
|
||||
let statusbar = null;
|
||||
if (this.config.wordCount) {
|
||||
statusbar = document.createElement('div');
|
||||
statusbar.className = 'me-statusbar';
|
||||
wrapper.appendChild(statusbar);
|
||||
}
|
||||
|
||||
this.container.appendChild(wrapper);
|
||||
|
||||
this.el = wrapper;
|
||||
this.toolbarEl = toolbar;
|
||||
this.bodyEl = body;
|
||||
this.editorPane = editorPane;
|
||||
this.previewPane = previewPane;
|
||||
this.dividerEl = divider;
|
||||
this.textarea = textarea;
|
||||
this.previewEl = preview;
|
||||
this.statusEl = statusbar;
|
||||
}
|
||||
|
||||
_buildToolbar() {
|
||||
const tools = this.config.toolbar;
|
||||
if (!tools || !Array.isArray(tools) || tools.length === 0) return;
|
||||
|
||||
const modeActions = [];
|
||||
tools.forEach((item) => {
|
||||
if (item === '|') {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'me-toolbar-sep';
|
||||
this.toolbarEl.appendChild(sep);
|
||||
return;
|
||||
}
|
||||
// 模式与全屏按钮归入右侧组
|
||||
if (item === 'edit' || item === 'split' || item === 'preview' || item === 'fullscreen') {
|
||||
modeActions.push(item);
|
||||
return;
|
||||
}
|
||||
this.toolbarEl.appendChild(this._createBtn(item));
|
||||
});
|
||||
|
||||
if (modeActions.length) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'me-toolbar-group';
|
||||
modeActions.forEach((item) => {
|
||||
const btn = this._createBtn(item);
|
||||
if (item === 'edit' || item === 'split' || item === 'preview') {
|
||||
btn.dataset.mode = item;
|
||||
if (item === this._mode) btn.classList.add('me-active');
|
||||
}
|
||||
group.appendChild(btn);
|
||||
});
|
||||
this.toolbarEl.appendChild(group);
|
||||
}
|
||||
}
|
||||
|
||||
_createBtn(item) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `me-btn me-btn-${item}`;
|
||||
btn.dataset.action = item;
|
||||
const label = i18nT(item) || item;
|
||||
btn.title = label;
|
||||
btn.setAttribute('aria-label', label);
|
||||
btn.innerHTML = ICONS[item] || `<span>${escapeHTML(item)}</span>`;
|
||||
return btn;
|
||||
}
|
||||
|
||||
// ============ 事件绑定 ============
|
||||
|
||||
_bindEvents() {
|
||||
const ta = this.textarea;
|
||||
|
||||
const onInput = () => {
|
||||
this._value = ta.value;
|
||||
this._scheduleRender();
|
||||
this._scheduleHistory();
|
||||
this._updateWordCount();
|
||||
this._emit('input', this._value);
|
||||
this._emit('change', this._value);
|
||||
if (typeof this.config.onInput === 'function') {
|
||||
try { this.config.onInput(this._value, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
if (typeof this.config.onChange === 'function') {
|
||||
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
};
|
||||
ta.addEventListener('input', onInput);
|
||||
|
||||
const onKeydown = (e) => this._handleKeydown(e);
|
||||
ta.addEventListener('keydown', onKeydown);
|
||||
|
||||
const onScroll = () => {
|
||||
if (!this.config.syncScroll || this._mode !== 'split') return;
|
||||
const max = ta.scrollHeight - ta.clientHeight;
|
||||
const ratio = max > 0 ? ta.scrollTop / max : 0;
|
||||
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
|
||||
this.previewPane.scrollTop = ratio * pmax;
|
||||
};
|
||||
ta.addEventListener('scroll', onScroll);
|
||||
|
||||
const onFocus = () => {
|
||||
this._emit('focus', this);
|
||||
if (typeof this.config.onFocus === 'function') {
|
||||
try { this.config.onFocus(this); } catch (e) { console.error(e); }
|
||||
}
|
||||
};
|
||||
const onBlur = () => {
|
||||
this._emit('blur', this);
|
||||
if (typeof this.config.onBlur === 'function') {
|
||||
try { this.config.onBlur(this); } catch (e) { console.error(e); }
|
||||
}
|
||||
};
|
||||
ta.addEventListener('focus', onFocus);
|
||||
ta.addEventListener('blur', onBlur);
|
||||
|
||||
// 工具栏点击
|
||||
const onToolbarClick = (e) => {
|
||||
const btn = e.target.closest('.me-btn');
|
||||
if (!btn) return;
|
||||
const mode = btn.dataset.mode;
|
||||
if (mode) {
|
||||
this.setMode(mode);
|
||||
return;
|
||||
}
|
||||
const action = btn.dataset.action;
|
||||
if (action) this.exec(action);
|
||||
};
|
||||
this.toolbarEl.addEventListener('click', onToolbarClick);
|
||||
|
||||
// 分隔条拖拽(分屏模式)
|
||||
const onDividerDown = (e) => this._bindDividerDrag(e);
|
||||
this.dividerEl.addEventListener('pointerdown', onDividerDown);
|
||||
|
||||
this._cleanups.push(() => {
|
||||
ta.removeEventListener('input', onInput);
|
||||
ta.removeEventListener('keydown', onKeydown);
|
||||
ta.removeEventListener('scroll', onScroll);
|
||||
ta.removeEventListener('focus', onFocus);
|
||||
ta.removeEventListener('blur', onBlur);
|
||||
this.toolbarEl.removeEventListener('click', onToolbarClick);
|
||||
this.dividerEl.removeEventListener('pointerdown', onDividerDown);
|
||||
});
|
||||
}
|
||||
|
||||
_handleKeydown(e) {
|
||||
// Tab 缩进
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
this._handleTab(e.shiftKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod) return;
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const shortcuts = {
|
||||
b: 'bold', i: 'italic', k: 'link', u: 'underline',
|
||||
e: 'code', '1': 'h1', '2': 'h2', '3': 'h3',
|
||||
q: 'quote',
|
||||
};
|
||||
|
||||
if (key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.undo();
|
||||
} else if ((key === 'z' && e.shiftKey) || key === 'y') {
|
||||
e.preventDefault();
|
||||
this.redo();
|
||||
} else if (key === 's') {
|
||||
e.preventDefault();
|
||||
this._emit('save', this._value);
|
||||
if (typeof this.config.onSave === 'function') {
|
||||
try { this.config.onSave(this._value, this); } catch (err) { console.error(err); }
|
||||
}
|
||||
} else if (shortcuts[key]) {
|
||||
e.preventDefault();
|
||||
this.exec(shortcuts[key]);
|
||||
}
|
||||
}
|
||||
|
||||
_handleTab(shift) {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const tabSize = this.config.tabSize || 0;
|
||||
const tabStr = tabSize > 0 ? ' '.repeat(tabSize) : '\t';
|
||||
|
||||
if (start === end) {
|
||||
// 无选区:插入缩进
|
||||
ta.value = ta.value.slice(0, start) + tabStr + ta.value.slice(end);
|
||||
ta.selectionStart = ta.selectionEnd = start + tabStr.length;
|
||||
} else {
|
||||
// 有选区:整块缩进/反缩进
|
||||
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
|
||||
const block = ta.value.slice(lineStart, end);
|
||||
const lines = block.split('\n');
|
||||
let newBlock;
|
||||
if (shift) {
|
||||
newBlock = lines.map((l) => l.replace(/^(\t| {1,4})/, '')).join('\n');
|
||||
} else {
|
||||
newBlock = lines.map((l) => tabStr + l).join('\n');
|
||||
}
|
||||
ta.value = ta.value.slice(0, lineStart) + newBlock + ta.value.slice(end);
|
||||
ta.selectionStart = lineStart;
|
||||
ta.selectionEnd = lineStart + newBlock.length;
|
||||
}
|
||||
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._emit('change', this._value);
|
||||
}
|
||||
|
||||
_bindDividerDrag(e) {
|
||||
if (this._mode !== 'split') return;
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const editorWidth = this.editorPane.getBoundingClientRect().width;
|
||||
const totalWidth = this.bodyEl.getBoundingClientRect().width;
|
||||
const divider = this.dividerEl;
|
||||
|
||||
const onMove = (ev) => {
|
||||
const dx = ev.clientX - startX;
|
||||
let newEditorWidth = editorWidth + dx;
|
||||
const min = 80;
|
||||
const max = totalWidth - 80 - divider.offsetWidth;
|
||||
newEditorWidth = Math.max(min, Math.min(max, newEditorWidth));
|
||||
const pct = (newEditorWidth / totalWidth) * 100;
|
||||
this.editorPane.style.flex = `0 0 ${pct}%`;
|
||||
this.previewPane.style.flex = `1 1 ${100 - pct}%`;
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
}
|
||||
|
||||
// ============ 渲染 ============
|
||||
|
||||
_scheduleRender() {
|
||||
if (this._renderRaf) return;
|
||||
this._renderRaf = requestAnimationFrame(() => {
|
||||
this._renderRaf = null;
|
||||
this._render();
|
||||
});
|
||||
}
|
||||
|
||||
_render() {
|
||||
if (this._mode === 'edit') return;
|
||||
MarkdownEditor.trigger('beforeRender', this);
|
||||
|
||||
const env = {
|
||||
highlight: this._highlightFn,
|
||||
locale: getCurrentLocale(),
|
||||
};
|
||||
|
||||
let html;
|
||||
try {
|
||||
html = this._renderFn(this._value, env);
|
||||
} catch (err) {
|
||||
console.error('MeEditor render error:', err);
|
||||
html = `<p style="color:#ef4444">${i18nT('renderError')}: ${escapeHTML(err.message)}</p>`;
|
||||
}
|
||||
|
||||
if (typeof this.config.sanitize === 'function') {
|
||||
try { html = this.config.sanitize(html); } catch (err) { console.error('sanitize error:', err); }
|
||||
}
|
||||
|
||||
this.previewEl.innerHTML = html;
|
||||
MarkdownEditor.trigger('afterRender', this);
|
||||
}
|
||||
|
||||
// ============ 历史栈 ============
|
||||
|
||||
_scheduleHistory() {
|
||||
if (this._historyTimer) clearTimeout(this._historyTimer);
|
||||
this._historyTimer = setTimeout(() => this._pushHistory(), 400);
|
||||
}
|
||||
|
||||
_pushHistory() {
|
||||
const cur = this._history[this._historyIndex];
|
||||
if (cur === this._value) return;
|
||||
this._history = this._history.slice(0, this._historyIndex + 1);
|
||||
this._history.push(this._value);
|
||||
const limit = this.config.historyLimit > 0 ? this.config.historyLimit : 100;
|
||||
while (this._history.length > limit) this._history.shift();
|
||||
this._historyIndex = this._history.length - 1;
|
||||
}
|
||||
|
||||
undo() {
|
||||
if (this._historyIndex <= 0) return this;
|
||||
this._historyIndex--;
|
||||
this._applyHistory();
|
||||
return this;
|
||||
}
|
||||
|
||||
redo() {
|
||||
if (this._historyIndex >= this._history.length - 1) return this;
|
||||
this._historyIndex++;
|
||||
this._applyHistory();
|
||||
return this;
|
||||
}
|
||||
|
||||
_applyHistory() {
|
||||
this._value = this._history[this._historyIndex];
|
||||
this.textarea.value = this._value;
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
this._emit('change', this._value);
|
||||
if (typeof this.config.onChange === 'function') {
|
||||
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
}
|
||||
|
||||
canUndo() { return this._historyIndex > 0; }
|
||||
canRedo() { return this._historyIndex < this._history.length - 1; }
|
||||
|
||||
// ============ 命令执行 ============
|
||||
|
||||
exec(action, ...args) {
|
||||
const actions = {
|
||||
bold: () => this._wrapSelection('**', '**'),
|
||||
italic: () => this._wrapSelection('*', '*'),
|
||||
strikethrough: () => this._wrapSelection('~~', '~~'),
|
||||
underline: () => this._wrapSelection('<u>', '</u>'),
|
||||
code: () => this._wrapSelection('`', '`'),
|
||||
h1: () => this._toggleLinePrefix('# '),
|
||||
h2: () => this._toggleLinePrefix('## '),
|
||||
h3: () => this._toggleLinePrefix('### '),
|
||||
quote: () => this._toggleLinePrefix('> '),
|
||||
ul: () => this._toggleLinePrefix('- '),
|
||||
ol: () => this._toggleLinePrefix('1. '),
|
||||
indent: () => this._handleTab(false),
|
||||
outdent: () => this._handleTab(true),
|
||||
hr: () => this._insertBlock('\n---\n'),
|
||||
link: () => this._insertLink(),
|
||||
image: () => this._insertImage(),
|
||||
table: () => this._insertTable(),
|
||||
undo: () => this.undo(),
|
||||
redo: () => this.redo(),
|
||||
edit: () => this.setMode('edit'),
|
||||
split: () => this.setMode('split'),
|
||||
preview: () => this.setMode('preview'),
|
||||
fullscreen: () => this.toggleFullscreen(),
|
||||
};
|
||||
const fn = actions[action];
|
||||
if (fn) fn.apply(this, args);
|
||||
return this;
|
||||
}
|
||||
|
||||
_wrapSelection(before, after) {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const selected = ta.value.slice(start, end);
|
||||
const text = selected || (i18nT('placeholder') || 'text');
|
||||
const inserted = before + text + after;
|
||||
|
||||
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end);
|
||||
ta.focus();
|
||||
|
||||
if (selected) {
|
||||
ta.selectionStart = start + before.length;
|
||||
ta.selectionEnd = start + before.length + text.length;
|
||||
} else {
|
||||
ta.selectionStart = ta.selectionEnd = start + before.length;
|
||||
}
|
||||
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
this._emit('change', this._value);
|
||||
}
|
||||
|
||||
_toggleLinePrefix(prefix) {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
|
||||
const lineEndPos = ta.value.indexOf('\n', start);
|
||||
const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos;
|
||||
const line = ta.value.slice(lineStart, lineEnd);
|
||||
|
||||
const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/);
|
||||
let newLine;
|
||||
if (existingMatch && existingMatch[0] === prefix) {
|
||||
newLine = line.slice(prefix.length);
|
||||
} else if (existingMatch) {
|
||||
newLine = prefix + line.slice(existingMatch[0].length);
|
||||
} else {
|
||||
newLine = prefix + line;
|
||||
}
|
||||
|
||||
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
|
||||
ta.focus();
|
||||
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
|
||||
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
this._emit('change', this._value);
|
||||
}
|
||||
|
||||
_insertBlock(text) {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const before = ta.value.slice(0, start);
|
||||
const needNL = before && !before.endsWith('\n');
|
||||
const insert = (needNL ? '\n' : '') + text;
|
||||
|
||||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd);
|
||||
ta.focus();
|
||||
const pos = start + insert.length;
|
||||
ta.selectionStart = ta.selectionEnd = pos;
|
||||
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
this._emit('change', this._value);
|
||||
}
|
||||
|
||||
_insertLink() {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const sel = ta.value.slice(start, end) || i18nT('link') || 'link';
|
||||
const url = 'https://';
|
||||
const insert = `[${sel}](${url})`;
|
||||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
|
||||
ta.focus();
|
||||
// 选中 url 便于替换
|
||||
const urlStart = start + sel.length + 3;
|
||||
ta.selectionStart = urlStart;
|
||||
ta.selectionEnd = urlStart + url.length;
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._emit('change', this._value);
|
||||
}
|
||||
|
||||
_insertImage() {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const sel = ta.value.slice(start, end) || i18nT('image') || 'image';
|
||||
const url = 'https://';
|
||||
const insert = ``;
|
||||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
|
||||
ta.focus();
|
||||
const urlStart = start + sel.length + 4;
|
||||
ta.selectionStart = urlStart;
|
||||
ta.selectionEnd = urlStart + url.length;
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._emit('change', this._value);
|
||||
}
|
||||
|
||||
_insertTable(rows = 3, cols = 3) {
|
||||
const header = Array.from({ length: cols }, (_, i) => `${i18nT('tableCols') || '列'}${i + 1}`).join(' | ');
|
||||
const sep = Array.from({ length: cols }, () => '---').join(' | ');
|
||||
let md = `| ${header} |\n| ${sep} |\n`;
|
||||
for (let r = 1; r < rows; r++) {
|
||||
const row = Array.from({ length: cols }, () => ' ').join(' | ');
|
||||
md += `| ${row} |\n`;
|
||||
}
|
||||
this._insertBlock('\n' + md);
|
||||
}
|
||||
|
||||
// ============ 模式与全屏 ============
|
||||
|
||||
setMode(mode) {
|
||||
if (!MODES.includes(mode) || mode === this._mode) return this;
|
||||
this._mode = mode;
|
||||
this.bodyEl.className = `me-body me-mode-${mode}`;
|
||||
this._updateModeButtons();
|
||||
if (mode !== 'edit') this._render();
|
||||
this._emit('modeChange', mode);
|
||||
if (typeof this.config.onModeChange === 'function') {
|
||||
try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
getMode() { return this._mode; }
|
||||
|
||||
_updateModeButtons() {
|
||||
this.toolbarEl.querySelectorAll('.me-btn[data-mode]').forEach((b) => {
|
||||
b.classList.toggle('me-active', b.dataset.mode === this._mode);
|
||||
});
|
||||
}
|
||||
|
||||
toggleFullscreen() {
|
||||
this._fullscreen = !this._fullscreen;
|
||||
this.el.classList.toggle('me-fullscreen', this._fullscreen);
|
||||
// 更新全屏按钮图标/标题
|
||||
const fsBtn = this.toolbarEl.querySelector('.me-btn-fullscreen');
|
||||
if (fsBtn) {
|
||||
fsBtn.title = this._fullscreen ? (i18nT('fullscreenExit') || '退出全屏') : (i18nT('fullscreen') || '全屏');
|
||||
}
|
||||
this._emit('fullscreen', this._fullscreen);
|
||||
if (typeof this.config.onFullscreen === 'function') {
|
||||
try { this.config.onFullscreen(this._fullscreen, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
isFullscreen() { return this._fullscreen; }
|
||||
|
||||
exitFullscreen() {
|
||||
if (this._fullscreen) this.toggleFullscreen();
|
||||
return this;
|
||||
}
|
||||
|
||||
// ============ 字数统计 ============
|
||||
|
||||
_updateWordCount() {
|
||||
if (!this.config.wordCount || !this.statusEl) return;
|
||||
const text = this._value || '';
|
||||
const chars = text.length;
|
||||
// 中文字符数 + 英文单词数
|
||||
const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
|
||||
const words = cnChars + enWords;
|
||||
const lines = text ? text.split('\n').length : 0;
|
||||
const readingMin = Math.max(1, Math.ceil(words / 300));
|
||||
|
||||
const parts = [
|
||||
`${i18nT('characters')}: ${chars}`,
|
||||
`${i18nT('words')}: ${words}`,
|
||||
`${i18nT('lines')}: ${lines}`,
|
||||
`${i18nT('readingTime')}: ${readingMin} ${i18nT('minutes')}`,
|
||||
];
|
||||
this.statusEl.textContent = parts.join(' · ');
|
||||
}
|
||||
|
||||
getStats() {
|
||||
const text = this._value || '';
|
||||
const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||
const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
|
||||
return {
|
||||
characters: text.length,
|
||||
words: cnChars + enWords,
|
||||
chineseChars: cnChars,
|
||||
englishWords: enWords,
|
||||
lines: text ? text.split('\n').length : 0,
|
||||
readingTime: Math.max(1, Math.ceil((cnChars + enWords) / 300)),
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 公共 API ============
|
||||
|
||||
getValue() { return this._value; }
|
||||
|
||||
setValue(md, opts = {}) {
|
||||
this._value = md || '';
|
||||
this.textarea.value = this._value;
|
||||
if (!opts.silent) this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
if (!opts.silent) {
|
||||
this._emit('change', this._value);
|
||||
if (typeof this.config.onChange === 'function') {
|
||||
try { this.config.onChange(this._value, this); } catch (e) { console.error(e); }
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
getHTML() {
|
||||
const env = { highlight: this._highlightFn, locale: getCurrentLocale() };
|
||||
let html = this._renderFn(this._value, env);
|
||||
if (typeof this.config.sanitize === 'function') {
|
||||
try { html = this.config.sanitize(html); } catch (e) { console.error(e); }
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
insert(text, opts = {}) {
|
||||
const ta = this.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const replace = opts.replace || false;
|
||||
ta.value = ta.value.slice(0, replace ? start : start) + text + ta.value.slice(replace ? end : end);
|
||||
ta.focus();
|
||||
const pos = start + text.length;
|
||||
ta.selectionStart = ta.selectionEnd = pos;
|
||||
this._value = ta.value;
|
||||
this._pushHistory();
|
||||
this._render();
|
||||
this._updateWordCount();
|
||||
this._emit('change', this._value);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在选区两侧包裹文本
|
||||
*/
|
||||
wrap(before, after) {
|
||||
this._wrapSelection(before, after || before);
|
||||
return this;
|
||||
}
|
||||
|
||||
focus() { this.textarea.focus(); return this; }
|
||||
blur() { this.textarea.blur(); return this; }
|
||||
|
||||
enable() {
|
||||
this.textarea.disabled = false;
|
||||
this.el.classList.remove('me-disabled');
|
||||
return this;
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.textarea.disabled = true;
|
||||
this.el.classList.add('me-disabled');
|
||||
return this;
|
||||
}
|
||||
|
||||
isDisabled() { return this.textarea.disabled; }
|
||||
|
||||
/**
|
||||
* 注册实例事件监听
|
||||
* 支持事件:change/input/focus/blur/save/modeChange/fullscreen/beforeCreate/afterCreate/beforeRender/afterRender/destroy
|
||||
*/
|
||||
on(name, fn) {
|
||||
if (!this._listeners[name]) this._listeners[name] = [];
|
||||
this._listeners[name].push(fn);
|
||||
return () => this.off(name, fn);
|
||||
}
|
||||
|
||||
off(name, fn) {
|
||||
const list = this._listeners[name];
|
||||
if (list) this._listeners[name] = list.filter((f) => f !== fn);
|
||||
return this;
|
||||
}
|
||||
|
||||
_emit(name, ...args) {
|
||||
const list = this._listeners[name];
|
||||
if (list) list.forEach((fn) => {
|
||||
try { fn(...args, this); } catch (e) { console.error(`MeEditor event "${name}" error:`, e); }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装插件
|
||||
* @param {string|Object} plugin - 预设插件名或插件对象
|
||||
* @param {Object} options - 插件配置
|
||||
*/
|
||||
use(plugin, options = {}) {
|
||||
let p = plugin;
|
||||
if (typeof plugin === 'string') {
|
||||
p = presetPlugins[plugin];
|
||||
if (!p) {
|
||||
console.warn(`MeEditor: preset plugin "${plugin}" not found`);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
if (!p || typeof p !== 'object') {
|
||||
console.warn('MeEditor: invalid plugin');
|
||||
return this;
|
||||
}
|
||||
const merged = { ...p, ...options };
|
||||
if (typeof merged.install === 'function') {
|
||||
try {
|
||||
merged.install(this);
|
||||
} catch (e) {
|
||||
console.error(`MeEditor: plugin "${merged.name || 'custom'}" install error:`, e);
|
||||
}
|
||||
}
|
||||
this._plugins.push(merged);
|
||||
return this;
|
||||
}
|
||||
|
||||
getPlugins() { return [...this._plugins]; }
|
||||
|
||||
/**
|
||||
* 向工具栏追加自定义按钮
|
||||
*/
|
||||
addToolbarButton(config) {
|
||||
if (!config || !config.action) return this;
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = `me-btn me-btn-custom-${config.action}`;
|
||||
btn.dataset.action = config.action;
|
||||
btn.title = config.title || config.action;
|
||||
btn.setAttribute('aria-label', config.title || config.action);
|
||||
btn.innerHTML = config.icon || `<span>${escapeHTML(config.text || config.action)}</span>`;
|
||||
if (typeof config.onClick === 'function') {
|
||||
btn.addEventListener('click', () => config.onClick(this));
|
||||
} else if (config.action) {
|
||||
// 注册到 exec 动作
|
||||
this._customActions = this._customActions || {};
|
||||
this._customActions[config.action] = config.handler;
|
||||
}
|
||||
this.toolbarEl.insertBefore(btn, this.toolbarEl.querySelector('.me-toolbar-group') || null);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁编辑器
|
||||
*/
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
MarkdownEditor.trigger('beforeDestroy', this);
|
||||
|
||||
if (this._renderRaf) cancelAnimationFrame(this._renderRaf);
|
||||
if (this._historyTimer) clearTimeout(this._historyTimer);
|
||||
|
||||
this._cleanups.forEach((fn) => { try { fn(); } catch (_) {} });
|
||||
this._cleanups = [];
|
||||
|
||||
// 销毁插件
|
||||
this._plugins.forEach((p) => {
|
||||
if (typeof p.destroy === 'function') {
|
||||
try { p.destroy(this); } catch (_) {}
|
||||
}
|
||||
});
|
||||
this._plugins = [];
|
||||
|
||||
if (this.el && this.el.parentNode) {
|
||||
this.el.parentNode.removeChild(this.el);
|
||||
}
|
||||
this.el = null;
|
||||
this.textarea = null;
|
||||
this.previewEl = null;
|
||||
|
||||
this._destroyed = true;
|
||||
|
||||
if (typeof this.config.onDestroy === 'function') {
|
||||
try { this.config.onDestroy(this); } catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
// 先触发 destroy 事件,再清空监听器(否则 destroy 监听器永远收不到事件)
|
||||
this._emit('destroy');
|
||||
this._listeners = {};
|
||||
MarkdownEditor.trigger('afterDestroy', this);
|
||||
}
|
||||
|
||||
isDestroyed() { return this._destroyed; }
|
||||
|
||||
/**
|
||||
* 获取编辑器状态
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
id: this.id,
|
||||
mode: this._mode,
|
||||
theme: this.config.theme,
|
||||
locale: getCurrentLocale(),
|
||||
fullscreen: this._fullscreen,
|
||||
disabled: this.textarea ? this.textarea.disabled : false,
|
||||
destroyed: this._destroyed,
|
||||
plugins: this._plugins.map((p) => p.name || 'custom'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { MarkdownEditor as Editor, MarkdownEditor };
|
||||
export default MarkdownEditor;
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* MetonaEditor i18n - 国际化管理
|
||||
* @module i18n
|
||||
* @version 0.1.0
|
||||
* @description 多语言支持、语言切换和翻译管理
|
||||
*/
|
||||
|
||||
import { LOCALES } from './constants.js';
|
||||
|
||||
let currentLocale = 'zh-CN';
|
||||
let localeListeners = new Set();
|
||||
let fallbackLocale = 'zh-CN';
|
||||
|
||||
/**
|
||||
* 获取当前语言
|
||||
*/
|
||||
export const getCurrentLocale = () => currentLocale;
|
||||
|
||||
/**
|
||||
* 设置当前语言
|
||||
*/
|
||||
export const setCurrentLocale = (locale) => {
|
||||
if (!LOCALES[locale]) {
|
||||
console.warn(`Locale "${locale}" not found, falling back to "${fallbackLocale}"`);
|
||||
locale = fallbackLocale;
|
||||
}
|
||||
|
||||
currentLocale = locale;
|
||||
notifyLocaleListeners(locale);
|
||||
saveLocale(locale);
|
||||
};
|
||||
|
||||
/**
|
||||
* 翻译函数
|
||||
*/
|
||||
export const t = (key, params = {}) => {
|
||||
const currentTranslation = getTranslation(currentLocale, key);
|
||||
if (currentTranslation !== undefined) {
|
||||
return interpolate(currentTranslation, params);
|
||||
}
|
||||
|
||||
if (currentLocale !== fallbackLocale) {
|
||||
const fallbackTranslation = getTranslation(fallbackLocale, key);
|
||||
if (fallbackTranslation !== undefined) {
|
||||
return interpolate(fallbackTranslation, params);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`Translation missing for key "${key}" in locale "${currentLocale}"`);
|
||||
return key;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取翻译
|
||||
*/
|
||||
const getTranslation = (locale, key) => {
|
||||
const localeData = LOCALES[locale];
|
||||
if (!localeData) return undefined;
|
||||
|
||||
const keys = key.split('.');
|
||||
let result = localeData;
|
||||
|
||||
for (const k of keys) {
|
||||
if (result && typeof result === 'object' && k in result) {
|
||||
result = result[k];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof result === 'string' ? result : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 插值函数
|
||||
*/
|
||||
const interpolate = (str, params) => {
|
||||
return str.replace(/\{(\w+)\}/g, (match, key) => {
|
||||
return params[key] !== undefined ? params[key] : match;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查翻译是否存在
|
||||
*/
|
||||
export const hasTranslation = (key) => {
|
||||
return getTranslation(currentLocale, key) !== undefined ||
|
||||
getTranslation(fallbackLocale, key) !== undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有翻译
|
||||
*/
|
||||
export const getTranslations = (locale) => {
|
||||
return LOCALES[locale] || {};
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加翻译
|
||||
*/
|
||||
export const addTranslations = (locale, translations) => {
|
||||
if (!LOCALES[locale]) {
|
||||
LOCALES[locale] = {};
|
||||
}
|
||||
|
||||
deepMerge(LOCALES[locale], translations);
|
||||
};
|
||||
|
||||
/**
|
||||
* 深度合并对象
|
||||
*/
|
||||
const deepMerge = (target, source) => {
|
||||
for (const key in source) {
|
||||
if (source[key] instanceof Object && key in target && target[key] instanceof Object) {
|
||||
deepMerge(target[key], source[key]);
|
||||
} else {
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取支持的语言列表
|
||||
*/
|
||||
export const getSupportedLocales = () => {
|
||||
return Object.keys(LOCALES);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查语言是否支持
|
||||
*/
|
||||
export const isLocaleSupported = (locale) => {
|
||||
return locale in LOCALES;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取语言名称
|
||||
*/
|
||||
export const getLocaleName = (locale) => {
|
||||
const names = {
|
||||
'zh-CN': '简体中文',
|
||||
'zh-TW': '繁體中文',
|
||||
'en-US': 'English (US)',
|
||||
'en-GB': 'English (UK)',
|
||||
'ja': '日本語',
|
||||
'ko': '한국어',
|
||||
'fr': 'Français',
|
||||
'de': 'Deutsch',
|
||||
'es': 'Español',
|
||||
'pt': 'Português',
|
||||
'ru': 'Русский',
|
||||
'ar': 'العربية',
|
||||
'hi': 'हिन्दी',
|
||||
'th': 'ไทย',
|
||||
'vi': 'Tiếng Việt',
|
||||
'id': 'Bahasa Indonesia',
|
||||
'ms': 'Bahasa Melayu',
|
||||
'tr': 'Türkçe',
|
||||
'it': 'Italiano',
|
||||
'nl': 'Nederlands',
|
||||
'pl': 'Polski',
|
||||
};
|
||||
|
||||
return names[locale] || locale;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取语言方向
|
||||
*/
|
||||
export const getLocaleDirection = (locale) => {
|
||||
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug'];
|
||||
return rtlLocales.includes(locale) ? 'rtl' : 'ltr';
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化数字
|
||||
*/
|
||||
export const formatNumber = (number, options = {}) => {
|
||||
try {
|
||||
return new Intl.NumberFormat(currentLocale, options).format(number);
|
||||
} catch (e) {
|
||||
return number.toString();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化货币
|
||||
*/
|
||||
export const formatCurrency = (amount, currency = 'USD', options = {}) => {
|
||||
try {
|
||||
return new Intl.NumberFormat(currentLocale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
...options,
|
||||
}).format(amount);
|
||||
} catch (e) {
|
||||
return amount.toString();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
export const formatDate = (date, options = {}) => {
|
||||
try {
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
return new Intl.DateTimeFormat(currentLocale, options).format(dateObj);
|
||||
} catch (e) {
|
||||
return date.toString();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加语言监听器
|
||||
*/
|
||||
export const addLocaleListener = (listener) => {
|
||||
localeListeners.add(listener);
|
||||
|
||||
return () => {
|
||||
localeListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除语言监听器
|
||||
*/
|
||||
export const removeLocaleListener = (listener) => {
|
||||
localeListeners.delete(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* 通知语言监听器
|
||||
*/
|
||||
const notifyLocaleListeners = (locale) => {
|
||||
localeListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(locale);
|
||||
} catch (e) {
|
||||
console.error('Locale listener error:', e);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除所有语言监听器
|
||||
*/
|
||||
export const clearLocaleListeners = () => {
|
||||
localeListeners.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存语言到本地存储
|
||||
*/
|
||||
export const saveLocale = (locale) => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem('metona-editor-locale', locale);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save locale:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从本地存储加载语言
|
||||
*/
|
||||
export const loadLocale = () => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
return localStorage.getItem('metona-editor-locale') || getDefaultLocale();
|
||||
} catch (e) {
|
||||
console.warn('Failed to load locale:', e);
|
||||
}
|
||||
}
|
||||
return getDefaultLocale();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取默认语言
|
||||
*/
|
||||
export const getDefaultLocale = () => {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const browserLocale = navigator.language || navigator.userLanguage;
|
||||
if (browserLocale && isLocaleSupported(browserLocale)) {
|
||||
return browserLocale;
|
||||
}
|
||||
|
||||
const shortLocale = browserLocale?.split('-')[0];
|
||||
if (shortLocale && isLocaleSupported(shortLocale)) {
|
||||
return shortLocale;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackLocale;
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化国际化系统
|
||||
*/
|
||||
export const initI18n = () => {
|
||||
const savedLocale = loadLocale();
|
||||
setCurrentLocale(savedLocale);
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换语言
|
||||
*/
|
||||
export const switchLocale = (locale) => {
|
||||
setCurrentLocale(locale);
|
||||
};
|
||||
|
||||
/**
|
||||
* 国际化工具 — 代理层
|
||||
*/
|
||||
export const i18nUtils = {
|
||||
t,
|
||||
getCurrentLocale,
|
||||
setCurrentLocale,
|
||||
switchLocale,
|
||||
getFallbackLocale: () => fallbackLocale,
|
||||
setFallbackLocale: (locale) => { fallbackLocale = locale; },
|
||||
hasTranslation,
|
||||
getTranslations,
|
||||
addTranslations,
|
||||
getSupportedLocales,
|
||||
isLocaleSupported,
|
||||
getLocaleName,
|
||||
getLocaleDirection,
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
addLocaleListener,
|
||||
removeLocaleListener,
|
||||
clearLocaleListeners,
|
||||
initI18n,
|
||||
saveLocale,
|
||||
loadLocale,
|
||||
getDefaultLocale,
|
||||
};
|
||||
|
||||
/**
|
||||
* 预设语言包
|
||||
*/
|
||||
export const presetLocales = {
|
||||
'zh-CN': {
|
||||
name: '简体中文',
|
||||
nativeName: '简体中文',
|
||||
direction: 'ltr',
|
||||
translations: LOCALES['zh-CN'],
|
||||
},
|
||||
'en-US': {
|
||||
name: 'English (US)',
|
||||
nativeName: 'English (US)',
|
||||
direction: 'ltr',
|
||||
translations: LOCALES['en-US'],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建国际化管理器
|
||||
*/
|
||||
export const createI18nManager = () => {
|
||||
return {
|
||||
t,
|
||||
getCurrentLocale,
|
||||
setCurrentLocale,
|
||||
switchLocale,
|
||||
getFallbackLocale: () => fallbackLocale,
|
||||
setFallbackLocale: (locale) => { fallbackLocale = locale; },
|
||||
hasTranslation,
|
||||
getTranslations,
|
||||
addTranslations,
|
||||
getSupportedLocales,
|
||||
isLocaleSupported,
|
||||
getLocaleName,
|
||||
getLocaleDirection,
|
||||
formatNumber,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
addLocaleListener,
|
||||
removeLocaleListener,
|
||||
clearLocaleListeners,
|
||||
initI18n,
|
||||
saveLocale,
|
||||
loadLocale,
|
||||
getDefaultLocale,
|
||||
};
|
||||
};
|
||||
|
||||
export { i18nUtils as default };
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* MetonaEditor Icons — 工具栏图标SVG定义
|
||||
* @module icons
|
||||
* @version 0.1.0
|
||||
* @description 工具栏格式化按钮 SVG 图标
|
||||
*/
|
||||
|
||||
export const ICONS = {
|
||||
bold: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>`,
|
||||
|
||||
italic: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>`,
|
||||
|
||||
underline: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>`,
|
||||
|
||||
strikethrough: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.3 19c.6-1.2 1-2.5 1-3.8 0-4.3-3.5-7.2-7.3-7.2-3.8 0-7.3 2.9-7.3 7.2 0 1.3.4 2.6 1 3.8"/><line x1="4" y1="12" x2="20" y2="12"/></svg>`,
|
||||
|
||||
h1: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17 12l3-2v8"/></svg>`,
|
||||
|
||||
h2: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M19 12v4"/><path d="M22 12h-4"/></svg>`,
|
||||
|
||||
h3: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-2 1-3 3-3s2 1 2 3"/></svg>`,
|
||||
|
||||
quote: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>`,
|
||||
|
||||
code: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`,
|
||||
|
||||
link: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
|
||||
|
||||
image: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
|
||||
|
||||
table: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>`,
|
||||
|
||||
ul: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
|
||||
|
||||
ol: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 14h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>`,
|
||||
|
||||
indent: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 8 7 12 3 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/></svg>`,
|
||||
|
||||
outdent: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 8 3 12 7 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/></svg>`,
|
||||
|
||||
hr: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/></svg>`,
|
||||
|
||||
undo: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>`,
|
||||
|
||||
redo: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`,
|
||||
|
||||
preview: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`,
|
||||
|
||||
split: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>`,
|
||||
|
||||
edit: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
|
||||
|
||||
fullscreen: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>`,
|
||||
|
||||
theme: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`,
|
||||
};
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* MetonaEditor - 轻量级 Markdown Editor 库
|
||||
* @module metona-editor
|
||||
* @version 0.1.0
|
||||
* @author thzxx
|
||||
* @description 现代化、零依赖、易扩展、易维护、易使用的 Markdown 编辑器库。单文件,开箱即用。
|
||||
* @license MIT
|
||||
*
|
||||
* 用法:
|
||||
* // 浏览器
|
||||
* <script src="dist/metona-editor.js"></script>
|
||||
* const editor = MeEditor.create('#editor', { mode: 'split' });
|
||||
*
|
||||
* // ES Module
|
||||
* import MeEditor from 'metona-editor';
|
||||
* const editor = MeEditor.create(container, options);
|
||||
*
|
||||
* // 直接使用类
|
||||
* import { MarkdownEditor } from 'metona-editor';
|
||||
* const editor = new MarkdownEditor(container, options);
|
||||
*/
|
||||
|
||||
import { MarkdownEditor } from './core.js';
|
||||
import { parseMarkdown, safeUrl, slugify } from './parser.js';
|
||||
import { themeUtils } from './themes.js';
|
||||
import { i18nUtils } from './i18n.js';
|
||||
import { pluginUtils, presetPlugins } from './plugins.js';
|
||||
import { animationUtils } from './animations.js';
|
||||
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants.js';
|
||||
|
||||
// 版本信息
|
||||
const VERSION = '0.1.0';
|
||||
|
||||
/**
|
||||
* 全局默认插件队列
|
||||
* 通过 beforeCreate 钩子自动应用到所有后续创建的编辑器实例
|
||||
*/
|
||||
const globalPlugins = [];
|
||||
|
||||
// 注册全局钩子:每个新实例创建时自动安装全局默认插件
|
||||
// 注意:core.js 构造函数中 beforeCreate 在 config.plugins 安装之前触发,
|
||||
// 且 this._plugins 已初始化为 [],因此此处调用 editor.use() 安全。
|
||||
MarkdownEditor.on('beforeCreate', (editor) => {
|
||||
if (!editor || editor._destroyed) return;
|
||||
globalPlugins.forEach((p) => {
|
||||
try { editor.use(p); } catch (e) { console.error('MeEditor global plugin install error:', e); }
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建编辑器实例(工厂函数,推荐用法)
|
||||
* @param {string|HTMLElement} container - 容器选择器或 DOM 元素
|
||||
* @param {Object} [options] - 配置项(见 DEFAULTS)
|
||||
* @returns {MarkdownEditor} 编辑器实例
|
||||
*/
|
||||
function create(container, options = {}) {
|
||||
return new MarkdownEditor(container, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册全局默认插件(作用于所有后续创建的编辑器实例)
|
||||
* @param {string|Object} plugin - 预设插件名 或 插件对象
|
||||
* @param {Object} [options] - 插件配置(覆盖预设默认值)
|
||||
* @returns {typeof api} 返回 api 自身以支持链式调用
|
||||
*/
|
||||
function use(plugin, options = {}) {
|
||||
let p = plugin;
|
||||
if (typeof plugin === 'string') {
|
||||
p = presetPlugins[plugin];
|
||||
if (!p) {
|
||||
console.warn(`MeEditor: preset plugin "${plugin}" not found`);
|
||||
return api;
|
||||
}
|
||||
}
|
||||
if (!p || typeof p !== 'object') {
|
||||
console.warn('MeEditor: invalid plugin');
|
||||
return api;
|
||||
}
|
||||
const merged = (options && typeof options === 'object' && Object.keys(options).length > 0)
|
||||
? { ...p, ...options }
|
||||
: p;
|
||||
globalPlugins.push(merged);
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册全局事件钩子(转发到 MarkdownEditor 静态钩子,所有实例共享)
|
||||
* 可用钩子:beforeCreate / afterCreate / beforeRender / afterRender / beforeDestroy / afterDestroy
|
||||
* @param {string} name - 钩子名
|
||||
* @param {Function} fn - 回调 (editor) => void
|
||||
* @returns {Function} 取消注册函数
|
||||
*/
|
||||
function on(name, fn) {
|
||||
return MarkdownEditor.on(name, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销全局事件钩子
|
||||
*/
|
||||
function off(name, fn) {
|
||||
MarkdownEditor.off(name, fn);
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换全局主题(转发到 themeUtils)
|
||||
*/
|
||||
function setTheme(theme, options) {
|
||||
if (themeUtils && typeof themeUtils.switchTheme === 'function') {
|
||||
themeUtils.switchTheme(theme, options);
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换全局语言(转发到 i18nUtils)
|
||||
*/
|
||||
function setLocale(locale) {
|
||||
if (i18nUtils && typeof i18nUtils.setCurrentLocale === 'function') {
|
||||
i18nUtils.setCurrentLocale(locale);
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁所有全局资源(主题监听、i18n 监听、动画、全局插件队列)
|
||||
* 注意:此方法不销毁已创建的编辑器实例,请单独调用每个实例的 destroy()
|
||||
*/
|
||||
function destroy() {
|
||||
if (themeUtils) {
|
||||
if (typeof themeUtils.clearThemeListeners === 'function') {
|
||||
try { themeUtils.clearThemeListeners(); } catch (_) {}
|
||||
}
|
||||
if (typeof themeUtils.unwatchSystemTheme === 'function') {
|
||||
try { themeUtils.unwatchSystemTheme(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
if (i18nUtils && typeof i18nUtils.clearLocaleListeners === 'function') {
|
||||
try { i18nUtils.clearLocaleListeners(); } catch (_) {}
|
||||
}
|
||||
if (animationUtils && typeof animationUtils.cancelAll === 'function') {
|
||||
try { animationUtils.cancelAll(); } catch (_) {}
|
||||
}
|
||||
globalPlugins.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取库运行状态
|
||||
*/
|
||||
function getStatus() {
|
||||
return {
|
||||
version: VERSION,
|
||||
theme: (themeUtils && typeof themeUtils.getCurrentTheme === 'function') ? themeUtils.getCurrentTheme() : 'auto',
|
||||
locale: (i18nUtils && typeof i18nUtils.getCurrentLocale === 'function') ? i18nUtils.getCurrentLocale() : 'zh-CN',
|
||||
globalPlugins: globalPlugins.map((p) => p.name || 'custom'),
|
||||
presetPlugins: Object.keys(presetPlugins || {}),
|
||||
};
|
||||
}
|
||||
|
||||
// 初始化主题与国际化(浏览器环境)
|
||||
if (typeof themeUtils === 'object' && typeof themeUtils.initTheme === 'function') {
|
||||
try { themeUtils.initTheme(); } catch (_) {}
|
||||
}
|
||||
if (typeof i18nUtils === 'object' && typeof i18nUtils.initI18n === 'function') {
|
||||
try { i18nUtils.initI18n(); } catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶层 API 对象(默认导出)
|
||||
*/
|
||||
const api = {
|
||||
// 版本
|
||||
VERSION,
|
||||
version: VERSION,
|
||||
|
||||
// 核心类
|
||||
MarkdownEditor,
|
||||
Editor: MarkdownEditor,
|
||||
|
||||
// 工厂函数(推荐入口)
|
||||
create,
|
||||
|
||||
// 顶层 API
|
||||
use,
|
||||
on,
|
||||
off,
|
||||
setTheme,
|
||||
setLocale,
|
||||
destroy,
|
||||
getStatus,
|
||||
|
||||
// 内置解析器(可单独使用或替换)
|
||||
parseMarkdown,
|
||||
safeUrl,
|
||||
slugify,
|
||||
|
||||
// 工具集
|
||||
themes: themeUtils,
|
||||
i18n: i18nUtils,
|
||||
animations: animationUtils,
|
||||
plugins: pluginUtils,
|
||||
presetPlugins,
|
||||
|
||||
// 常量
|
||||
DEFAULTS,
|
||||
ICONS,
|
||||
THEMES,
|
||||
EDIT_MODES,
|
||||
DEFAULT_TOOLBAR,
|
||||
TOOLBAR_ACTIONS,
|
||||
};
|
||||
|
||||
// 浏览器环境全局注册(UMD/CDN 场景:window.MeEditor)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.MeEditor = api;
|
||||
}
|
||||
|
||||
export default api;
|
||||
export {
|
||||
api,
|
||||
api as meEditor,
|
||||
api as MeEditor,
|
||||
MarkdownEditor,
|
||||
MarkdownEditor as Editor,
|
||||
create,
|
||||
use,
|
||||
on,
|
||||
off,
|
||||
setTheme,
|
||||
setLocale,
|
||||
destroy,
|
||||
getStatus,
|
||||
parseMarkdown,
|
||||
safeUrl,
|
||||
slugify,
|
||||
themeUtils,
|
||||
i18nUtils,
|
||||
pluginUtils,
|
||||
presetPlugins,
|
||||
animationUtils,
|
||||
DEFAULTS,
|
||||
ICONS,
|
||||
THEMES,
|
||||
EDIT_MODES,
|
||||
DEFAULT_TOOLBAR,
|
||||
TOOLBAR_ACTIONS,
|
||||
VERSION,
|
||||
};
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* MetonaEditor Locales — 国际化翻译数据
|
||||
* @module locales
|
||||
* @version 0.1.0
|
||||
* @description 内置 zh-CN / en-US 完整翻译
|
||||
*/
|
||||
|
||||
export const LOCALES = {
|
||||
'zh-CN': {
|
||||
bold: '粗体',
|
||||
italic: '斜体',
|
||||
underline: '下划线',
|
||||
strikethrough: '删除线',
|
||||
h1: '标题1',
|
||||
h2: '标题2',
|
||||
h3: '标题3',
|
||||
quote: '引用',
|
||||
code: '代码',
|
||||
link: '链接',
|
||||
image: '图片',
|
||||
table: '表格',
|
||||
ul: '无序列表',
|
||||
ol: '有序列表',
|
||||
indent: '增加缩进',
|
||||
outdent: '减少缩进',
|
||||
hr: '分隔线',
|
||||
undo: '撤销',
|
||||
redo: '重做',
|
||||
edit: '编辑',
|
||||
split: '分屏',
|
||||
preview: '预览',
|
||||
fullscreen: '全屏',
|
||||
fullscreenExit: '退出全屏',
|
||||
theme: '主题',
|
||||
light: '亮色',
|
||||
dark: '暗色',
|
||||
auto: '自动',
|
||||
warm: '暖色',
|
||||
wordCount: '字数统计',
|
||||
characters: '字符',
|
||||
words: '词数',
|
||||
lines: '行数',
|
||||
readingTime: '阅读',
|
||||
minutes: '分钟',
|
||||
placeholder: '开始输入 Markdown...',
|
||||
empty: '暂无内容',
|
||||
copied: '已复制',
|
||||
copyContent: '复制内容',
|
||||
copyHTML: '复制 HTML',
|
||||
copySuccess: '复制成功',
|
||||
copyFailed: '复制失败',
|
||||
clearContent: '清空内容',
|
||||
clearConfirm: '确定要清空所有内容吗?',
|
||||
linkPlaceholder: '请输入链接地址',
|
||||
imagePlaceholder: '请输入图片地址',
|
||||
altPlaceholder: '请输入替代文本',
|
||||
tableRows: '行数',
|
||||
tableCols: '列数',
|
||||
confirm: '确认',
|
||||
cancel: '取消',
|
||||
exportMarkdown: '导出 Markdown',
|
||||
exportHTML: '导出 HTML',
|
||||
search: '搜索',
|
||||
replace: '替换',
|
||||
replaceAll: '全部替换',
|
||||
searchPlaceholder: '查找内容',
|
||||
replacePlaceholder: '替换为',
|
||||
findNext: '查找下一个',
|
||||
findPrev: '查找上一个',
|
||||
matchCase: '区分大小写',
|
||||
wholeWord: '全字匹配',
|
||||
close: '关闭',
|
||||
open: '打开',
|
||||
save: '保存',
|
||||
saved: '已保存',
|
||||
saving: '保存中...',
|
||||
delete: '删除',
|
||||
confirmDelete: '确定要删除吗?',
|
||||
unsavedChanges: '有未保存的更改',
|
||||
error: '错误',
|
||||
success: '成功',
|
||||
warning: '警告',
|
||||
info: '信息',
|
||||
loading: '加载中...',
|
||||
retry: '重试',
|
||||
renderError: '渲染失败',
|
||||
},
|
||||
|
||||
'en-US': {
|
||||
bold: 'Bold',
|
||||
italic: 'Italic',
|
||||
underline: 'Underline',
|
||||
strikethrough: 'Strikethrough',
|
||||
h1: 'Heading 1',
|
||||
h2: 'Heading 2',
|
||||
h3: 'Heading 3',
|
||||
quote: 'Quote',
|
||||
code: 'Code',
|
||||
link: 'Link',
|
||||
image: 'Image',
|
||||
table: 'Table',
|
||||
ul: 'Bullet List',
|
||||
ol: 'Numbered List',
|
||||
indent: 'Indent',
|
||||
outdent: 'Outdent',
|
||||
hr: 'Horizontal Rule',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
edit: 'Edit',
|
||||
split: 'Split',
|
||||
preview: 'Preview',
|
||||
fullscreen: 'Fullscreen',
|
||||
fullscreenExit: 'Exit Fullscreen',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
auto: 'Auto',
|
||||
warm: 'Warm',
|
||||
wordCount: 'Word Count',
|
||||
characters: 'Characters',
|
||||
words: 'Words',
|
||||
lines: 'Lines',
|
||||
readingTime: 'Reading',
|
||||
minutes: 'min',
|
||||
placeholder: 'Start typing Markdown...',
|
||||
empty: 'No content',
|
||||
copied: 'Copied',
|
||||
copyContent: 'Copy Content',
|
||||
copyHTML: 'Copy HTML',
|
||||
copySuccess: 'Copied successfully',
|
||||
copyFailed: 'Copy failed',
|
||||
clearContent: 'Clear Content',
|
||||
clearConfirm: 'Clear all content?',
|
||||
linkPlaceholder: 'Enter link URL',
|
||||
imagePlaceholder: 'Enter image URL',
|
||||
altPlaceholder: 'Enter alt text',
|
||||
tableRows: 'Rows',
|
||||
tableCols: 'Columns',
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
exportMarkdown: 'Export Markdown',
|
||||
exportHTML: 'Export HTML',
|
||||
search: 'Search',
|
||||
replace: 'Replace',
|
||||
replaceAll: 'Replace All',
|
||||
searchPlaceholder: 'Find',
|
||||
replacePlaceholder: 'Replace with',
|
||||
findNext: 'Find Next',
|
||||
findPrev: 'Find Previous',
|
||||
matchCase: 'Match Case',
|
||||
wholeWord: 'Whole Word',
|
||||
close: 'Close',
|
||||
open: 'Open',
|
||||
save: 'Save',
|
||||
saved: 'Saved',
|
||||
saving: 'Saving...',
|
||||
delete: 'Delete',
|
||||
confirmDelete: 'Are you sure you want to delete?',
|
||||
unsavedChanges: 'You have unsaved changes',
|
||||
error: 'Error',
|
||||
success: 'Success',
|
||||
warning: 'Warning',
|
||||
info: 'Info',
|
||||
loading: 'Loading...',
|
||||
retry: 'Retry',
|
||||
renderError: 'Render failed',
|
||||
},
|
||||
};
|
||||
+322
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* MetonaEditor Parser - 轻量 Markdown 解析器
|
||||
* @module parser
|
||||
* @version 0.1.0
|
||||
* @description 自研零依赖 Markdown 解析器,覆盖 CommonMark 子集 + GFM 扩展
|
||||
*
|
||||
* 支持语法:
|
||||
* - 标题 # ~ ######
|
||||
* - 段落 / 换行
|
||||
* - 粗体 **text** / __text__
|
||||
* - 斜体 *text* / _text_
|
||||
* - 删除线 ~~text~~
|
||||
* - 行内代码 `code`
|
||||
* - 代码块 ```lang ... ``` 与 ~~~ ... ~~~
|
||||
* - 引用块 >
|
||||
* - 无序列表 - / * / +
|
||||
* - 有序列表 1.
|
||||
* - 任务列表 - [ ] / - [x]
|
||||
* - 水平线 --- / *** / ___
|
||||
* - 表格 | a | b |
|
||||
* - 链接 [text](url) / 自动链接 <url>
|
||||
* - 图片 
|
||||
* - 转义字符 \X
|
||||
*
|
||||
* 安全:所有文本经 HTML 转义;URL 过滤 javascript:/vbscript: 等危险协议
|
||||
* 扩展:env.highlight(code, lang) => html 钩子用于代码高亮
|
||||
*/
|
||||
|
||||
import { escapeHTML } from './utils.js';
|
||||
|
||||
/**
|
||||
* 危险协议过滤
|
||||
*/
|
||||
const safeUrl = (url) => {
|
||||
if (!url) return '';
|
||||
const u = String(url).trim();
|
||||
if (/^(javascript|vbscript|file):/i.test(u)) return '';
|
||||
// 仅允许 data:image/
|
||||
if (/^data:/i.test(u) && !/^data:image\//i.test(u)) return '';
|
||||
return u;
|
||||
};
|
||||
|
||||
/**
|
||||
* 主解析入口
|
||||
* @param {string} md - Markdown 源文本
|
||||
* @param {Object} env - 渲染环境 { highlight, locale }
|
||||
* @returns {string} HTML 字符串
|
||||
*/
|
||||
export const parseMarkdown = (md, env = {}) => {
|
||||
if (md == null) return '';
|
||||
const text = String(md).replace(/\r\n?/g, '\n');
|
||||
const lines = text.split('\n');
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// 空行
|
||||
if (/^\s*$/.test(line)) { i++; continue; }
|
||||
|
||||
// 围栏代码块
|
||||
const fence = line.match(/^(\s*)(`{3,}|~{3,})\s*([\w+-]*)\s*$/);
|
||||
if (fence) {
|
||||
const fenceChar = fence[2][0];
|
||||
const lang = fence[3] || '';
|
||||
const codeLines = [];
|
||||
i++;
|
||||
const closeRe = new RegExp('^\\s*' + fenceChar + '{3,}\\s*$');
|
||||
while (i < lines.length && !closeRe.test(lines[i])) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) i++; // 跳过结束围栏
|
||||
tokens.push({ type: 'code', lang, content: codeLines.join('\n') });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ATX 标题
|
||||
const h = line.match(/^(#{1,6})\s+(.*?)(?:\s+#{1,6})?\s*$/);
|
||||
if (h) {
|
||||
tokens.push({ type: 'heading', level: h[1].length, text: h[2] });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 水平线
|
||||
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
||||
tokens.push({ type: 'hr' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 引用块
|
||||
if (/^\s*>/.test(line)) {
|
||||
const quote = [];
|
||||
while (i < lines.length && /^\s*>/.test(lines[i])) {
|
||||
quote.push(lines[i].replace(/^\s*>?\s?/, ''));
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: 'quote', content: quote.join('\n') });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 表格:当前行含 |,下一行是分隔行
|
||||
if (/\|/.test(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
||||
const header = line;
|
||||
i += 2;
|
||||
const rows = [];
|
||||
while (i < lines.length && /\|/.test(lines[i]) && !/^\s*$/.test(lines[i])) {
|
||||
rows.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: 'table', header, rows });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 无序列表 / 任务列表
|
||||
if (/^\s*[-*+]\s/.test(line)) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^\s*[-*+]\s/.test(lines[i])) {
|
||||
const raw = lines[i].replace(/^\s*[-*+]\s/, '');
|
||||
const task = raw.match(/^\[([ xX])\]\s+(.*)$/);
|
||||
if (task) {
|
||||
items.push({ text: task[2], checked: task[1].toLowerCase() === 'x', task: true });
|
||||
} else {
|
||||
items.push({ text: raw, checked: false, task: false });
|
||||
}
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: 'ul', items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 有序列表
|
||||
if (/^\s*\d+\.\s/.test(line)) {
|
||||
const items = [];
|
||||
while (i < lines.length && /^\s*\d+\.\s/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\s*\d+\.\s/, ''));
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: 'ol', items });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 段落(收集连续非块级行)
|
||||
const para = [];
|
||||
while (i < lines.length) {
|
||||
const l = lines[i];
|
||||
if (/^\s*$/.test(l)) break;
|
||||
if (/^\s*(`{3,}|~{3,})/.test(l)) break;
|
||||
if (/^#{1,6}\s/.test(l)) break;
|
||||
if (/^\s*>/.test(l)) break;
|
||||
if (/^\s*[-*+]\s/.test(l)) break;
|
||||
if (/^\s*\d+\.\s/.test(l)) break;
|
||||
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(l)) break;
|
||||
para.push(l);
|
||||
i++;
|
||||
}
|
||||
tokens.push({ type: 'paragraph', text: para.join('\n') });
|
||||
}
|
||||
|
||||
return tokens.map((tok) => renderToken(tok, env)).join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* 表格分隔行判定
|
||||
*/
|
||||
const isTableSeparator = (line) => {
|
||||
return /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line) && /-/.test(line);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染单个块级 token
|
||||
*/
|
||||
const renderToken = (tok, env) => {
|
||||
switch (tok.type) {
|
||||
case 'heading': {
|
||||
const id = slugify(tok.text);
|
||||
return `<h${tok.level} id="${id}">${renderInline(tok.text, env)}</h${tok.level}>`;
|
||||
}
|
||||
case 'paragraph':
|
||||
return `<p>${renderInline(tok.text, env)}</p>`;
|
||||
case 'hr':
|
||||
return '<hr/>';
|
||||
case 'quote':
|
||||
return `<blockquote>${parseMarkdown(tok.content, env)}</blockquote>`;
|
||||
case 'code':
|
||||
return renderCode(tok.content, tok.lang, env);
|
||||
case 'ul':
|
||||
return `<ul>${tok.items.map((it) => renderListItem(it, env)).join('')}</ul>`;
|
||||
case 'ol':
|
||||
return `<ol>${tok.items.map((it) => `<li>${renderInline(it, env)}</li>`).join('')}</ol>`;
|
||||
case 'table':
|
||||
return renderTable(tok, env);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染列表项
|
||||
*/
|
||||
const renderListItem = (it, env) => {
|
||||
if (it.task) {
|
||||
const checked = it.checked ? ' checked' : '';
|
||||
return `<li class="me-task-item"><input type="checkbox" disabled${checked}/> ${renderInline(it.text, env)}</li>`;
|
||||
}
|
||||
return `<li>${renderInline(it.text, env)}</li>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染代码块
|
||||
*/
|
||||
const renderCode = (code, lang, env) => {
|
||||
const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : '';
|
||||
if (env.highlight && typeof env.highlight === 'function' && lang) {
|
||||
try {
|
||||
const highlighted = env.highlight(code, lang);
|
||||
if (typeof highlighted === 'string') {
|
||||
return `<pre><code${langClass}>${highlighted}</code></pre>`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('MeEditor highlight error:', e);
|
||||
}
|
||||
}
|
||||
return `<pre><code${langClass}>${escapeHTML(code)}</code></pre>`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染表格
|
||||
*/
|
||||
const renderTable = (tok, env) => {
|
||||
const splitRow = (r) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
|
||||
const headers = splitRow(tok.header);
|
||||
let html = '<div class="me-table-wrap"><table><thead><tr>';
|
||||
html += headers.map((h) => `<th>${renderInline(h, env)}</th>`).join('');
|
||||
html += '</tr></thead><tbody>';
|
||||
html += tok.rows.map((r) => {
|
||||
const cells = splitRow(r);
|
||||
return `<tr>${cells.map((c) => `<td>${renderInline(c, env)}</td>`).join('')}</tr>`;
|
||||
}).join('');
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染内联元素
|
||||
* 顺序:提取代码占位 -> escape -> 图片 -> 链接 -> 自动链接 -> 粗体 -> 斜体 -> 删除线 -> 还原代码 -> 换行
|
||||
*/
|
||||
const renderInline = (text, env) => {
|
||||
if (!text) return '';
|
||||
const codes = [];
|
||||
let s = String(text);
|
||||
|
||||
// 1. 提取行内代码到占位符,避免被后续正则破坏
|
||||
s = s.replace(/`+([^`]+?)`+/g, (m, c) => {
|
||||
const idx = codes.length;
|
||||
codes.push(c);
|
||||
return `\u0000${idx}\u0000`;
|
||||
});
|
||||
|
||||
// 2. HTML 转义(占位符中的零字符不被影响)
|
||||
s = escapeHTML(s);
|
||||
|
||||
// 3. 图片 
|
||||
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^&]*)")?\)/g, (m, alt, url, title) => {
|
||||
const u = safeUrl(url);
|
||||
if (!u) return escapeHTML(m);
|
||||
const t = title ? ` title="${title}"` : '';
|
||||
return `<img src="${u}" alt="${alt}"${t} loading="lazy"/>`;
|
||||
});
|
||||
|
||||
// 4. 链接 [text](url "title")
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^&]*)")?\)/g, (m, txt, url, title) => {
|
||||
const u = safeUrl(url);
|
||||
if (!u) return escapeHTML(m);
|
||||
const t = title ? ` title="${title}"` : '';
|
||||
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${txt}</a>`;
|
||||
});
|
||||
|
||||
// 5. 自动链接 <url>
|
||||
s = s.replace(/<(https?:\/\/[^\s&]+)>/g, (m, url) => {
|
||||
const u = safeUrl(url);
|
||||
if (!u) return m;
|
||||
return `<a href="${u}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
});
|
||||
|
||||
// 6. 粗体 **text** / __text__
|
||||
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
s = s.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
||||
|
||||
// 7. 斜体 *text* / _text_
|
||||
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
|
||||
s = s.replace(/(^|[^_])_([^_\n]+)_/g, '$1<em>$2</em>');
|
||||
|
||||
// 8. 删除线 ~~text~~
|
||||
s = s.replace(/~~([^~]+)~~/g, '<del>$1</del>');
|
||||
|
||||
// 9. 还原行内代码
|
||||
s = s.replace(/\u0000(\d+)\u0000/g, (m, idx) => `<code>${escapeHTML(codes[+idx])}</code>`);
|
||||
|
||||
// 10. 软换行
|
||||
s = s.replace(/\n/g, '<br/>');
|
||||
|
||||
return s;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成标题锚点 id(支持中文)
|
||||
*/
|
||||
const slugify = (text) => {
|
||||
return String(text)
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u4e00-\u9fa5\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
};
|
||||
|
||||
export { safeUrl, slugify };
|
||||
export default parseMarkdown;
|
||||
+483
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* MetonaEditor Plugins - 插件系统
|
||||
* @module plugins
|
||||
* @version 0.1.0
|
||||
* @description 插件管理器 + 预设插件(autoSave / exportTool / searchReplace)
|
||||
*
|
||||
* 插件约定:
|
||||
* {
|
||||
* name: string,
|
||||
* description?: string,
|
||||
* install(editor, options): void, // 安装时调用,editor 为 MarkdownEditor 实例
|
||||
* destroy(editor): void // 卸载时调用,清理事件与 DOM
|
||||
* }
|
||||
* install 的 this 指向插件对象本身(core.js 通过 merged.install(editor) 调用),
|
||||
* 因此可在 this 上存放运行时状态(如定时器、DOM 引用)。
|
||||
*/
|
||||
|
||||
import { t } from './i18n.js';
|
||||
|
||||
/**
|
||||
* 插件管理器(独立于编辑器实例,用于全局注册与查询;编辑器实例级安装由 core.js 的 use() 负责)
|
||||
*/
|
||||
class PluginManager {
|
||||
constructor() {
|
||||
this.plugins = new Map();
|
||||
}
|
||||
|
||||
register(name, plugin) {
|
||||
if (this.plugins.has(name)) {
|
||||
console.warn(`MeEditor: plugin "${name}" already registered`);
|
||||
return this;
|
||||
}
|
||||
if (!plugin || typeof plugin !== 'object' || (!plugin.name && !name)) {
|
||||
console.error(`MeEditor: invalid plugin "${name}"`);
|
||||
return this;
|
||||
}
|
||||
this.plugins.set(name, { name, ...plugin, enabled: true });
|
||||
return this;
|
||||
}
|
||||
|
||||
unregister(name) {
|
||||
this.plugins.delete(name);
|
||||
return this;
|
||||
}
|
||||
|
||||
get(name) { return this.plugins.get(name) || null; }
|
||||
has(name) { return this.plugins.has(name); }
|
||||
getAll() { return Array.from(this.plugins.values()); }
|
||||
getNames() { return Array.from(this.plugins.keys()); }
|
||||
enable(name) { const p = this.plugins.get(name); if (p) p.enabled = true; return this; }
|
||||
disable(name) { const p = this.plugins.get(name); if (p) p.enabled = false; return this; }
|
||||
isEnabled(name){ const p = this.plugins.get(name); return p ? p.enabled : false; }
|
||||
|
||||
destroy() {
|
||||
this.plugins.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const defaultPluginManager = new PluginManager();
|
||||
|
||||
// ============ 预设插件 ============
|
||||
|
||||
/**
|
||||
* 自动保存插件
|
||||
* 将编辑器内容定时 / 失焦 / 保存时写入 localStorage,并提供草稿恢复与清除。
|
||||
*
|
||||
* 配置:
|
||||
* key: 存储 key,默认 'me-draft-' + editor.id
|
||||
* delay: 防抖延迟(ms),默认 1000
|
||||
*/
|
||||
const autoSavePlugin = {
|
||||
name: 'autoSave',
|
||||
description: '自动保存到 localStorage,支持草稿恢复',
|
||||
key: null,
|
||||
delay: 1000,
|
||||
|
||||
_timer: null,
|
||||
_onInput: null,
|
||||
_onBlur: null,
|
||||
_onSave: null,
|
||||
|
||||
install(editor) {
|
||||
if (!editor || typeof editor.getValue !== 'function') return;
|
||||
const key = this.key || ('me-draft-' + (editor.id || ''));
|
||||
|
||||
const save = () => {
|
||||
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
||||
try {
|
||||
const value = editor.getValue();
|
||||
localStorage.setItem(key, value);
|
||||
if (typeof editor._emit === 'function') {
|
||||
editor._emit('autosave', { key, value });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('MeEditor autoSave: save failed', e);
|
||||
}
|
||||
};
|
||||
|
||||
this._save = save;
|
||||
this._onInput = () => {
|
||||
if (this._timer) clearTimeout(this._timer);
|
||||
this._timer = setTimeout(save, this.delay);
|
||||
};
|
||||
this._onBlur = save;
|
||||
this._onSave = save;
|
||||
|
||||
editor.on('change', this._onInput);
|
||||
editor.on('blur', this._onBlur);
|
||||
editor.on('save', this._onSave);
|
||||
|
||||
// 暴露草稿 API
|
||||
editor.restoreDraft = () => {
|
||||
try {
|
||||
const v = localStorage.getItem(key);
|
||||
if (v != null && typeof editor.setValue === 'function') {
|
||||
editor.setValue(v);
|
||||
}
|
||||
return v;
|
||||
} catch (e) { return null; }
|
||||
};
|
||||
editor.clearDraft = () => {
|
||||
try { localStorage.removeItem(key); } catch (e) {}
|
||||
return editor;
|
||||
};
|
||||
editor.getDraftKey = () => key;
|
||||
},
|
||||
|
||||
destroy(editor) {
|
||||
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
||||
if (editor && typeof editor.off === 'function') {
|
||||
if (this._onInput) editor.off('change', this._onInput);
|
||||
if (this._onBlur) editor.off('blur', this._onBlur);
|
||||
if (this._onSave) editor.off('save', this._onSave);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 导出工具插件
|
||||
* 提供 .md / .html 文件下载。
|
||||
*/
|
||||
const exportToolPlugin = {
|
||||
name: 'exportTool',
|
||||
description: '导出 Markdown / HTML 文件',
|
||||
|
||||
install(editor) {
|
||||
if (!editor || typeof editor.getValue !== 'function') return;
|
||||
|
||||
const download = (filename, content, mime) => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const blob = new Blob([content], { type: mime + ';charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
const stamp = () => {
|
||||
const d = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
editor.exportMarkdown = (filename) => {
|
||||
download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown');
|
||||
return editor;
|
||||
};
|
||||
|
||||
editor.exportHTML = (filename, opts = {}) => {
|
||||
const title = opts.title || 'Document';
|
||||
const css = opts.css || '';
|
||||
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="${opts.lang || 'zh-CN'}">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>${title}</title>
|
||||
${css ? `<style>${css}</style>` : ''}
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
</body>
|
||||
</html>`;
|
||||
download(filename || `metona-${stamp()}.html`, html, 'text/html');
|
||||
return editor;
|
||||
};
|
||||
},
|
||||
|
||||
destroy() {},
|
||||
};
|
||||
|
||||
/**
|
||||
* 查找替换插件
|
||||
* Ctrl+F 唤出浮动搜索条,支持上一个 / 下一个 / 替换 / 全部替换。
|
||||
*/
|
||||
const searchReplacePlugin = {
|
||||
name: 'searchReplace',
|
||||
description: '查找替换(Ctrl+F)',
|
||||
|
||||
_onKeydown: null,
|
||||
_panel: null,
|
||||
_cleanup: null,
|
||||
|
||||
install(editor) {
|
||||
if (!editor || !editor.textarea) return;
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
this._injectStyle();
|
||||
|
||||
this._onKeydown = (e) => {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (!mod) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === 'f') {
|
||||
e.preventDefault();
|
||||
this._open(editor);
|
||||
} else if (k === 'h') {
|
||||
e.preventDefault();
|
||||
this._open(editor, true);
|
||||
} else if (k === 'escape' && this._panel) {
|
||||
this._close(editor);
|
||||
}
|
||||
};
|
||||
editor.textarea.addEventListener('keydown', this._onKeydown);
|
||||
},
|
||||
|
||||
_injectStyle() {
|
||||
if (document.getElementById('me-search-style')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'me-search-style';
|
||||
style.textContent = `
|
||||
.me-search{position:absolute;top:8px;right:12px;z-index:20;display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--md-toolbar-bg, #f8f9fa);border:1px solid var(--md-border, rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);font-size:13px;min-width:280px}
|
||||
.me-search-row{display:flex;gap:4px;align-items:center}
|
||||
.me-search input{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--md-border, rgba(0,0,0,0.15));border-radius:4px;background:var(--md-textarea-bg, #fff);color:var(--md-text, #1f2937);font-size:13px}
|
||||
.me-search input:focus{outline:none;border-color:var(--md-accent, #3b82f6)}
|
||||
.me-search button{padding:4px 8px;border:1px solid var(--md-border, rgba(0,0,0,0.15));background:var(--md-bg, #fff);color:var(--md-text, #1f2937);border-radius:4px;cursor:pointer;font-size:12px;line-height:1}
|
||||
.me-search button:hover{background:var(--md-accent, #3b82f6);color:#fff;border-color:var(--md-accent, #3b82f6)}
|
||||
.me-search .me-search-count{color:var(--md-muted, #6b7280);font-size:12px;min-width:60px;text-align:center}
|
||||
.me-search .me-search-close{padding:2px 6px}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
},
|
||||
|
||||
_open(editor, showReplace) {
|
||||
if (this._panel) {
|
||||
this._panel.dataset.replace = showReplace ? '1' : '0';
|
||||
this._updateReplaceVisible();
|
||||
const inp = this._panel.querySelector('.me-search-find');
|
||||
if (inp) { inp.focus(); inp.select(); }
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = editor.textarea.value.substring(
|
||||
editor.textarea.selectionStart,
|
||||
editor.textarea.selectionEnd
|
||||
);
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'me-search';
|
||||
panel.dataset.replace = showReplace ? '1' : '0';
|
||||
panel.innerHTML = `
|
||||
<div class="me-search-row">
|
||||
<input type="text" class="me-search-find" placeholder="${(t('searchPlaceholder') || '查找内容')}" value="${escapeAttr(selected)}"/>
|
||||
<button type="button" class="me-search-prev" title="${(t('findPrev') || '上一个')}">↑</button>
|
||||
<button type="button" class="me-search-next" title="${(t('findNext') || '下一个')}">↓</button>
|
||||
<span class="me-search-count"></span>
|
||||
<button type="button" class="me-search-close" title="${(t('close') || '关闭')}" aria-label="close">×</button>
|
||||
</div>
|
||||
<div class="me-search-row me-search-replace-row">
|
||||
<input type="text" class="me-search-replace" placeholder="${(t('replacePlaceholder') || '替换为')}"/>
|
||||
<button type="button" class="me-search-replace-one">${(t('replace') || '替换')}</button>
|
||||
<button type="button" class="me-search-replace-all">${(t('replaceAll') || '全部')}</button>
|
||||
</div>
|
||||
`;
|
||||
editor.el.appendChild(panel);
|
||||
this._panel = panel;
|
||||
this._updateReplaceVisible();
|
||||
|
||||
const findInput = panel.querySelector('.me-search-find');
|
||||
const replaceInput = panel.querySelector('.me-search-replace');
|
||||
const countEl = panel.querySelector('.me-search-count');
|
||||
|
||||
const findAll = () => {
|
||||
const text = editor.textarea.value;
|
||||
const q = findInput.value;
|
||||
if (!q) { countEl.textContent = ''; return []; }
|
||||
const idxs = [];
|
||||
let from = 0;
|
||||
while (true) {
|
||||
const idx = text.indexOf(q, from);
|
||||
if (idx === -1) break;
|
||||
idxs.push(idx);
|
||||
from = idx + q.length;
|
||||
}
|
||||
countEl.textContent = idxs.length ? `${idxs.length}` : '0';
|
||||
return idxs;
|
||||
};
|
||||
|
||||
const selectAt = (idx) => {
|
||||
const q = findInput.value;
|
||||
editor.textarea.focus();
|
||||
editor.textarea.setSelectionRange(idx, idx + q.length);
|
||||
// 滚动到可见
|
||||
const lineHeight = parseFloat(getComputedStyle(editor.textarea).lineHeight) || 20;
|
||||
const lineNum = editor.textarea.value.substring(0, idx).split('\n').length - 1;
|
||||
editor.textarea.scrollTop = Math.max(0, lineNum * lineHeight - editor.textarea.clientHeight / 2);
|
||||
};
|
||||
|
||||
let lastIdxs = [];
|
||||
let cursor = -1;
|
||||
|
||||
const findNext = () => {
|
||||
lastIdxs = findAll();
|
||||
if (!lastIdxs.length) return;
|
||||
const cur = editor.textarea.selectionEnd;
|
||||
let next = lastIdxs.find((i) => i >= cur);
|
||||
if (next == null) next = lastIdxs[0];
|
||||
cursor = lastIdxs.indexOf(next);
|
||||
selectAt(next);
|
||||
};
|
||||
|
||||
const findPrev = () => {
|
||||
lastIdxs = findAll();
|
||||
if (!lastIdxs.length) return;
|
||||
const cur = editor.textarea.selectionStart;
|
||||
let prev = -1;
|
||||
for (let i = lastIdxs.length - 1; i >= 0; i--) {
|
||||
if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; }
|
||||
}
|
||||
if (prev === -1) prev = lastIdxs[lastIdxs.length - 1];
|
||||
cursor = lastIdxs.indexOf(prev);
|
||||
selectAt(prev);
|
||||
};
|
||||
|
||||
const replaceOne = () => {
|
||||
const q = findInput.value;
|
||||
const r = replaceInput.value;
|
||||
if (!q) return;
|
||||
const ta = editor.textarea;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const cur = ta.value.substring(start, end);
|
||||
if (cur === q) {
|
||||
ta.value = ta.value.substring(0, start) + r + ta.value.substring(end);
|
||||
ta.setSelectionRange(start, start + r.length);
|
||||
editor._value = ta.value;
|
||||
if (typeof editor._pushHistory === 'function') editor._pushHistory();
|
||||
if (typeof editor._render === 'function') editor._render();
|
||||
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
|
||||
}
|
||||
findNext();
|
||||
};
|
||||
|
||||
const replaceAll = () => {
|
||||
const q = findInput.value;
|
||||
const r = replaceInput.value;
|
||||
if (!q) return;
|
||||
const ta = editor.textarea;
|
||||
const before = ta.value;
|
||||
const after = before.split(q).join(r);
|
||||
if (before === after) return;
|
||||
ta.value = after;
|
||||
ta.setSelectionRange(0, 0);
|
||||
editor._value = ta.value;
|
||||
if (typeof editor._pushHistory === 'function') editor._pushHistory();
|
||||
if (typeof editor._render === 'function') editor._render();
|
||||
if (typeof editor._emit === 'function') editor._emit('change', editor._value);
|
||||
findAll();
|
||||
};
|
||||
|
||||
findInput.addEventListener('input', () => { lastIdxs = findAll(); cursor = -1; });
|
||||
findInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); this._close(editor); }
|
||||
});
|
||||
replaceInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); replaceOne(); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); this._close(editor); }
|
||||
});
|
||||
panel.querySelector('.me-search-next').addEventListener('click', findNext);
|
||||
panel.querySelector('.me-search-prev').addEventListener('click', findPrev);
|
||||
panel.querySelector('.me-search-close').addEventListener('click', () => this._close(editor));
|
||||
panel.querySelector('.me-search-replace-one').addEventListener('click', replaceOne);
|
||||
panel.querySelector('.me-search-replace-all').addEventListener('click', replaceAll);
|
||||
|
||||
if (selected) findAll();
|
||||
findInput.focus();
|
||||
findInput.select();
|
||||
|
||||
this._cleanup = () => {
|
||||
if (panel.parentNode) panel.parentNode.removeChild(panel);
|
||||
};
|
||||
},
|
||||
|
||||
_updateReplaceVisible() {
|
||||
if (!this._panel) return;
|
||||
const show = this._panel.dataset.replace === '1';
|
||||
const row = this._panel.querySelector('.me-search-replace-row');
|
||||
if (row) row.style.display = show ? 'flex' : 'none';
|
||||
},
|
||||
|
||||
_close(editor) {
|
||||
if (this._cleanup) { this._cleanup(); this._cleanup = null; }
|
||||
this._panel = null;
|
||||
if (editor && editor.textarea) editor.textarea.focus();
|
||||
},
|
||||
|
||||
destroy(editor) {
|
||||
this._close(editor);
|
||||
if (this._onKeydown && editor && editor.textarea) {
|
||||
editor.textarea.removeEventListener('keydown', this._onKeydown);
|
||||
}
|
||||
this._onKeydown = null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 预设插件注册表
|
||||
*/
|
||||
const presetPlugins = {
|
||||
autoSave: autoSavePlugin,
|
||||
exportTool: exportToolPlugin,
|
||||
searchReplace: searchReplacePlugin,
|
||||
};
|
||||
|
||||
/**
|
||||
* 转义属性值(用于内联 HTML 属性)
|
||||
*/
|
||||
const escapeAttr = (s) => String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
/**
|
||||
* 插件工具
|
||||
*/
|
||||
const pluginUtils = {
|
||||
createManager() { return new PluginManager(); },
|
||||
manager: defaultPluginManager,
|
||||
register(name, plugin) { return defaultPluginManager.register(name, plugin); },
|
||||
unregister(name) { return defaultPluginManager.unregister(name); },
|
||||
get(name) { return defaultPluginManager.get(name); },
|
||||
has(name) { return defaultPluginManager.has(name); },
|
||||
getAll() { return defaultPluginManager.getAll(); },
|
||||
getNames() { return defaultPluginManager.getNames(); },
|
||||
enable(name) { return defaultPluginManager.enable(name); },
|
||||
disable(name) { return defaultPluginManager.disable(name); },
|
||||
isEnabled(name) { return defaultPluginManager.isEnabled(name); },
|
||||
getPreset(name) { return presetPlugins[name] ? { ...presetPlugins[name] } : null; },
|
||||
getAllPresets() { return Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {}); },
|
||||
|
||||
createPlugin(config = {}) {
|
||||
const result = {
|
||||
name: config.name || 'custom',
|
||||
description: config.description || '',
|
||||
// 默认提供空函数,确保 editor.use(plugin) 时 destroy() 调用安全
|
||||
install: () => {},
|
||||
destroy: () => {},
|
||||
...config,
|
||||
};
|
||||
// 强制类型校验:用户传入非函数时回退为空函数,避免运行时报错
|
||||
if (typeof result.install !== 'function') result.install = () => {};
|
||||
if (typeof result.destroy !== 'function') result.destroy = () => {};
|
||||
return result;
|
||||
},
|
||||
|
||||
validatePlugin(plugin) {
|
||||
const errors = [];
|
||||
if (!plugin || typeof plugin !== 'object') errors.push('plugin must be an object');
|
||||
if (plugin && !plugin.name) errors.push('plugin must have a name');
|
||||
if (plugin && plugin.install && typeof plugin.install !== 'function') errors.push('install must be a function');
|
||||
return { valid: errors.length === 0, errors };
|
||||
},
|
||||
};
|
||||
|
||||
export { presetPlugins, pluginUtils, defaultPluginManager, PluginManager };
|
||||
export default presetPlugins;
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* MetonaEditor Styles - 编辑器样式
|
||||
* @module styles
|
||||
* @version 0.1.0
|
||||
* @description 编辑器 UI 样式注入与管理
|
||||
*/
|
||||
|
||||
import { THEMES } from './constants.js';
|
||||
|
||||
let styleElement = null;
|
||||
|
||||
/**
|
||||
* 生成 CSS 样式
|
||||
*/
|
||||
const generateCSS = () => {
|
||||
return `
|
||||
/* ============ CSS 变量默认值(被 themes.js 动态覆盖) ============ */
|
||||
:root {
|
||||
--md-bg: #ffffff;
|
||||
--md-text: #1f2937;
|
||||
--md-border: rgba(0, 0, 0, 0.08);
|
||||
--md-shadow: 0 10px 36px -10px rgba(0,0,0,0.18), 0 4px 14px -4px rgba(0,0,0,0.08);
|
||||
--md-hover-shadow: 0 14px 48px -10px rgba(0,0,0,0.22), 0 6px 18px -4px rgba(0,0,0,0.10);
|
||||
--md-toolbar-bg: rgba(248, 249, 250, 0.92);
|
||||
--md-textarea-bg: #ffffff;
|
||||
--md-preview-bg: #ffffff;
|
||||
--md-code-bg: rgba(243, 244, 246, 1);
|
||||
--md-code-text: #1f2937;
|
||||
--md-accent: #3b82f6;
|
||||
--md-muted: #6b7280;
|
||||
--md-radius: 10px;
|
||||
--md-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
|
||||
--md-mono: "SF Mono", "Cascadia Code", "Consolas", "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* ============ 容器 ============ */
|
||||
.me-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--md-border);
|
||||
border-radius: var(--md-radius);
|
||||
overflow: hidden;
|
||||
background: var(--md-bg);
|
||||
color: var(--md-text);
|
||||
box-shadow: var(--md-shadow);
|
||||
font-family: var(--md-font);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
position: relative;
|
||||
transition: box-shadow 0.25s ease, border-color 0.25s ease;
|
||||
width: 100%;
|
||||
}
|
||||
.me-wrapper:hover { box-shadow: var(--md-hover-shadow); }
|
||||
.me-wrapper.me-disabled { opacity: 0.6; pointer-events: none; }
|
||||
.me-wrapper * { box-sizing: border-box; }
|
||||
.me-wrapper.me-fullscreen {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
width: 100vw; height: 100vh;
|
||||
z-index: 9999;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ============ 工具栏 ============ */
|
||||
.me-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
background: var(--md-toolbar-bg);
|
||||
border-bottom: 1px solid var(--md-border);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
min-height: 40px;
|
||||
}
|
||||
.me-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px; height: 30px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--md-text);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, transform 0.1s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.me-btn:hover { background: var(--md-code-bg); color: var(--md-accent); }
|
||||
.me-btn:active { transform: scale(0.92); }
|
||||
.me-btn.me-active { background: var(--md-accent); color: #fff; }
|
||||
.me-btn:focus-visible { outline: 2px solid var(--md-accent); outline-offset: 1px; }
|
||||
.me-btn svg { width: 17px; height: 17px; display: block; }
|
||||
.me-btn span { font-size: 12px; font-weight: 500; }
|
||||
|
||||
.me-toolbar-sep {
|
||||
display: inline-block;
|
||||
width: 1px; height: 20px;
|
||||
background: var(--md-border);
|
||||
margin: 0 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.me-toolbar-group {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
padding-left: 6px;
|
||||
border-left: 1px solid var(--md-border);
|
||||
}
|
||||
.me-toolbar-group .me-btn { width: auto; padding: 0 8px; }
|
||||
.me-toolbar-group .me-btn span { font-size: 12px; }
|
||||
|
||||
/* ============ 主体布局 ============ */
|
||||
.me-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
.me-editor-pane, .me-preview-pane {
|
||||
flex: 1 1 50%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.me-divider {
|
||||
flex: 0 0 1px;
|
||||
background: var(--md-border);
|
||||
cursor: col-resize;
|
||||
position: relative;
|
||||
}
|
||||
.me-divider::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; bottom: 0; left: -3px; right: -3px;
|
||||
}
|
||||
.me-divider:hover { background: var(--md-accent); }
|
||||
|
||||
/* 编辑区 */
|
||||
.me-textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px 18px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
resize: none;
|
||||
background: var(--md-textarea-bg);
|
||||
color: var(--md-text);
|
||||
font-family: var(--md-mono);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
tab-size: 2;
|
||||
-moz-tab-size: 2;
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.me-textarea::placeholder { color: var(--md-muted); opacity: 0.7; }
|
||||
.me-textarea:focus { outline: 0; }
|
||||
|
||||
/* 预览区 */
|
||||
.me-preview-pane { overflow: auto; background: var(--md-preview-bg); }
|
||||
.me-preview {
|
||||
padding: 18px 22px;
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 模式切换 */
|
||||
.me-body.me-mode-edit .me-preview-pane,
|
||||
.me-body.me-mode-edit .me-divider { display: none; }
|
||||
.me-body.me-mode-edit .me-editor-pane { flex: 1 1 100%; }
|
||||
.me-body.me-mode-preview .me-editor-pane,
|
||||
.me-body.me-mode-preview .me-divider { display: none; }
|
||||
.me-body.me-mode-preview .me-preview-pane { flex: 1 1 100%; }
|
||||
|
||||
/* ============ 预览区排版 ============ */
|
||||
.me-preview > :first-child { margin-top: 0; }
|
||||
.me-preview > :last-child { margin-bottom: 0; }
|
||||
.me-preview h1, .me-preview h2, .me-preview h3,
|
||||
.me-preview h4, .me-preview h5, .me-preview h6 {
|
||||
margin: 1.4em 0 0.6em;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.me-preview h1 { font-size: 1.9em; padding-bottom: 0.3em; border-bottom: 1px solid var(--md-border); }
|
||||
.me-preview h2 { font-size: 1.55em; padding-bottom: 0.3em; border-bottom: 1px solid var(--md-border); }
|
||||
.me-preview h3 { font-size: 1.3em; }
|
||||
.me-preview h4 { font-size: 1.12em; }
|
||||
.me-preview h5 { font-size: 1em; }
|
||||
.me-preview h6 { font-size: 0.9em; color: var(--md-muted); }
|
||||
.me-preview p { margin: 0.7em 0; }
|
||||
.me-preview a { color: var(--md-accent); text-decoration: none; }
|
||||
.me-preview a:hover { text-decoration: underline; }
|
||||
.me-preview strong { font-weight: 650; }
|
||||
.me-preview em { font-style: italic; }
|
||||
.me-preview del { text-decoration: line-through; opacity: 0.75; }
|
||||
.me-preview ul, .me-preview ol { margin: 0.6em 0; padding-left: 1.6em; }
|
||||
.me-preview li { margin: 0.25em 0; }
|
||||
.me-preview li.me-task-item { list-style: none; margin-left: -1.4em; }
|
||||
.me-preview li.me-task-item input { margin-right: 0.5em; vertical-align: middle; }
|
||||
.me-preview blockquote {
|
||||
margin: 0.8em 0;
|
||||
padding: 0.4em 1em;
|
||||
border-left: 3px solid var(--md-accent);
|
||||
background: var(--md-code-bg);
|
||||
border-radius: 0 6px 6px 0;
|
||||
color: var(--md-text);
|
||||
}
|
||||
.me-preview blockquote > :first-child { margin-top: 0; }
|
||||
.me-preview blockquote > :last-child { margin-bottom: 0; }
|
||||
.me-preview hr {
|
||||
border: 0;
|
||||
height: 1px;
|
||||
background: var(--md-border);
|
||||
margin: 1.6em 0;
|
||||
}
|
||||
.me-preview code {
|
||||
font-family: var(--md-mono);
|
||||
font-size: 0.88em;
|
||||
padding: 0.15em 0.4em;
|
||||
background: var(--md-code-bg);
|
||||
color: var(--md-code-text);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.me-preview pre {
|
||||
margin: 0.9em 0;
|
||||
padding: 14px 16px;
|
||||
background: var(--md-code-bg);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--md-border);
|
||||
}
|
||||
.me-preview pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--md-code-text);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.6;
|
||||
border-radius: 0;
|
||||
}
|
||||
.me-preview img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.me-table-wrap { overflow-x: auto; margin: 0.9em 0; }
|
||||
.me-preview table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.93em;
|
||||
display: block;
|
||||
}
|
||||
.me-preview th, .me-preview td {
|
||||
border: 1px solid var(--md-border);
|
||||
padding: 7px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.me-preview th {
|
||||
background: var(--md-code-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.me-preview tr:nth-child(even) td { background: var(--md-code-bg); }
|
||||
.me-preview tr:nth-child(even) td { background-color: color-mix(in srgb, var(--md-code-bg) 40%, transparent); }
|
||||
|
||||
/* ============ 状态栏 ============ */
|
||||
.me-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 14px;
|
||||
padding: 4px 12px;
|
||||
border-top: 1px solid var(--md-border);
|
||||
background: var(--md-toolbar-bg);
|
||||
color: var(--md-muted);
|
||||
font-size: 12px;
|
||||
min-height: 26px;
|
||||
}
|
||||
.me-statusbar:empty { display: none; }
|
||||
|
||||
/* ============ 响应式 ============ */
|
||||
@media (max-width: 768px) {
|
||||
.me-body.me-mode-split {
|
||||
flex-direction: column;
|
||||
}
|
||||
.me-body.me-mode-split .me-divider {
|
||||
flex: 0 0 1px;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
.me-body.me-mode-split .me-divider::after {
|
||||
top: -3px; bottom: -3px; left: 0; right: 0;
|
||||
}
|
||||
.me-body.me-mode-split .me-editor-pane,
|
||||
.me-body.me-mode-split .me-preview-pane {
|
||||
flex: 1 1 50%;
|
||||
width: 100%;
|
||||
}
|
||||
.me-toolbar { padding: 4px; }
|
||||
.me-btn { width: 28px; height: 28px; }
|
||||
.me-preview { padding: 14px; }
|
||||
.me-textarea { padding: 12px; font-size: 13px; }
|
||||
}
|
||||
|
||||
/* ============ 无障碍 ============ */
|
||||
.me-wrapper:focus-within {
|
||||
border-color: var(--md-accent);
|
||||
}
|
||||
.me-textarea:focus-visible { outline: 0; }
|
||||
|
||||
/* ============ 动画 ============ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.me-wrapper, .me-btn, .me-textarea { transition: none !important; }
|
||||
}
|
||||
|
||||
/* ============ 全屏模式优化 ============ */
|
||||
.me-wrapper.me-fullscreen .me-preview,
|
||||
.me-wrapper.me-fullscreen .me-textarea { font-size: 15px; }
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 注入样式(单例)
|
||||
*/
|
||||
export const injectStyles = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (styleElement && document.getElementById('metona-editor-styles')) return;
|
||||
|
||||
const css = generateCSS();
|
||||
styleElement = document.createElement('style');
|
||||
styleElement.id = 'metona-editor-styles';
|
||||
styleElement.textContent = css.replace(/\s+/g, ' ');
|
||||
document.head.appendChild(styleElement);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新样式
|
||||
*/
|
||||
export const updateStyles = () => {
|
||||
if (!styleElement) { injectStyles(); return; }
|
||||
styleElement.textContent = generateCSS().replace(/\s+/g, ' ');
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除样式
|
||||
*/
|
||||
export const removeStyles = () => {
|
||||
if (styleElement && styleElement.parentNode) {
|
||||
styleElement.parentNode.removeChild(styleElement);
|
||||
}
|
||||
styleElement = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取系统主题
|
||||
*/
|
||||
export const getSystemTheme = () => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
/**
|
||||
* 监听系统主题变化
|
||||
*/
|
||||
export const watchSystemTheme = (callback) => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e) => callback(e.matches ? 'dark' : 'light');
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
};
|
||||
|
||||
export { THEMES };
|
||||
export default { injectStyles, updateStyles, removeStyles, getSystemTheme, watchSystemTheme };
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* MetonaEditor Themes - 主题管理
|
||||
* @module themes
|
||||
* @version 0.1.0
|
||||
* @description 主题系统、自定义主题和主题切换
|
||||
*/
|
||||
|
||||
import { THEMES } from './constants.js';
|
||||
import { prefersDark } from './utils.js';
|
||||
|
||||
let currentTheme = 'auto';
|
||||
let themeListeners = new Set();
|
||||
let systemThemeMediaQuery = null;
|
||||
let systemThemeListener = null;
|
||||
|
||||
/**
|
||||
* 获取系统主题
|
||||
*/
|
||||
export const getSystemTheme = () => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return prefersDark() ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取主题(别名)
|
||||
*/
|
||||
export const getTheme = (theme) => {
|
||||
return resolveTheme(theme);
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析主题
|
||||
*/
|
||||
export const resolveTheme = (theme) => {
|
||||
if (theme === 'auto') {
|
||||
if (currentTheme && currentTheme !== 'auto') {
|
||||
return currentTheme;
|
||||
}
|
||||
return getSystemTheme();
|
||||
}
|
||||
return theme;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取主题配置
|
||||
*/
|
||||
export const getThemeConfig = (theme) => {
|
||||
const resolved = resolveTheme(theme);
|
||||
return THEMES[resolved] || THEMES.light;
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用主题
|
||||
*/
|
||||
export const applyTheme = (theme) => {
|
||||
currentTheme = theme;
|
||||
const resolved = resolveTheme(theme);
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-md-theme', resolved);
|
||||
document.documentElement.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto');
|
||||
document.documentElement.classList.add(`md-theme-${resolved}`);
|
||||
|
||||
const config = getThemeConfig(theme);
|
||||
setThemeVariables(config);
|
||||
}
|
||||
|
||||
notifyThemeListeners(theme, resolved);
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置主题CSS变量
|
||||
*/
|
||||
export const setThemeVariables = (config) => {
|
||||
if (typeof document === 'undefined' || !config || typeof config !== 'object') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
// 基础变量(保留用于全局与组件回退)
|
||||
root.style.setProperty('--md-bg', config.bg);
|
||||
root.style.setProperty('--md-text', config.text);
|
||||
root.style.setProperty('--md-border', config.border);
|
||||
root.style.setProperty('--md-shadow', config.shadow);
|
||||
root.style.setProperty('--md-hover-shadow', config.hoverShadow);
|
||||
// 编辑器专属变量
|
||||
root.style.setProperty('--md-toolbar-bg', config.toolbarBg || config.bg);
|
||||
root.style.setProperty('--md-textarea-bg', config.textareaBg || config.bg);
|
||||
root.style.setProperty('--md-preview-bg', config.previewBg || config.bg);
|
||||
root.style.setProperty('--md-code-bg', config.codeBg || 'rgba(127,127,127,0.1)');
|
||||
root.style.setProperty('--md-code-text', config.codeText || config.text);
|
||||
root.style.setProperty('--md-accent', config.accent || '#3b82f6');
|
||||
root.style.setProperty('--md-muted', config.muted || '#9ca3af');
|
||||
// 兼容旧字段
|
||||
if (config.progressBg) root.style.setProperty('--md-progress-bg', config.progressBg);
|
||||
if (config.closeHoverBg) root.style.setProperty('--md-close-hover-bg', config.closeHoverBg);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前主题
|
||||
*/
|
||||
export const getCurrentTheme = () => {
|
||||
return currentTheme;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取解析后的主题
|
||||
*/
|
||||
export const getResolvedTheme = () => {
|
||||
return resolveTheme(currentTheme);
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换主题
|
||||
*/
|
||||
export const switchTheme = (theme) => {
|
||||
applyTheme(theme);
|
||||
saveTheme(theme);
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换亮色/暗色主题
|
||||
*/
|
||||
export const toggleTheme = () => {
|
||||
const resolved = getResolvedTheme();
|
||||
const newTheme = resolved === 'dark' ? 'light' : 'dark';
|
||||
switchTheme(newTheme);
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置为自动主题
|
||||
*/
|
||||
export const resetToAuto = () => {
|
||||
switchTheme('auto');
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存主题到本地存储
|
||||
*/
|
||||
export const saveTheme = (theme) => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
localStorage.setItem('metona-editor-theme', theme);
|
||||
} catch (e) {
|
||||
console.warn('Failed to save theme:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从本地存储加载主题
|
||||
*/
|
||||
export const loadTheme = () => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
try {
|
||||
return localStorage.getItem('metona-editor-theme') || 'auto';
|
||||
} catch (e) {
|
||||
console.warn('Failed to load theme:', e);
|
||||
}
|
||||
}
|
||||
return 'auto';
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化主题系统
|
||||
*/
|
||||
export const initTheme = () => {
|
||||
const savedTheme = loadTheme();
|
||||
applyTheme(savedTheme);
|
||||
watchSystemTheme();
|
||||
};
|
||||
|
||||
/**
|
||||
* 监听系统主题变化
|
||||
*/
|
||||
export const watchSystemTheme = () => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
|
||||
if (systemThemeMediaQuery && systemThemeListener) {
|
||||
systemThemeMediaQuery.removeEventListener('change', systemThemeListener);
|
||||
}
|
||||
|
||||
systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
systemThemeListener = (e) => {
|
||||
if (currentTheme === 'auto') {
|
||||
applyTheme('auto');
|
||||
}
|
||||
};
|
||||
|
||||
systemThemeMediaQuery.addEventListener('change', systemThemeListener);
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止监听系统主题变化
|
||||
*/
|
||||
export const unwatchSystemTheme = () => {
|
||||
if (systemThemeMediaQuery && systemThemeListener) {
|
||||
systemThemeMediaQuery.removeEventListener('change', systemThemeListener);
|
||||
systemThemeMediaQuery = null;
|
||||
systemThemeListener = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加主题监听器
|
||||
*/
|
||||
export const addThemeListener = (listener) => {
|
||||
themeListeners.add(listener);
|
||||
|
||||
return () => {
|
||||
themeListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除主题监听器
|
||||
*/
|
||||
export const removeThemeListener = (listener) => {
|
||||
themeListeners.delete(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* 通知主题监听器
|
||||
*/
|
||||
const notifyThemeListeners = (theme, resolved) => {
|
||||
themeListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(theme, resolved);
|
||||
} catch (e) {
|
||||
console.error('Theme listener error:', e);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除所有主题监听器
|
||||
*/
|
||||
export const clearThemeListeners = () => {
|
||||
themeListeners.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册自定义主题
|
||||
*/
|
||||
export const registerTheme = (name, config) => {
|
||||
const base = THEMES.light;
|
||||
THEMES[name] = {
|
||||
bg: config.bg || base.bg,
|
||||
text: config.text || base.text,
|
||||
border: config.border || base.border,
|
||||
shadow: config.shadow || base.shadow,
|
||||
hoverShadow: config.hoverShadow || base.hoverShadow,
|
||||
toolbarBg: config.toolbarBg || config.bg || base.toolbarBg,
|
||||
textareaBg: config.textareaBg || config.bg || base.textareaBg,
|
||||
previewBg: config.previewBg || config.bg || base.previewBg,
|
||||
codeBg: config.codeBg || base.codeBg,
|
||||
codeText: config.codeText || base.codeText,
|
||||
accent: config.accent || base.accent,
|
||||
muted: config.muted || base.muted,
|
||||
// 兼容旧字段
|
||||
progressBg: config.progressBg,
|
||||
closeHoverBg: config.closeHoverBg,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 注销自定义主题
|
||||
*/
|
||||
export const unregisterTheme = (name) => {
|
||||
if (name === 'light' || name === 'dark' || name === 'auto') {
|
||||
console.warn('Cannot unregister built-in theme:', name);
|
||||
return;
|
||||
}
|
||||
|
||||
delete THEMES[name];
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有主题
|
||||
*/
|
||||
export const getAllThemes = () => {
|
||||
return { ...THEMES };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取主题名称列表
|
||||
*/
|
||||
export const getThemeNames = () => {
|
||||
return Object.keys(THEMES);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查主题是否存在
|
||||
*/
|
||||
export const hasTheme = (name) => {
|
||||
return name in THEMES;
|
||||
};
|
||||
|
||||
/**
|
||||
* 主题工具 — 代理层
|
||||
*/
|
||||
export const themeUtils = {
|
||||
getSystemTheme,
|
||||
resolveTheme,
|
||||
getThemeConfig,
|
||||
applyTheme,
|
||||
getCurrentTheme,
|
||||
getResolvedTheme,
|
||||
switchTheme,
|
||||
toggleTheme,
|
||||
resetToAuto,
|
||||
initTheme,
|
||||
watchSystemTheme,
|
||||
unwatchSystemTheme,
|
||||
addThemeListener,
|
||||
removeThemeListener,
|
||||
clearThemeListeners,
|
||||
registerTheme,
|
||||
unregisterTheme,
|
||||
getAllThemes,
|
||||
getThemeNames,
|
||||
hasTheme,
|
||||
saveTheme,
|
||||
loadTheme,
|
||||
};
|
||||
|
||||
/**
|
||||
* 预设主题
|
||||
*/
|
||||
export const presetThemes = {
|
||||
light: {
|
||||
name: '浅色',
|
||||
description: '明亮清晰的主题',
|
||||
config: THEMES.light,
|
||||
},
|
||||
dark: {
|
||||
name: '深色',
|
||||
description: '护眼舒适的暗色主题',
|
||||
config: THEMES.dark,
|
||||
},
|
||||
auto: {
|
||||
name: '自动',
|
||||
description: '跟随系统主题设置',
|
||||
config: 'auto',
|
||||
},
|
||||
warm: {
|
||||
name: '暖色',
|
||||
description: '温馨舒适的暖色主题',
|
||||
config: THEMES.warm,
|
||||
},
|
||||
};
|
||||
|
||||
// 注册预设主题
|
||||
Object.entries(presetThemes).forEach(([name, theme]) => {
|
||||
if (name !== 'auto' && theme.config !== 'auto') {
|
||||
THEMES[name] = theme.config;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建主题管理器
|
||||
*/
|
||||
export const createThemeManager = () => {
|
||||
return {
|
||||
getSystemTheme,
|
||||
resolveTheme,
|
||||
getThemeConfig,
|
||||
applyTheme,
|
||||
getCurrentTheme,
|
||||
getResolvedTheme,
|
||||
switchTheme,
|
||||
toggleTheme,
|
||||
resetToAuto,
|
||||
initTheme,
|
||||
watchSystemTheme,
|
||||
unwatchSystemTheme,
|
||||
addThemeListener,
|
||||
removeThemeListener,
|
||||
clearThemeListeners,
|
||||
registerTheme,
|
||||
unregisterTheme,
|
||||
getAllThemes,
|
||||
getThemeNames,
|
||||
hasTheme,
|
||||
saveTheme,
|
||||
loadTheme,
|
||||
};
|
||||
};
|
||||
|
||||
export { themeUtils as default };
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* MetonaEditor Utils - 工具函数
|
||||
* @module utils
|
||||
* @version 0.1.0
|
||||
* @description 通用工具函数集合
|
||||
*/
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
* @returns {string} 唯一ID
|
||||
*/
|
||||
export const generateId = () => {
|
||||
return 'me-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
||||
};
|
||||
|
||||
/**
|
||||
* HTML转义
|
||||
* @param {string} s - 输入字符串
|
||||
* @returns {string} 转义后的字符串
|
||||
*/
|
||||
export const escapeHTML = (s) => {
|
||||
if (typeof document === 'undefined') {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(s == null ? '' : s);
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测暗色模式偏好
|
||||
* @returns {boolean} 是否偏好暗色模式
|
||||
*/
|
||||
export const prefersDark = () => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
};
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
* @param {Function} func - 要防抖的函数
|
||||
* @param {number} wait - 等待时间(毫秒)
|
||||
* @param {boolean} immediate - 是否立即执行
|
||||
* @returns {Function} 防抖后的函数
|
||||
*/
|
||||
export const debounce = (func, wait, immediate = false) => {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const context = this;
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
if (!immediate) func.apply(context, args);
|
||||
};
|
||||
const callNow = immediate && !timeout;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
if (callNow) func.apply(context, args);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* @param {Function} func - 要节流的函数
|
||||
* @param {number} limit - 限制时间(毫秒)
|
||||
* @returns {Function} 节流后的函数
|
||||
*/
|
||||
export const throttle = (func, limit) => {
|
||||
let inThrottle;
|
||||
return function executedFunction(...args) {
|
||||
const context = this;
|
||||
if (!inThrottle) {
|
||||
func.apply(context, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 深度合并对象
|
||||
* @param {Object} target - 目标对象
|
||||
* @param {Object} source - 源对象
|
||||
* @returns {Object} 合并后的对象
|
||||
*/
|
||||
export const deepMerge = (target, source) => {
|
||||
const output = { ...target };
|
||||
|
||||
Object.keys(source).forEach(key => {
|
||||
if (source[key] instanceof Object && key in target && target[key] instanceof Object) {
|
||||
output[key] = deepMerge(target[key], source[key]);
|
||||
} else {
|
||||
output[key] = source[key];
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否为浏览器环境
|
||||
* @returns {boolean} 是否为浏览器环境
|
||||
*/
|
||||
export const isBrowser = () => {
|
||||
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param {number} bytes - 字节数
|
||||
* @param {number} decimals - 小数位数
|
||||
* @returns {string} 格式化后的字符串
|
||||
*/
|
||||
export const formatFileSize = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
/**
|
||||
* 延迟执行
|
||||
* @param {number} ms - 毫秒数
|
||||
* @returns {Promise} Promise对象
|
||||
*/
|
||||
export const sleep = (ms) => {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
};
|
||||
Reference in New Issue
Block a user