Initial commit: MetonaEditor v0.1.0

This commit is contained in:
2026-07-23 16:23:07 +08:00
commit 22e867eda8
32 changed files with 17621 additions and 0 deletions
+70
View File
@@ -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)',
});
+194
View File
@@ -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
View File
@@ -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 = `![${sel}](${url})`;
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
View File
@@ -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 };
+56
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
* - 图片 ![alt](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. 图片 ![alt](url "title")
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+&quot;([^&]*)&quot;)?\)/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+&quot;([^&]*)&quot;)?\)/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(/&lt;(https?:\/\/[^\s&]+)&gt;/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
View File
@@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
/**
* 插件工具
*/
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
View File
@@ -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
View File
@@ -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
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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));
};