/**
* MetonaEditor Utils — utility functions
* @module utils
* @version 0.2.0
*/
/** Generate a unique ID */
const generateId = () => {
return 'me-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
};
/** HTML-escape a string */
const escapeHTML = (s) => {
const str = String(s == null ? '' : s);
if (typeof document === 'undefined') {
return str
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
};
/** Detect dark mode preference */
const prefersDark = () => {
if (typeof window === 'undefined' || !window.matchMedia)
return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
/** Check if running in browser */
const isBrowser = () => {
return typeof window !== 'undefined' && typeof document !== 'undefined';
};
/**
* MetonaEditor Icons — SVG toolbar icons
* @module icons
* @version 0.2.0
*/
const ICONS = {
bold: ` `,
italic: ` `,
underline: ` `,
strikethrough: ` `,
h1: ` `,
h2: ` `,
h3: ` `,
quote: ` `,
code: ` `,
link: ` `,
image: ` `,
table: ` `,
ul: ` `,
ol: ` `,
indent: ` `,
outdent: ` `,
hr: ` `,
undo: ` `,
redo: ` `,
preview: ` `,
split: ` `,
edit: ` `,
fullscreen: ` `,
theme: ` `,
};
/**
* MetonaEditor Locales — translation data
* @module locales
* @version 0.2.3
*/
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: '渲染失败',
outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
},
'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',
outline: 'Outline',
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
},
ja: {
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: 'リンクURLを入力', imagePlaceholder: '画像URLを入力',
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: 'レンダリング失敗',
outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
},
ko: {
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: '링크 URL 입력', imagePlaceholder: '이미지 URL 입력',
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: '렌더링 실패',
outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
},
fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
h1: 'Titre 1', h2: 'Titre 2', h3: 'Titre 3', quote: 'Citation', code: 'Code',
link: 'Lien', image: 'Image', table: 'Tableau', ul: 'Liste à puces', ol: 'Liste numérotée',
indent: 'Indenter', outdent: 'Désindenter', hr: 'Ligne horizontale', undo: 'Annuler', redo: 'Rétablir',
edit: 'Éditer', split: 'Fractionné', preview: 'Aperçu', fullscreen: 'Plein écran',
fullscreenExit: 'Quitter plein écran', theme: 'Thème', light: 'Clair', dark: 'Sombre',
auto: 'Auto', warm: 'Chaud', wordCount: 'Nombre de mots', characters: 'Caractères',
words: 'Mots', lines: 'Lignes', readingTime: 'Lecture', minutes: 'min',
placeholder: 'Commencez à taper Markdown...', empty: 'Aucun contenu', copied: 'Copié',
copyContent: 'Copier le contenu', copyHTML: 'Copier HTML', copySuccess: 'Copié avec succès',
copyFailed: 'Échec de la copie', clearContent: 'Effacer le contenu', clearConfirm: 'Effacer tout le contenu ?',
linkPlaceholder: 'Entrez l\'URL du lien', imagePlaceholder: 'Entrez l\'URL de l\'image',
altPlaceholder: 'Entrez le texte alternatif', tableRows: 'Lignes', tableCols: 'Colonnes',
confirm: 'Confirmer', cancel: 'Annuler', exportMarkdown: 'Exporter Markdown',
exportHTML: 'Exporter HTML', search: 'Rechercher', replace: 'Remplacer', replaceAll: 'Tout remplacer',
searchPlaceholder: 'Rechercher', replacePlaceholder: 'Remplacer par',
findNext: 'Suivant', findPrev: 'Précédent', matchCase: 'Respecter la casse',
wholeWord: 'Mot entier', close: 'Fermer', open: 'Ouvrir', save: 'Enregistrer',
saved: 'Enregistré', saving: 'Enregistrement...', delete: 'Supprimer', confirmDelete: 'Confirmer la suppression ?',
unsavedChanges: 'Modifications non enregistrées', error: 'Erreur', success: 'Succès',
warning: 'Avertissement', info: 'Info', loading: 'Chargement...', retry: 'Réessayer',
renderError: 'Échec du rendu',
outline: 'Plan',
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
},
de: {
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
h1: 'Überschrift 1', h2: 'Überschrift 2', h3: 'Überschrift 3', quote: 'Zitat', code: 'Code',
link: 'Link', image: 'Bild', table: 'Tabelle', ul: 'Aufzählung', ol: 'Nummerierte Liste',
indent: 'Einrücken', outdent: 'Ausrücken', hr: 'Trennlinie', undo: 'Rückgängig', redo: 'Wiederholen',
edit: 'Bearbeiten', split: 'Geteilt', preview: 'Vorschau', fullscreen: 'Vollbild',
fullscreenExit: 'Vollbild beenden', theme: 'Design', light: 'Hell', dark: 'Dunkel',
auto: 'Auto', warm: 'Warm', wordCount: 'Wörter', characters: 'Zeichen',
words: 'Wörter', lines: 'Zeilen', readingTime: 'Lesezeit', minutes: 'min',
placeholder: 'Markdown eingeben...', empty: 'Kein Inhalt', copied: 'Kopiert',
copyContent: 'Inhalt kopieren', copyHTML: 'HTML kopieren', copySuccess: 'Erfolgreich kopiert',
copyFailed: 'Kopieren fehlgeschlagen', clearContent: 'Inhalt löschen', clearConfirm: 'Gesamten Inhalt löschen?',
linkPlaceholder: 'Link-URL eingeben', imagePlaceholder: 'Bild-URL eingeben',
altPlaceholder: 'Alternativtext eingeben', tableRows: 'Zeilen', tableCols: 'Spalten',
confirm: 'Bestätigen', cancel: 'Abbrechen', exportMarkdown: 'Markdown exportieren',
exportHTML: 'HTML exportieren', search: 'Suchen', replace: 'Ersetzen', replaceAll: 'Alle ersetzen',
searchPlaceholder: 'Suchen', replacePlaceholder: 'Ersetzen mit',
findNext: 'Nächstes', findPrev: 'Vorheriges', matchCase: 'Groß-/Kleinschreibung',
wholeWord: 'Ganzes Wort', close: 'Schließen', open: 'Öffnen', save: 'Speichern',
saved: 'Gespeichert', saving: 'Speichern...', delete: 'Löschen', confirmDelete: 'Wirklich löschen?',
unsavedChanges: 'Ungespeicherte Änderungen', error: 'Fehler', success: 'Erfolg',
warning: 'Warnung', info: 'Info', loading: 'Laden...', retry: 'Wiederholen',
renderError: 'Rendern fehlgeschlagen',
outline: 'Gliederung',
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
},
};
/**
* MetonaEditor Constants — default configs, themes, animations
* @module constants
* @version 0.2.0
*/
/** Default toolbar button sequence */
const DEFAULT_TOOLBAR = [
'bold', 'italic', 'strikethrough', 'code', '|',
'h1', 'h2', 'h3', '|',
'quote', 'ul', 'ol', '|',
'link', 'image', 'table', 'hr', '|',
'undo', 'redo', '|',
'edit', 'split', 'preview', 'fullscreen',
];
/** Default options */
const DEFAULTS = Object.freeze({
value: '',
placeholder: '',
mode: 'split',
height: 400,
toolbar: DEFAULT_TOOLBAR,
wordCount: true,
autofocus: false,
spellcheck: false,
historyLimit: 100,
historyDebounce: 400,
syncScroll: true,
tabSize: 2,
readOnly: false,
lineNumbers: true,
outline: false,
autoBrackets: true,
zenMode: false,
wordWrap: true,
maxLength: 0,
theme: 'auto',
locale: 'zh-CN',
render: null,
highlight: null,
sanitize: null,
className: '',
style: {},
plugins: [],
onChange: null,
onInput: null,
onFocus: null,
onBlur: null,
onSave: null,
onModeChange: null,
onFullscreen: null,
onCreate: null,
onDestroy: null,
onLinkClick: null,
floatingToolbar: true,
});
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',
progressBg: 'rgba(217, 119, 6, 0.15)',
closeHoverBg: 'rgba(217, 119, 6, 0.2)',
},
};
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)' },
};
const EDIT_MODES = ['edit', 'split', 'preview'];
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',
];
/**
* MetonaEditor Styles — CSS-in-JS injection
* @module styles
* @version 0.2.0
*/
let styleElement = null;
const generateCSS = () => {
return `: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 .25s,border-color .25s;width:100%}
.me-wrapper:hover{box-shadow:var(--md-hover-shadow)}
.me-wrapper.me-disabled{opacity:.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 .15s,color .15s,transform .1s;flex-shrink:0}
.me-btn:hover{background:var(--md-code-bg);color:var(--md-accent)}
.me-btn:active{transform:scale(.92)}
.me-btn.me-active{background:var(--md-accent);color:#fff}
.me-btn:disabled{opacity:.35;cursor:not-allowed;pointer-events:none}
.me-btn:focus-visible{outline:2px solid var(--md-accent);outline-offset:2px}
.me-btn:focus:not(:focus-visible){outline:none}
.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-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-editor-inner{display:flex;height:100%;overflow:hidden;position:relative}
.me-gutter{flex:0 0 auto;min-width:36px;padding:16px 8px 16px 6px;overflow:hidden;background:var(--md-code-bg);border-right:1px solid var(--md-border);color:var(--md-muted);font-family:var(--md-mono);font-size:13.5px;line-height:1.7;text-align:right;user-select:none;white-space:pre}
.me-gutter-line{padding-right:4px}
.me-gutter-active{color:var(--md-accent);font-weight:600;background:rgba(59,130,246,.08);border-radius:3px}
.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;display:block;white-space:pre-wrap;word-wrap:break-word;position:relative;z-index:1}
.me-textarea::placeholder{color:var(--md-muted);opacity:.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 .6em;font-weight:650;line-height:1.3}
.me-preview h1{font-size:1.9em;padding-bottom:.3em;border-bottom:1px solid var(--md-border)}
.me-preview h2{font-size:1.55em;padding-bottom:.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:.9em;color:var(--md-muted)}
.me-preview p{margin:.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:.75}
.me-preview ul,.me-preview ol{margin:.6em 0;padding-left:1.6em}
.me-preview li{margin:.25em 0}
.me-preview li.me-task-item{list-style:none;margin-left:-1.4em}
.me-preview li.me-task-item input{margin-right:.5em;vertical-align:middle}
.me-preview blockquote{margin:.8em 0;padding:.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:.88em;padding:.15em .4em;background:var(--md-code-bg);color:var(--md-code-text);border-radius:4px}
.me-preview pre{margin:.9em 0;padding:14px 16px;background:var(--md-code-bg);border-radius:8px;overflow-x:auto;border:1px solid var(--md-border)}
.me-code-title{font-family:var(--md-mono);font-size:.85em;padding:8px 14px;background:var(--md-border);border-radius:8px 8px 0 0;color:var(--md-text);margin:.9em 0 -0.9em;font-weight:600}
.me-code-title+pre{margin-top:0;border-radius:0 0 8px 8px;border-top:none}
.me-preview pre code{padding:0;background:transparent;color:var(--md-code-text);font-size:.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:.9em 0}
.me-preview table{border-collapse:collapse;width:100%;font-size:.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-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}
.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}
.me-wrapper.me-readonly .me-textarea{background:var(--md-code-bg);cursor:default;opacity:.88}
.me-wrapper.me-readonly .me-toolbar{opacity:.75}
.me-wrapper.me-readonly .me-btn:disabled{opacity:.3;cursor:not-allowed;pointer-events:none}
.me-preview mark{background:rgba(250,204,21,.3);color:inherit;padding:.1em .2em;border-radius:3px}
.me-preview sup{font-size:.75em;vertical-align:super;line-height:1}
.me-preview sub{font-size:.75em;vertical-align:sub;line-height:1}
.me-preview .me-math-block{display:block;margin:1.2em 0;padding:12px 16px;background:var(--md-code-bg);border-radius:8px;overflow-x:auto;font-family:var(--md-mono);font-size:.95em;text-align:center}
.me-preview .me-math-inline{font-family:var(--md-mono);font-size:.95em;padding:.05em .2em}
.me-preview dl{margin:.8em 0}
.me-preview dt{font-weight:650;margin:.6em 0 .2em}
.me-preview dd{margin:0 0 .3em 1.6em;color:var(--md-text)}
.me-preview .me-footnote-ref a{font-size:.75em;vertical-align:super;text-decoration:none;color:var(--md-accent)}
.me-preview .me-footnotes{margin-top:2em;border-top:1px solid var(--md-border);padding-top:.8em;font-size:.9em;color:var(--md-muted)}
.me-preview .me-footnotes hr{display:none}
.me-preview .me-footnotes ol{padding-left:1.2em}
.me-preview .me-footnote-item{margin:.3em 0}
.me-preview .me-footnote-backref{text-decoration:none;color:var(--md-accent);margin-right:.4em}
.me-toast{position:absolute;bottom:16px;right:16px;z-index:30;display:flex;align-items:center;gap:8px;padding:10px 16px;border-radius:8px;font-size:13px;pointer-events:auto;cursor:pointer;opacity:0;transform:translateY(12px);transition:opacity .3s,transform .3s;box-shadow:0 6px 20px rgba(0,0,0,.15);max-width:360px}
.me-toast.me-toast-enter{opacity:1;transform:translateY(0)}
.me-toast.me-toast-leave{opacity:0;transform:translateY(12px)}
.me-toast-success{background:#10b981;color:#fff}
.me-toast-error{background:#ef4444;color:#fff}
.me-toast-warning{background:#f59e0b;color:#fff}
.me-toast-info{background:var(--md-accent,#3b82f6);color:#fff}
.me-toast-icon{font-weight:700;font-size:16px;flex-shrink:0}
.me-toast-msg{line-height:1.4}
.me-context-menu{position:fixed;z-index:40;min-width:180px;padding:4px 0;background:var(--md-bg);border:1px solid var(--md-border);border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.18);font-size:13px}
.me-context-menu-item{display:flex;align-items:center;justify-content:space-between;padding:6px 14px;cursor:pointer;color:var(--md-text);transition:background .1s}
.me-context-menu-item:hover{background:var(--md-code-bg);color:var(--md-accent)}
.me-context-menu-sep{height:1px;background:var(--md-border);margin:4px 0}
.me-context-menu-shortcut{color:var(--md-muted);font-size:11px;margin-left:24px}
.me-context-menu-item.me-disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
.me-outline{position:absolute;top:0;right:0;width:220px;height:100%;overflow-y:auto;background:var(--md-preview-bg);border-left:1px solid var(--md-border);padding:12px 14px;font-size:13px;z-index:15}
.me-outline-title{font-weight:650;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--md-border);color:var(--md-text)}
.me-outline ul{list-style:none;padding:0;margin:0}
.me-outline li{margin:2px 0}
.me-outline a{color:var(--md-muted);text-decoration:none;display:block;padding:2px 6px;border-radius:4px;transition:background .1s,color .1s}
.me-outline a:hover{background:var(--md-code-bg);color:var(--md-accent)}
.me-outline a.me-outline-active{background:var(--md-accent);color:#fff;font-weight:600}
.me-outline-l1 a{font-weight:600;color:var(--md-text)}
.me-outline-l2 a{padding-left:12px}
.me-outline-l3 a{padding-left:20px;font-size:12px}
.me-outline-l4 a{padding-left:28px;font-size:12px}
.me-wrapper.me-zen .me-body{max-width:820px;margin:0 auto}
.me-wrapper.me-zen .me-textarea{font-size:15px;line-height:1.8}
.me-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media print{.me-wrapper{border:0!important;box-shadow:none!important}
.me-toolbar,.me-statusbar,.me-divider{display:none!important}
.me-body.me-mode-split .me-editor-pane{display:none}
.me-body.me-mode-split .me-preview-pane{flex:1 1 100%!important}
.me-preview{padding:0!important;font-size:11pt}
.me-wrapper.me-fullscreen{position:static!important;width:auto!important;height:auto!important}}
[dir=rtl] .me-gutter{border-right:none;border-left:1px solid var(--md-border);text-align:left}
[dir=rtl] .me-gutter-line{padding-right:0;padding-left:4px}
[dir=rtl] .me-toolbar-group{margin-left:0;margin-right:auto;padding-left:0;padding-right:6px;border-left:none;border-right:1px solid var(--md-border)}
[dir=rtl] .me-outline{right:auto;left:0;border-left:none;border-right:1px solid var(--md-border)}
[dir=rtl] .me-outline-l2 a{padding-left:0;padding-right:12px}
[dir=rtl] .me-outline-l3 a{padding-left:0;padding-right:20px}
[dir=rtl] .me-outline-l4 a{padding-left:0;padding-right:28px}
[dir=rtl] .me-preview blockquote{border-left:none;border-right:3px solid var(--md-accent);border-radius:6px 0 0 6px}
[dir=rtl] .me-preview ul,[dir=rtl] .me-preview ol{padding-left:0;padding-right:1.6em}
[dir=rtl] .me-preview li.me-task-item{margin-left:0;margin-right:-1.4em}
[dir=rtl] .me-preview dd{margin-left:0;margin-right:1.6em}
[dir=rtl] .me-preview .me-footnotes ol{padding-left:0;padding-right:1.2em}
[dir=rtl] .me-preview .me-footnote-backref{margin-right:0;margin-left:.4em}
[dir=rtl] .me-search{right:auto;left:12px}
[dir=rtl] .me-toast{right:auto;left:16px}
[dir=rtl] .me-context-menu-shortcut{margin-left:0;margin-right:24px}
[dir=rtl] .me-statusbar{justify-content:flex-start}`;
};
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;
document.head.appendChild(styleElement);
};
/**
* MetonaEditor i18n — internationalization
* @module i18n
* @version 0.2.0
*/
let currentLocale = 'zh-CN';
let localeListeners = new Set();
let fallbackLocale = 'zh-CN';
const pluralRules = {
zh: () => 'other',
en: (n) => n === 1 ? 'one' : 'other',
ru: (n) => {
const m = n % 10, h = n % 100;
if (m === 1 && h !== 11)
return 'one';
if (m >= 2 && m <= 4 && !(h >= 12 && h <= 14))
return 'few';
if (m === 0 || (m >= 5 && m <= 9) || (h >= 11 && h <= 14))
return 'many';
return 'other';
},
};
const getPluralForm = (locale, count) => {
const lang = locale.split('-')[0].toLowerCase();
const rule = pluralRules[lang] || pluralRules.en;
return rule(Math.abs(count));
};
const getCurrentLocale = () => currentLocale;
const setCurrentLocale = (locale) => {
let target = locale;
if (!LOCALES[target]) {
console.warn(`Locale "${target}" not found, falling back to "${fallbackLocale}"`);
target = fallbackLocale;
}
currentLocale = target;
notifyLocaleListeners(target);
saveLocale(target);
};
const t = (key, params = {}, locale) => {
const loc = locale || currentLocale;
let translation = getTranslation(loc, key);
if (translation && typeof translation === 'object' && params.count !== undefined) {
const form = getPluralForm(loc, params.count);
translation = translation[form] || translation.other || key;
}
if (typeof translation === 'string')
return interpolate(translation, params);
if (loc !== fallbackLocale) {
let fb = getTranslation(fallbackLocale, key);
if (fb && typeof fb === 'object' && params.count !== undefined) {
const form = getPluralForm(fallbackLocale, params.count);
fb = fb[form] || fb.other;
}
if (typeof fb === 'string')
return interpolate(fb, params);
}
console.warn(`Translation missing for key "${key}" in locale "${loc}"`);
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 result;
};
const interpolate = (str, params) => {
return str.replace(/\{(\w+)\}/g, (_match, key) => params[key] !== undefined ? String(params[key]) : _match);
};
const hasTranslation = (key) => {
return getTranslation(currentLocale, key) !== undefined || getTranslation(fallbackLocale, key) !== undefined;
};
const getTranslations = (locale) => LOCALES[locale] || {};
const addTranslations = (locale, translations) => {
if (!LOCALES[locale])
LOCALES[locale] = {};
deepMergeI18n(LOCALES[locale], translations);
};
const loadRemote = async (url, locale) => {
try {
const res = await fetch(url);
if (!res.ok)
throw new Error(`HTTP ${res.status}`);
const data = await res.json();
addTranslations(locale, data);
return true;
}
catch (e) {
console.error(`MeEditor: failed to load locale "${locale}" from ${url}`, e);
return false;
}
};
const deepMergeI18n = (target, source) => {
for (const key in source) {
if (source[key] instanceof Object && key in target && target[key] instanceof Object && !Array.isArray(source[key])) {
deepMergeI18n(target[key], source[key]);
}
else {
target[key] = source[key];
}
}
return target;
};
const getSupportedLocales = () => Object.keys(LOCALES);
const isLocaleSupported = (locale) => locale in LOCALES;
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', tr: 'Türkçe',
it: 'Italiano', nl: 'Nederlands', pl: 'Polski',
};
return names[locale] || locale;
};
const getLocaleDirection = (locale) => {
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug', 'dv', 'ku', 'syr'];
const short = locale.split('-')[0].toLowerCase();
return rtlLocales.includes(short) ? 'rtl' : 'ltr';
};
const formatNumber = (number, options = {}) => {
try {
return new Intl.NumberFormat(currentLocale, options).format(number);
}
catch (_) {
return String(number);
}
};
const formatCurrency = (amount, currency = 'USD', options = {}) => {
try {
return new Intl.NumberFormat(currentLocale, { style: 'currency', currency, ...options }).format(amount);
}
catch (_) {
return String(amount);
}
};
const formatDate = (date, options = {}) => {
try {
const d = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(currentLocale, options).format(d);
}
catch (_) {
return String(date);
}
};
const addLocaleListener = (fn) => {
localeListeners.add(fn);
return () => { localeListeners.delete(fn); };
};
const removeLocaleListener = (fn) => { localeListeners.delete(fn); };
const clearLocaleListeners = () => { localeListeners.clear(); };
const notifyLocaleListeners = (locale) => {
localeListeners.forEach((fn) => {
try {
fn(locale);
}
catch (e) {
console.error('Locale listener error:', e);
}
});
};
const saveLocale = (locale) => {
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem('metona-editor-locale', locale);
}
catch (_) { }
}
};
const loadLocale = () => {
if (typeof localStorage !== 'undefined') {
try {
return localStorage.getItem('metona-editor-locale') || getDefaultLocale();
}
catch (_) { }
}
return getDefaultLocale();
};
const getDefaultLocale = () => {
if (typeof navigator !== 'undefined') {
const browserLocale = navigator.language || navigator.userLanguage;
if (browserLocale && isLocaleSupported(browserLocale))
return browserLocale;
const short = browserLocale?.split('-')[0];
if (short && isLocaleSupported(short))
return short;
}
return fallbackLocale;
};
const initI18n = () => {
const saved = loadLocale();
setCurrentLocale(saved);
};
const switchLocale = (locale) => { setCurrentLocale(locale); };
const createInstanceI18n = (editor) => {
let instanceLocale = editor.config?.locale || currentLocale || 'zh-CN';
const instanceT = (key, params = {}) => t(key, params, instanceLocale);
const set = (locale) => {
if (!locale || instanceLocale === locale)
return instanceLocale;
instanceLocale = locale;
if (editor.el) {
editor.el.setAttribute('lang', locale);
editor.el.setAttribute('dir', getLocaleDirection(locale));
}
if (editor.textarea) {
editor.textarea.placeholder = instanceT('placeholder') || '';
editor.textarea.setAttribute('aria-label', instanceT('edit') || '');
editor.textarea.setAttribute('dir', getLocaleDirection(locale));
}
if (typeof editor._emit === 'function') {
editor._emit('localeChange', { locale, direction: getLocaleDirection(locale) });
}
if (editor.toolbarEl) {
editor.toolbarEl.querySelectorAll('.me-btn').forEach((btn) => {
const action = btn.dataset.action || btn.dataset.mode;
if (action) {
const label = instanceT(action);
if (label && label !== action) {
btn.title = label;
btn.setAttribute('aria-label', label);
}
}
});
}
return instanceLocale;
};
return {
set, get: () => instanceLocale, getDirection: () => getLocaleDirection(instanceLocale), t: instanceT,
formatNumber: (n, opts) => { try {
return new Intl.NumberFormat(instanceLocale, opts).format(n);
}
catch (_) {
return String(n);
} },
formatDate: (d, opts) => { try {
return new Intl.DateTimeFormat(instanceLocale, opts).format(d instanceof Date ? d : new Date(d));
}
catch (_) {
return String(d);
} },
};
};
const createI18nManager = () => ({
t, getCurrentLocale, setCurrentLocale, switchLocale,
getFallbackLocale: () => fallbackLocale, setFallbackLocale: (locale) => { fallbackLocale = locale; },
hasTranslation, getTranslations, addTranslations, loadRemote,
getSupportedLocales, isLocaleSupported, getLocaleName, getLocaleDirection,
formatNumber, formatCurrency, formatDate, addLocaleListener, removeLocaleListener, clearLocaleListeners,
initI18n, saveLocale, loadLocale, getDefaultLocale, createInstanceI18n,
});
({
'zh-CN': { name: '简体中文', nativeName: '简体中文', direction: 'ltr', translations: LOCALES['zh-CN'] },
'en-US': { name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', translations: LOCALES['en-US'] },
ja: { name: '日本語', nativeName: '日本語', direction: 'ltr', translations: LOCALES['ja'] },
ko: { name: '한국어', nativeName: '한국어', direction: 'ltr', translations: LOCALES['ko'] },
fr: { name: 'Français', nativeName: 'Français', direction: 'ltr', translations: LOCALES['fr'] },
de: { name: 'Deutsch', nativeName: 'Deutsch', direction: 'ltr', translations: LOCALES['de'] },
});
const i18nUtils = createI18nManager();
/**
* MetonaEditor Parser — lightweight Markdown parser (TypeScript)
* @module parser
* @version 0.2.0
*/
// ============ Precompiled regex ============
const RE_EMPTY = /^\s*$/;
const RE_ATX = /^(#{1,6})\s+(.+?)(?:\s+#{1,6})?\s*$/;
const RE_SETEXT_H1 = /^={3,}\s*$/;
const RE_SETEXT_H2 = /^-{3,}\s*$/;
const RE_FENCE_START = /^(\s{0,3})(`{3,}|~{3,})\s*([\w+#.-]*)(\s+[^\n]*?)?\s*$/;
const RE_HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/;
const RE_QUOTE = /^\s{0,3}>\s?/;
const RE_UL = /^(\s*)([-*+])\s/;
const RE_OL = /^(\s*)(\d+)\.\s/;
const RE_TASK = /^\[([ xX])\]\s+(.*)$/;
const RE_TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
const RE_INDENT_CODE = /^( {4}|\t)/;
const RE_DEF_LIST = /^:\s+/;
const RE_FOOTNOTE_DEF = /^\[\^([^\]]+)\]:\s*/;
// ============ Emoji map ============
const EMOJI_MAP = {
smile: '😊', grinning: '😀', joy: '😂', rofl: '🤣', wink: '😉', blush: '😊', innocent: '😇',
heart_eyes: '😍', kissing_heart: '😘', yum: '😋', stuck_out_tongue: '😛', sunglasses: '😎',
smirk: '😏', unamused: '😒', disappointed: '😞', worried: '😟', confused: '😕',
cry: '😢', sob: '😭', scream: '😱', angry: '😠', rage: '💢', triumph: '😤', sleepy: '😪',
dizzy_face: '😵', zipper_mouth: '🤐', nerd: '🤓', thinking: '🤔', rolling_eyes: '🙄',
expressionless: '😑', thumbsup: '👍', thumbsdown: '👎', clap: '👏', pray: '🙏', muscle: '💪',
ok: '👌', point_up: '👆', point_down: '👇', point_left: '👈', point_right: '👉',
raised_hands: '🙌', wave: '👋', punch: '👊', crossed_fingers: '🤞',
heart: '❤️', broken_heart: '💔', star: '⭐', star2: '🌟', fire: '🔥', rocket: '🚀',
check: '✅', cross: '❌', warning: '⚠️', info: 'ℹ️', question: '❓', exclamation: '❗',
bangbang: '‼️', grey_exclamation: '❕', bulb: '💡', book: '📖', memo: '📝', pin: '📌',
link: '🔗', lock: '🔒', unlock: '🔓', key: '🔑', hammer: '🔨', wrench: '🔧',
gear: '⚙️', tools: '🛠️', magnet: '🧲', zap: '⚡', cloud: '☁️',
sun: '☀️', moon: '🌙', rain: '🌧️', snow: '❄️', umbrella: '☂️', rainbow: '🌈',
tornado: '🌪️', fog: '🌫️', droplet: '💧',
coffee: '☕', pizza: '🍕', cake: '🎂', beer: '🍺', wine: '🍷',
hamburger: '🍔', fries: '🍟', apple: '🍎', banana: '🍌', grapes: '🍇',
watermelon: '🍉', strawberry: '🍓', peach: '🍑', cherry: '🍒', taco: '🌮',
tada: '🎉', gift: '🎁', crown: '👑', gem: '💎', ring: '💍',
confetti: '🎊', balloon: '🎈', ribbon: '🎀', medal: '🏅', trophy: '🏆',
eye: '👁️', ear: '👂', nose: '👃', tongue: '👅', lips: '👄',
brain: '🧠', speech: '💬', thought: '💭', anger: '💢', sweat: '💦',
one: '1️⃣', two: '2️⃣', three: '3️⃣', four: '4️⃣', five: '5️⃣',
six: '6️⃣', seven: '7️⃣', eight: '8️⃣', nine: '9️⃣', zero: '0️⃣',
arrow_up: '⬆️', arrow_down: '⬇️', arrow_left: '⬅️', arrow_right: '➡️',
arrow_upper_right: '↗️', arrow_lower_right: '↘️', arrow_lower_left: '↙️', arrow_upper_left: '↖️',
computer: '💻', phone: '📱', battery: '🔋', electric_plug: '🔌',
copyright: '©️', registered: '®️', tm: '™️', hundred: '💯', boom: '💥', dash: '💨',
hole: '🕳️', bomb: '💣', email: '📧', phone2: '📞', clock: '🕐', hourglass: '⏳',
calendar: '📅', money: '💰', shopping: '🛒', package: '📦', mailbox: '📫',
art: '🎨', music: '🎵', movie: '🎬', game: '🎮', sport: '⚽',
earth: '🌍', house: '🏠', car: '🚗', airplane: '✈️', ship: '🚢',
};
// ============ Render cache ============
const renderCache = new Map();
const MAX_CACHE_SIZE = 300;
const cachedRenderInline = (text, env) => {
if (!text)
return '';
if (!env.highlight && text.length < 600) {
const cached = renderCache.get(text);
if (cached !== undefined)
return cached;
const result = renderInline(text, env);
if (renderCache.size >= MAX_CACHE_SIZE) {
const firstKey = renderCache.keys().next().value;
renderCache.delete(firstKey);
}
renderCache.set(text, result);
return result;
}
return renderInline(text, env);
};
const clearRenderCache = () => { renderCache.clear(); };
// ============ Security utils ============
const safeUrl = (url) => {
if (!url)
return '';
const u = String(url).trim();
if (/^(javascript|vbscript|file):/i.test(u))
return '';
if (/^data:/i.test(u)) {
if (!/^data:image\//i.test(u))
return '';
if (u.length > 500000)
return '';
}
return u;
};
const slugify = (text) => {
let slug = String(text).normalize('NFKC').toLowerCase()
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf\s-]/g, '')
.trim().replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
return slug || 'heading';
};
// ============ Block handler registry ============
const blockHandlers = [];
const registerBlockHandler = (handler) => {
blockHandlers.push(handler);
blockHandlers.sort((a, b) => a.priority - b.priority);
};
const isBlockStart = (line) => {
if (RE_EMPTY.test(line) || RE_FENCE_START.test(line) || RE_ATX.test(line))
return true;
if (RE_HR.test(line) || RE_QUOTE.test(line) || RE_UL.test(line) || RE_OL.test(line))
return true;
if (RE_FOOTNOTE_DEF.test(line) || RE_DEF_LIST.test(line))
return true;
if (/^\$\$/.test(line))
return true;
return false;
};
// Register all built-in block handlers
registerBlockHandler({ name: 'refDef', priority: 0.1,
test: (line) => {
// Must not match footnote definitions: [^id]: text
if (/^\[\^/.test(line))
return null;
const m = line.match(/^\[([^\]]+)\]:\s*(?:<(\S+)>|(\S+))(?:\s+['"\(](.*?)['"\)])?\s*$/);
return m ? m : null;
},
parse: (_lines, i, match, _tokens, _footnotes, refs) => {
const refId = match[1].toLowerCase();
const url = match[2] || match[3] || '';
const title = match[4] || '';
if (url && refs) {
refs[refId] = JSON.stringify({ url, title });
}
return { token: null, newIndex: i + 1 };
},
});
registerBlockHandler({ name: 'blank', priority: 0,
test: (line) => RE_EMPTY.test(line) ? true : null,
parse: (_lines, i) => ({ token: null, newIndex: i + 1 }),
});
registerBlockHandler({ name: 'fencedCode', priority: 1,
test: (line) => {
const m = line.match(RE_FENCE_START);
if (!m)
return null;
// Reject fences where the info string contains backticks (false positives like ``` `code` ```)
const rest = (m[4] || '');
if (rest.includes('`'))
return null;
return m;
},
parse: (lines, i, match) => {
const fenceChar = match[2][0];
const fenceLen = match[2].length;
const lang = match[3] || '';
const rest = (match[4] || '').trim();
// Parse info string: first word is lang, rest are key=value or standalone flags
const infoParts = rest ? rest.split(/\s+/).filter(Boolean) : [];
const attrs = {};
for (let p = 0; p < infoParts.length; p++) {
const kv = infoParts[p].split('=');
if (kv.length === 2)
attrs[kv[0]] = kv[1];
else
attrs[infoParts[p]] = 'true';
}
const codeLines = [];
let j = i + 1;
while (j < lines.length) {
const cm = lines[j].match(/^(\s{0,3})(`{3,}|~{3,})\s*$/);
if (cm && cm[2][0] === fenceChar && cm[2].length >= fenceLen)
break;
codeLines.push(lines[j]);
j++;
}
if (j < lines.length)
j++;
return { token: { type: 'code', lang, content: codeLines.join('\n'), attrs }, newIndex: j };
},
});
registerBlockHandler({ name: 'indentedCode', priority: 2,
test: (line) => {
if (!RE_INDENT_CODE.test(line))
return null;
if (RE_ATX.test(line) || RE_QUOTE.test(line) || RE_UL.test(line) || RE_OL.test(line))
return null;
return true;
},
parse: (lines, i) => {
const codeLines = [];
while (i < lines.length && RE_INDENT_CODE.test(lines[i])) {
codeLines.push(lines[i].replace(/^( {4}|\t)/, ''));
i++;
}
if (codeLines.length)
return { token: { type: 'code', lang: '', content: codeLines.join('\n'), indent: true }, newIndex: i };
return { token: null, newIndex: i };
},
});
registerBlockHandler({ name: 'atxHeading', priority: 3,
test: (line) => line.match(RE_ATX),
parse: (_lines, i, match) => ({
token: { type: 'heading', level: match[1].length, text: match[2] }, newIndex: i + 1,
}),
});
registerBlockHandler({ name: 'setextH1', priority: 4,
test: (line, lines, i) => {
if (i + 1 >= lines.length)
return null;
if (!RE_SETEXT_H1.test(lines[i + 1]))
return null;
if (RE_EMPTY.test(line) || RE_UL.test(line) || RE_OL.test(line) || RE_QUOTE.test(line) || /^\s*`{3,}/.test(line))
return null;
return true;
},
parse: (lines, i) => ({ token: { type: 'heading', level: 1, text: lines[i] }, newIndex: i + 2 }),
});
registerBlockHandler({ name: 'setextH2', priority: 5,
test: (line, lines, i) => {
if (i + 1 >= lines.length)
return null;
if (!RE_SETEXT_H2.test(lines[i + 1]))
return null;
if (RE_EMPTY.test(line) || RE_UL.test(line) || RE_OL.test(line) || RE_QUOTE.test(line) || /^\s*`{3,}/.test(line))
return null;
return true;
},
parse: (lines, i) => ({ token: { type: 'heading', level: 2, text: lines[i] }, newIndex: i + 2 }),
});
registerBlockHandler({ name: 'hr', priority: 6,
test: (line) => RE_HR.test(line) ? true : null,
parse: (_lines, i) => ({ token: { type: 'hr' }, newIndex: i + 1 }),
});
registerBlockHandler({ name: 'blockquote', priority: 7,
test: (line) => RE_QUOTE.test(line) ? true : null,
parse: (lines, i) => {
const quoteLines = [];
while (i < lines.length && RE_QUOTE.test(lines[i])) {
quoteLines.push(lines[i].replace(RE_QUOTE, ''));
i++;
}
return { token: { type: 'quote', content: quoteLines.join('\n') }, newIndex: i };
},
});
registerBlockHandler({ name: 'table', priority: 8,
test: (line, lines, i) => {
if (!/\|/.test(line) || i + 1 >= lines.length)
return null;
if (!isTableSeparator(lines[i + 1]))
return null;
return { header: line, align: parseAlign(lines[i + 1]) };
},
parse: (lines, i, match) => {
let j = i + 2;
const rows = [];
while (j < lines.length && /\|/.test(lines[j]) && !RE_EMPTY.test(lines[j])) {
rows.push(lines[j]);
j++;
}
return { token: { type: 'table', header: match.header, rows, align: match.align }, newIndex: j };
},
});
registerBlockHandler({ name: 'ul', priority: 9,
test: (line) => line.match(RE_UL),
parse: (lines, i, match) => {
const { items, endIdx } = parseList(lines, i, match[2], match[1].length, false);
return { token: { type: 'ul', items }, newIndex: endIdx };
},
});
registerBlockHandler({ name: 'ol', priority: 10,
test: (line) => line.match(RE_OL),
parse: (lines, i, match) => {
const start = parseInt(match[2], 10);
const { items, endIdx } = parseList(lines, i, match[2], match[1].length, true);
return { token: { type: 'ol', items, start }, newIndex: endIdx };
},
});
registerBlockHandler({ name: 'footnoteDef', priority: 11,
test: (line) => line.match(RE_FOOTNOTE_DEF),
parse: (lines, i, match, _tokens, footnotes) => {
const fnId = match[1];
const fnContent = lines[i].slice(match[0].length);
const fnLines = [fnContent];
let j = i + 1;
while (j < lines.length && !RE_EMPTY.test(lines[j]) && !isBlockStart(lines[j])) {
fnLines.push(lines[j]);
j++;
}
footnotes[fnId] = fnLines.join(' ');
return { token: null, newIndex: j };
},
});
registerBlockHandler({ name: 'defList', priority: 12,
test: (line) => RE_DEF_LIST.test(line) ? true : null,
parse: (lines, i, _match, tokens) => {
const term = (tokens.length > 0 && tokens[tokens.length - 1].type === 'paragraph') ? tokens.pop().text : '';
const defs = [lines[i].replace(RE_DEF_LIST, '')];
let j = i + 1;
while (j < lines.length && RE_DEF_LIST.test(lines[j])) {
defs.push(lines[j].replace(RE_DEF_LIST, ''));
j++;
}
return { token: { type: 'defList', term, defs }, newIndex: j };
},
});
registerBlockHandler({ name: 'mathBlock', priority: 13,
test: (line) => /^\$\$/.test(line) ? true : null,
parse: (lines, i) => {
const single = lines[i].match(/^\$\$([\s\S]+?)\$\$\s*$/);
if (single)
return { token: { type: 'mathBlock', content: single[1] }, newIndex: i + 1 };
const mathLines = [lines[i].replace(/^\$\$/, '')];
let j = i + 1;
while (j < lines.length && !/\$\$/.test(lines[j])) {
mathLines.push(lines[j]);
j++;
}
if (j < lines.length) {
mathLines.push(lines[j].replace(/\$\$\s*$/, ''));
j++;
}
return { token: { type: 'mathBlock', content: mathLines.join('\n') }, newIndex: j };
},
});
// ============ Block parsing ============
const parseTokens = (md) => {
if (md == null)
return { tokens: [], footnotes: {}, refs: {} };
const text = String(md).replace(/\r\n?/g, '\n');
const lines = text.split('\n');
const tokens = [];
const footnotes = {};
const refs = {};
let i = 0;
while (i < lines.length) {
const line = lines[i];
let handled = false;
for (const handler of blockHandlers) {
const match = handler.test(line, lines, i);
if (match !== null && match !== false) {
const result = handler.parse(lines, i, match, tokens, footnotes, refs);
if (result.token)
tokens.push(result.token);
i = result.newIndex;
handled = true;
break;
}
}
if (handled)
continue;
// Paragraph fallback
const para = [];
while (i < lines.length) {
const l = lines[i];
if (RE_EMPTY.test(l))
break;
if (isBlockStart(l))
break;
if (i + 1 < lines.length && (RE_SETEXT_H1.test(lines[i + 1]) || RE_SETEXT_H2.test(lines[i + 1])))
break;
if (/\|/.test(l) && i + 1 < lines.length && isTableSeparator(lines[i + 1]))
break;
para.push(l);
i++;
}
if (para.length)
tokens.push({ type: 'paragraph', text: para.join('\n') });
}
return { tokens, footnotes, refs };
};
// ============ Token rendering ============
const renderTokens = (tokens, env = {}, footnotes = {}) => {
let html = tokens.map((tok) => renderToken(tok, env)).join('\n');
const fnIds = Object.keys(footnotes);
if (fnIds.length) {
html += '\n
';
}
return html;
};
const parseMarkdown = (md, env = {}) => {
const { tokens, footnotes, refs } = parseTokens(md);
return renderTokens(tokens, { ...env, refs }, footnotes);
};
// ============ List parsing ============
const parseList = (lines, startIdx, marker, baseIndent, isOl, olStart) => {
const items = [];
let i = startIdx;
const itemIndent = baseIndent + (isOl ? String(marker).length + 2 : 2);
while (i < lines.length) {
const line = lines[i];
let itemMatch;
if (isOl) {
itemMatch = line.match(new RegExp(`^(\\s{${baseIndent}})\\d+\\.\\s`));
if (!itemMatch)
break;
}
else {
itemMatch = line.match(new RegExp(`^(\\s{${baseIndent}})[-*+]\\s`));
if (!itemMatch)
break;
}
let raw = line.slice(itemMatch[0].length);
const task = raw.match(RE_TASK);
let content = task ? task[2] : raw;
const checked = task ? task[1].toLowerCase() === 'x' : false;
const isTask = !!task;
i++;
const subContent = [];
const subTokens = [];
while (i < lines.length) {
const cl = lines[i];
if (RE_EMPTY.test(cl)) {
let peek = i + 1;
while (peek < lines.length && RE_EMPTY.test(lines[peek]))
peek++;
if (peek < lines.length) {
const pl = lines[peek];
const isCont = pl.length > itemIndent && pl[itemIndent] !== ' '
? false : new RegExp(`^\\s{${itemIndent},}`).test(pl);
if (isCont) {
subContent.push('');
i++;
continue;
}
}
break;
}
if (isOl) {
if (new RegExp(`^(\\s{0,${baseIndent}})\\d+\\.\\s`).test(cl))
break;
}
else {
if (new RegExp(`^(\\s{0,${baseIndent}})[-*+]\\s`).test(cl))
break;
}
if (RE_QUOTE.test(cl) || RE_ATX.test(cl) || RE_HR.test(cl) || RE_FOOTNOTE_DEF.test(cl))
break;
const nUl = cl.match(new RegExp(`^(\\s{${itemIndent},})([-*+])\\s`));
const nOl = cl.match(new RegExp(`^(\\s{${itemIndent},})(\\d+)\\.\\s`));
if (nUl || nOl) {
const isNOl = !!nOl;
const nM = isNOl ? nOl[2] : nUl[2];
const nI = isNOl ? nOl[1].length : nUl[1].length;
const nS = isNOl ? parseInt(nOl[2], 10) : undefined;
const nested = parseList(lines, i, nM, nI, isNOl);
subTokens.push({ type: isNOl ? 'ol' : 'ul', items: nested.items, start: nS });
i = nested.endIdx;
continue;
}
if (cl.length > itemIndent && cl.slice(0, itemIndent).trim() === '') {
subContent.push(cl.slice(itemIndent));
}
else {
subContent.push(cl);
}
i++;
}
if (subContent.length) {
content = content ? content + '\n' + subContent.join('\n') : subContent.join('\n');
}
items.push({ type: 'listItem', text: content, checked, task: isTask, subTokens: subTokens.length ? subTokens : undefined });
}
return { items, endIdx: i };
};
// ============ Table utils ============
const isTableSeparator = (line) => RE_TABLE_SEP.test(line) && /-/.test(line);
const parseAlign = (line) => {
const cells = line.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
return cells.map((c) => {
if (/^:/.test(c) && /:$/.test(c))
return 'center';
if (/:$/.test(c))
return 'right';
if (/^:/.test(c))
return 'left';
return 'left';
});
};
// ============ Token renderer ============
const renderToken = (tok, env, footnotes) => {
switch (tok.type) {
case 'heading': {
const id = slugify(tok.text);
return `${cachedRenderInline(tok.text, env)} `;
}
case 'paragraph': return `${cachedRenderInline(tok.text, env)}
`;
case 'hr': return ' ';
case 'quote': return `${parseMarkdown(tok.content, env)} `;
case 'code': return renderCode(tok.content, tok.lang, env, tok.attrs);
case 'ul': return `${tok.items.map((it) => renderListItem(it, env)).join('')} `;
case 'ol': {
const sn = tok.start || 1;
const sa = sn > 1 ? ` start="${sn}"` : '';
const body = tok.items.map((it, idx) => renderListItem(it, env, sn > 1 ? idx + sn : undefined)).join('');
return `${body} `;
}
case 'table': return renderTable(tok, env);
case 'defList': {
let h = '';
h += `${cachedRenderInline(tok.term, env)} `;
tok.defs.forEach((d) => { h += `${cachedRenderInline(d, env)} `; });
return h + ' ';
}
case 'mathBlock': return `${escapeHTML(tok.content)}
`;
default: {
// Fallback: render unknown block types as styled div
const typeClass = `me-block-${tok.type}`;
const content = tok.content ? (typeof tok.content === 'string' ? cachedRenderInline(tok.content, env) : escapeHTML(String(tok.content))) : '';
return `${content}
`;
}
}
};
const renderListItem = (it, env, idx) => {
if (it.task) {
const checked = it.checked ? ' checked' : '';
let h = ` ${cachedRenderInline(it.text, env)}`;
if (it.subTokens)
h += '\n' + it.subTokens.map((st) => renderToken(st, env)).join('\n');
return h + ' ';
}
let h = idx != null ? `` : ' ';
h += cachedRenderInline(it.text, env);
if (it.subTokens)
h += '\n' + it.subTokens.map((st) => renderToken(st, env)).join('\n');
return h + ' ';
};
const renderCode = (code, lang, env, attrs) => {
if (lang === 'mermaid')
return ``;
const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : '';
const titleHtml = attrs?.title ? `${escapeHTML(attrs.title)}
` : '';
if (env.highlight && typeof env.highlight === 'function' && lang) {
try {
const highlighted = env.highlight(code, lang);
if (typeof highlighted === 'string')
return `${titleHtml}${highlighted} `;
}
catch (e) {
console.error('MeEditor highlight error:', e);
}
}
return `${titleHtml}${escapeHTML(code)} `;
};
const renderTable = (tok, env) => {
// Split respecting backslash-escaped pipes
const splitRow = (r) => {
const cells = [];
let current = '';
let escaped = false;
for (let i = 0; i < r.length; i++) {
const ch = r[i];
if (escaped) {
current += ch;
escaped = false;
}
else if (ch === '\\') {
escaped = true;
}
else if (ch === '|') {
cells.push(current.trim());
current = '';
}
else {
current += ch;
}
}
cells.push(current.trim());
// Remove leading/trailing empty cells from outer pipes
if (cells.length > 0 && cells[0] === '')
cells.shift();
if (cells.length > 0 && cells[cells.length - 1] === '')
cells.pop();
return cells;
};
const headers = splitRow(tok.header);
const align = tok.align || [];
const as = (i) => align[i] && align[i] !== 'left' ? ` style="text-align:${align[i]}"` : '';
let h = '';
h += headers.map((hd, i) => `${cachedRenderInline(hd, env)} `).join('');
h += ' ';
h += tok.rows.map((r) => {
const cells = splitRow(r);
return `${cells.map((c, i) => `${cachedRenderInline(c, env)} `).join('')} `;
}).join('');
return h + '
';
};
// ============ Inline rendering ============
const renderInline = (text, env) => {
if (!text)
return '';
const codes = [];
let s = extractInlineCodes(text, codes);
const protectedItems = [];
s = s.replace(/&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});/g, (m) => {
protectedItems.push(m);
return `\u0005${protectedItems.length - 1}\u0005`;
});
s = s.replace(//g, (m) => {
protectedItems.push(m);
return `\u0005${protectedItems.length - 1}\u0005`;
});
s = escapeHTML(s);
s = s.replace(/\u0005(\d+)\u0005/g, (_m, idx) => protectedItems[+idx] || _m);
s = scanInline(s, codes, env);
s = s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => {
const code = codes[+idx];
if (!code)
return _m;
return `${escapeHTML(code.content)}`;
});
s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m);
s = s.replace(/ \n/g, ' \n');
s = s.replace(/\n/g, ' ');
return s;
};
const extractInlineCodes = (text, codes) => {
let result = '';
let i = 0;
while (i < text.length) {
if (text[i] === '`') {
let startLen = 1;
while (i + startLen < text.length && text[i + startLen] === '`')
startLen++;
let j = i + startLen;
let found = false;
while (j < text.length) {
const nextTick = text.indexOf('`', j);
if (nextTick === -1)
break;
let endLen = 1;
while (nextTick + endLen < text.length && text[nextTick + endLen] === '`')
endLen++;
if (endLen === startLen) {
const content = text.slice(i + startLen, nextTick);
codes.push({ content, len: startLen });
result += `\u0000${codes.length - 1}\u0000`;
i = nextTick + endLen;
found = true;
break;
}
j = nextTick + endLen;
}
if (!found) {
result += text.slice(i, i + startLen);
i += startLen;
}
}
else {
result += text[i];
i++;
}
}
return result;
};
const INLINE_RE = new RegExp([
'(\\*\\*\\*[^*\\n][^*\\n]*?\\*\\*\\*)',
'|(___[^_\\n][^_\\n]*?___)',
'|(!\\[[^\\]]*\\]\\([^)]+\\))',
'|(? {
const placeholderMap = new Map();
s = s.replace(/\u0000\d+\u0000/g, (m) => {
const key = `\u0003${placeholderMap.size}\u0003`;
placeholderMap.set(key, m);
return key;
});
s = s.replace(INLINE_RE, (fullMatch) => {
const match = fullMatch;
if (match.startsWith('***'))
return `${match.slice(3, -3)} `;
if (match.startsWith('___'))
return `${match.slice(3, -3)} `;
if (match.startsWith(') {
const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
if (!m)
return match;
const u = safeUrl(m[2]);
if (!u)
return escapeHTML(match);
const t = m[3] ? ` title="${m[3]}"` : '';
return ` `;
}
if (match.startsWith('![') && match.includes('][')) {
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
if (!m)
return match;
const refKey = (m[2] || m[1]).toLowerCase();
if (env.refs && env.refs[refKey]) {
try {
const ref = JSON.parse(env.refs[refKey]);
const u = safeUrl(ref.url);
if (!u)
return escapeHTML(match);
const t = ref.title ? ` title="${ref.title}"` : '';
return ` `;
}
catch (_) {
return ` `;
}
}
return ` `;
}
if (match.startsWith('[') && match.includes('](')) {
const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
if (!m)
return match;
const u = safeUrl(m[2]);
if (!u)
return match;
const t = m[3] ? ` title="${m[3]}"` : '';
const linkText = renderInline(m[1], env);
return `${linkText} `;
}
if (match.startsWith('[') && match.includes('][')) {
const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/);
if (!m)
return match;
const refKey = (m[2] || m[1]).toLowerCase();
if (env.refs && env.refs[refKey]) {
try {
const ref = JSON.parse(env.refs[refKey]);
const u = safeUrl(ref.url);
if (!u)
return escapeHTML(match);
const t = ref.title ? ` title="${ref.title}"` : '';
return `${m[1]} `;
}
catch (_) {
return escapeHTML(match);
}
}
return escapeHTML(match);
}
if (match.startsWith('<http')) {
const url = match.slice(4, -4);
const u = safeUrl(url);
return `${url} `;
}
if (match.startsWith('**'))
return `${match.slice(2, -2)} `;
if (match.startsWith('__'))
return `${match.slice(2, -2)} `;
if (match.startsWith('~~'))
return `${match.slice(2, -2)}`;
if (match.startsWith('=='))
return `${match.slice(2, -2)} `;
if (match.startsWith('*') && !match.startsWith('**') && !match.startsWith('***'))
return `${match.slice(1, -1)} `;
if (match.startsWith('_') && !match.startsWith('__') && !match.startsWith('___'))
return `${match.slice(1, -1)} `;
if (match.startsWith('^') && !match.startsWith('^^'))
return `${match.slice(1, -1)} `;
if (match.startsWith('~') && !match.startsWith('~~'))
return `${match.slice(1, -1)} `;
if (match.startsWith('$') && !match.startsWith('$$'))
return `${escapeHTML(match.slice(1, -1))} `;
if (/^\[\^/.test(match)) {
const fnId = match.slice(2, -1);
return ``;
}
if (match.startsWith('\\') && match.length === 2) {
const escaped = match[1];
if (/[!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~]/.test(escaped))
return escaped;
return match;
}
return match;
});
s = s.replace(/\u0003(\d+)\u0003/g, (m) => placeholderMap.get(m) || m);
return s;
};
/**
* MetonaEditor Plugins — plugin system v2
* @module plugins
* @version 0.2.0
*/
// ============ Plugin Manager ============
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, {
version: plugin.version || '0.0.0', description: plugin.description || '',
depends: plugin.depends || [], priority: plugin.priority || 0,
...plugin, name, 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();
// ============ Topological sort ============
const topologicalSort = (plugins) => {
const map = new Map();
plugins.forEach((p) => map.set(p.name, p));
const inDegree = new Map();
const adj = new Map();
plugins.forEach((p) => { inDegree.set(p.name, 0); adj.set(p.name, []); });
plugins.forEach((p) => {
(p.depends || []).forEach((dep) => {
if (map.has(dep)) {
adj.get(dep).push(p.name);
inDegree.set(p.name, (inDegree.get(p.name) || 0) + 1);
}
else {
console.warn(`MeEditor: plugin "${p.name}" depends on unknown "${dep}"`);
}
});
});
const queue = [];
inDegree.forEach((deg, name) => { if (deg === 0)
queue.push(name); });
const sorted = [];
while (queue.length) {
queue.sort((a, b) => ((map.get(b).priority || 0) - (map.get(a).priority || 0)));
const name = queue.shift();
sorted.push(map.get(name));
(adj.get(name) || []).forEach((n) => { inDegree.set(n, inDegree.get(n) - 1); if (inDegree.get(n) === 0)
queue.push(n); });
}
if (sorted.length !== plugins.length) {
console.warn('MeEditor: circular dependency detected, falling back to original order');
return plugins;
}
return sorted;
};
// ============ Config validation ============
const validateConfig = (schema = {}, config = {}) => {
const errors = [];
const patched = { ...config };
for (const [key, rule] of Object.entries(schema)) {
const val = config[key];
if (rule.required && (val === undefined || val === null)) {
errors.push(`"${key}" is required`);
continue;
}
if (val === undefined && rule.default !== undefined) {
patched[key] = rule.default;
continue;
}
if (val !== undefined && rule.type) {
const actual = Array.isArray(val) ? 'array' : typeof val;
if (actual !== rule.type)
errors.push(`"${key}" expected ${rule.type}, got ${actual}`);
}
if (val !== undefined && rule.enum && !rule.enum.includes(val))
errors.push(`"${key}" must be one of [${rule.enum.join(', ')}]`);
if (val !== undefined && typeof rule.validator === 'function') {
try {
const r = rule.validator(val);
if (r !== true)
errors.push(`"${key}": ${r}`);
}
catch (e) {
errors.push(`"${key}": ${e.message}`);
}
}
}
return { valid: errors.length === 0, errors, patched };
};
// ============ Preset plugins ============
const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>');
const autoSavePlugin = {
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
install(editor, options) {
if (!editor || typeof editor.getValue !== 'function')
return;
const opts = options || this;
const key = opts.key || ('me-draft-' + (editor.id || ''));
const delay = opts.delay || 1000;
const state = { _timer: null };
const save = () => {
if (state._timer) {
clearTimeout(state._timer);
state._timer = null;
}
try {
localStorage.setItem(key, editor.getValue());
if (typeof editor._emit === 'function')
editor._emit('autosave', { key, value: editor.getValue() });
}
catch (e) {
console.warn('MeEditor autoSave:', e);
}
};
const _onInput = () => { if (state._timer)
clearTimeout(state._timer); state._timer = setTimeout(save, delay); };
const _onBlur = save;
const _onSave = save;
editor.on('change', _onInput);
editor.on('blur', _onBlur);
editor.on('save', _onSave);
editor.restoreDraft = () => { try {
const v = localStorage.getItem(key);
if (v != null)
editor.setValue(v);
return v;
}
catch (_) {
return null;
} };
editor.clearDraft = () => { try {
localStorage.removeItem(key);
}
catch (_) { } return editor; };
editor.getDraftKey = () => key;
editor.__autoSaveCleanup = { state, _onInput, _onBlur, _onSave, save };
},
destroy(editor) {
const cleanup = editor.__autoSaveCleanup;
if (cleanup) {
if (cleanup.state._timer) {
clearTimeout(cleanup.state._timer);
cleanup.state._timer = null;
}
if (editor && typeof editor.off === 'function') {
if (cleanup._onInput)
editor.off('change', cleanup._onInput);
if (cleanup._onBlur)
editor.off('blur', cleanup._onBlur);
if (cleanup._onSave)
editor.off('save', cleanup._onSave);
}
delete editor.__autoSaveCleanup;
}
},
};
const exportToolPlugin = {
name: 'exportTool', version: '0.1.0', description: 'Export Markdown/HTML', priority: 50,
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() : '';
// Build minimal embedded CSS for the exported HTML
const embedCSS = opts.embedCSS !== false ? `` : '';
download(filename || `metona-${stamp()}.html`, `\n\n\n \n \n${title} \n${css ? `` : ''}${embedCSS}\n\n\n${body}\n\n`, 'text/html');
return editor;
};
},
destroy() { },
};
const searchReplacePlugin = {
name: 'searchReplace', version: '0.2.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
// Inject style once globally
if (!document.getElementById('me-search-style')) {
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);
}
const state = {
_panel: null,
_regexMode: false,
_cleanup: null,
};
const _updateReplaceVisible = () => {
if (!state._panel)
return;
const show = state._panel.dataset.replace === '1';
const row = state._panel.querySelector('.me-search-replace-row');
if (row)
row.style.display = show ? 'flex' : 'none';
};
const _close = () => {
if (state._cleanup) {
state._cleanup();
state._cleanup = null;
}
state._panel = null;
if (editor && editor.textarea)
editor.textarea.focus();
};
const _open = (showReplace) => {
if (state._panel) {
state._panel.dataset.replace = showReplace ? '1' : '0';
_updateReplaceVisible();
const inp = state._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 = `↑ ↓ .* ×
${t('replace') || 'Replace'} ${t('replaceAll') || 'All'}
`;
editor.el.appendChild(panel);
state._panel = panel;
_updateReplaceVisible();
state._regexMode = false;
const fi = panel.querySelector('.me-search-find');
const ri = panel.querySelector('.me-search-replace');
const ce = panel.querySelector('.me-search-count');
const regexBtn = panel.querySelector('.me-search-regex');
regexBtn.addEventListener('click', () => {
state._regexMode = !state._regexMode;
regexBtn.classList.toggle('me-active', state._regexMode);
lastIdxs = findAll();
});
const findAll = () => {
const q = fi.value;
if (!q) {
ce.textContent = '';
return [];
}
const idxs = [];
let from = 0;
if (state._regexMode) {
try {
const re = new RegExp(q, 'g');
let m;
while ((m = re.exec(editor.textarea.value)) !== null) {
idxs.push(m.index);
if (m[0].length === 0)
re.lastIndex++;
}
}
catch (_) {
ce.textContent = 'err';
return [];
}
}
else {
while (true) {
const idx = editor.textarea.value.indexOf(q, from);
if (idx === -1)
break;
idxs.push(idx);
from = idx + q.length;
}
}
ce.textContent = idxs.length ? `${idxs.length}` : '0';
return idxs;
};
let lastIdxs = [];
const selectAt = (idx, len) => {
editor.textarea.focus();
editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length));
};
const findNext = () => {
lastIdxs = findAll();
if (!lastIdxs.length)
return;
const cur = editor.textarea.selectionEnd;
const qlen = state._regexMode ? (() => { try {
const m = new RegExp(fi.value).exec(editor.textarea.value.substring(cur));
return m ? m[0].length : fi.value.length;
}
catch (_) {
return fi.value.length;
} })() : fi.value.length;
let next = lastIdxs.find((i) => i >= cur);
if (next == null)
next = lastIdxs[0];
selectAt(next, qlen);
};
const findPrev = () => {
lastIdxs = findAll();
if (!lastIdxs.length)
return;
const cur = editor.textarea.selectionStart;
const qlen = state._regexMode ? (() => { try {
const m = new RegExp(fi.value).exec(editor.textarea.value.substring(Math.max(0, cur - 100), cur + 100));
return m ? m[0].length : fi.value.length;
}
catch (_) {
return fi.value.length;
} })() : fi.value.length;
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];
selectAt(prev, qlen);
};
const replaceOne = () => {
const q = fi.value, r = ri.value;
if (!q)
return;
const ta = editor.textarea;
const s = ta.selectionStart, e = ta.selectionEnd;
const matchText = ta.value.substring(s, e);
if (state._regexMode) {
try {
if (new RegExp(q).test(matchText)) {
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
ta.setSelectionRange(s, s + r.length);
}
}
catch (_) { }
}
else if (matchText === q) {
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
ta.setSelectionRange(s, s + 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 = fi.value, r = ri.value;
if (!q)
return;
const ta = editor.textarea;
if (state._regexMode) {
try {
ta.value = ta.value.replace(new RegExp(q, 'g'), r);
}
catch (_) {
return;
}
}
else {
ta.value = ta.value.split(q).join(r);
}
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();
};
fi.addEventListener('input', () => { lastIdxs = findAll(); });
fi.addEventListener('keydown', (e) => { if (e.key === 'Enter') {
e.preventDefault();
e.shiftKey ? findPrev() : findNext();
} if (e.key === 'Escape') {
e.preventDefault();
_close();
} });
ri.addEventListener('keydown', (e) => { if (e.key === 'Enter') {
e.preventDefault();
replaceOne();
} if (e.key === 'Escape') {
e.preventDefault();
_close();
} });
panel.querySelector('.me-search-next').addEventListener('click', findNext);
panel.querySelector('.me-search-prev').addEventListener('click', findPrev);
panel.querySelector('.me-search-close').addEventListener('click', () => _close());
panel.querySelector('.me-search-replace-one').addEventListener('click', replaceOne);
panel.querySelector('.me-search-replace-all').addEventListener('click', replaceAll);
fi.focus();
fi.select();
state._cleanup = () => { if (panel.parentNode)
panel.parentNode.removeChild(panel); };
};
const _onKeydown = (e) => {
const mod = e.ctrlKey || e.metaKey;
if (!mod)
return;
const k = e.key.toLowerCase();
if (k === 'f') {
e.preventDefault();
_open(false);
}
else if (k === 'h') {
e.preventDefault();
_open(true);
}
else if (k === 'escape' && state._panel) {
_close();
}
};
editor.textarea.addEventListener('keydown', _onKeydown);
// Expose for tests
editor.__srState = state;
editor.__srOpen = _open;
editor.__srClose = _close;
editor.__srKeydown = _onKeydown;
},
destroy(editor) {
const state = editor.__srState;
if (state) {
if (state._cleanup) {
state._cleanup();
state._cleanup = null;
}
state._panel = null;
}
const _onKeydown = editor.__srKeydown;
if (_onKeydown && editor && editor.textarea) {
editor.textarea.removeEventListener('keydown', _onKeydown);
}
delete editor.__srState;
delete editor.__srOpen;
delete editor.__srClose;
delete editor.__srKeydown;
},
};
const imagePastePlugin = {
name: 'imagePaste', version: '0.2.0', description: 'Paste image as base64', priority: 60,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const _onPaste = (e) => {
const items = e.clipboardData?.items;
if (!items)
return;
for (const item of items) {
if (item.type?.startsWith('image/')) {
e.preventDefault();
const reader = new FileReader();
reader.onload = () => { editor.insert(`\n`); };
reader.readAsDataURL(item.getAsFile());
break;
}
}
};
editor.textarea.addEventListener('paste', _onPaste);
editor.__ipOnPaste = _onPaste;
},
destroy(editor) {
const _onPaste = editor.__ipOnPaste;
if (_onPaste && editor?.textarea)
editor.textarea.removeEventListener('paste', _onPaste);
delete editor.__ipOnPaste;
},
};
const shortcutHelpPlugin = {
name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
if (!document.getElementById('me-shortcut-style')) {
const s = document.createElement('style');
s.id = 'me-shortcut-style';
s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
document.head.appendChild(s);
}
let _panel = null;
const _close = () => { if (_panel) {
_panel.remove();
_panel = null;
} };
const _open = () => {
if (_panel) {
_close();
return;
}
// i18n-aware shortcut labels
const builtin = [
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'],
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'],
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'],
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'],
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'],
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'],
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'],
['?', t('close') || 'Shortcuts'],
];
let rows = '';
builtin.forEach(([c, d]) => { rows += `${c} ${d} `; });
const overlay = document.createElement('div');
overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `⌨️ ${t('close') || 'Shortcuts'} × `;
overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close'))
_close(); });
document.body.appendChild(overlay);
_panel = overlay;
};
const _onKeydown = (e) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
_open();
}
if (e.key === 'Escape' && _panel)
_close();
};
editor.textarea.addEventListener('keydown', _onKeydown);
editor.__shState = { _panel, _open, _close, _onKeydown };
},
destroy(editor) {
const state = editor.__shState;
if (state) {
if (state._panel) {
state._panel.remove();
}
}
const _onKeydown = editor.__shState?._onKeydown;
if (_onKeydown && editor?.textarea)
editor.textarea.removeEventListener('keydown', _onKeydown);
delete editor.__shState;
},
};
const fileSystemPlugin = {
name: 'fileSystem', version: '0.2.0', description: 'File System Access API', priority: 90,
install(editor) {
if (!editor || typeof editor.getValue !== 'function')
return;
const hasAPI = typeof window !== 'undefined' && typeof window.showOpenFilePicker === 'function';
let _fileHandle = null;
editor.openFile = async (opts = {}) => {
if (!hasAPI) {
editor.toast?.('File System Access API not supported', { type: 'warning' });
return null;
}
try {
const [handle] = await window.showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md', '.txt', '.markdown'] } }], ...opts });
_fileHandle = handle;
const file = await handle.getFile();
const content = await file.text();
editor.setValue(content);
editor._emit?.('fileOpened', { name: file.name, handle });
return { name: file.name, content, handle };
}
catch (e) {
if (e.name !== 'AbortError')
console.error('Open file error:', e);
return null;
}
};
editor.saveFile = async (opts = {}) => {
let handle = _fileHandle;
if (!handle || opts.saveAs) {
if (!hasAPI) {
editor.toast?.('File System Access API not supported', { type: 'warning' });
return false;
}
try {
handle = await window.showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' });
_fileHandle = handle;
}
catch (e) {
if (e.name !== 'AbortError')
console.error('Save error:', e);
return false;
}
}
try {
const w = await handle.createWritable();
await w.write(editor.getValue());
await w.close();
editor._emit?.('fileSaved', { handle });
return true;
}
catch (e) {
_fileHandle = null;
if (!opts.saveAs)
return editor.saveFile({ ...opts, saveAs: true });
console.error('Write error:', e);
return false;
}
};
editor.saveFileAs = (name) => editor.saveFile({ saveAs: true, name });
editor.getFileHandle = () => _fileHandle;
editor.__fsCleanup = { getHandle: () => _fileHandle };
},
destroy(editor) {
if (editor) {
delete editor.openFile;
delete editor.saveFile;
delete editor.saveFileAs;
delete editor.getFileHandle;
}
delete editor.__fsCleanup;
},
};
// ============ Preset plugins table ============
const presetPlugins = {
autoSave: autoSavePlugin, exportTool: exportToolPlugin, searchReplace: searchReplacePlugin,
imagePaste: imagePastePlugin, shortcutHelp: shortcutHelpPlugin, fileSystem: fileSystemPlugin,
};
// ============ Plugin utils ============
const pluginUtils = {
createManager: () => new PluginManager(),
manager: defaultPluginManager,
register: (name, plugin) => defaultPluginManager.register(name, plugin),
unregister: (name) => defaultPluginManager.unregister(name),
get: (name) => defaultPluginManager.get(name),
has: (name) => defaultPluginManager.has(name),
getAll: () => defaultPluginManager.getAll(),
getNames: () => defaultPluginManager.getNames(),
enable: (name) => defaultPluginManager.enable(name),
disable: (name) => defaultPluginManager.disable(name),
isEnabled: (name) => defaultPluginManager.isEnabled(name),
getPreset: (name) => presetPlugins[name] ? { ...presetPlugins[name] } : null,
getAllPresets: () => Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {}),
createPlugin: (config = {}) => {
const result = { name: config.name || 'custom', version: config.version || '0.0.0', description: config.description || '', depends: config.depends || [], priority: config.priority || 0, 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 };
},
topologicalSort, validateConfig,
};
/**
* MetonaEditor Themes — theme system
* @module themes
* @version 0.2.0
*/
// ============ State ============
let globalCurrentTheme = 'auto';
let globalThemeListeners = new Set();
let _sysWatchUnsub = null;
// ============ System theme ============
const getSystemTheme = () => {
if (typeof window === 'undefined')
return 'light';
return prefersDark() ? 'dark' : 'light';
};
const _watchSystem = (callback) => {
if (typeof window === 'undefined' || !window.matchMedia)
return () => { };
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e) => callback(e.matches ? 'dark' : 'light');
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
};
const watchSystemTheme = () => {
if (typeof window === 'undefined' || !window.matchMedia)
return;
if (_sysWatchUnsub) {
_sysWatchUnsub();
_sysWatchUnsub = null;
}
_sysWatchUnsub = _watchSystem((sysTheme) => {
if (globalCurrentTheme === 'auto')
applyTheme('auto');
});
};
const unwatchSystemTheme = () => {
if (_sysWatchUnsub) {
_sysWatchUnsub();
_sysWatchUnsub = null;
}
};
// ============ Theme resolution ============
const resolveTheme = (theme) => {
if (!theme || theme === 'auto')
return getSystemTheme();
return theme;
};
const getThemeConfig = (theme) => {
const resolved = resolveTheme(theme);
const config = THEMES[resolved];
return (config && typeof config === 'object' ? config : THEMES.light);
};
// ============ CSS variables ============
const setThemeVariables = (config, target) => {
if (typeof document === 'undefined' || !config || typeof config !== 'object')
return;
const root = target || document.documentElement;
const vars = {
'--md-bg': config.bg, '--md-text': config.text, '--md-border': config.border,
'--md-shadow': config.shadow, '--md-hover-shadow': config.hoverShadow,
'--md-toolbar-bg': config.toolbarBg || config.bg,
'--md-textarea-bg': config.textareaBg || config.bg,
'--md-preview-bg': config.previewBg || config.bg,
'--md-code-bg': config.codeBg || 'rgba(127,127,127,0.1)',
'--md-code-text': config.codeText || config.text,
'--md-accent': config.accent || '#3b82f6',
'--md-muted': config.muted || '#9ca3af',
};
for (const [k, v] of Object.entries(vars)) {
if (v !== undefined && v !== null)
root.style.setProperty(k, String(v));
}
if (config.progressBg)
root.style.setProperty('--md-progress-bg', config.progressBg);
if (config.closeHoverBg)
root.style.setProperty('--md-close-hover-bg', config.closeHoverBg);
};
const exportCSSVars = (el) => {
const root = el || document.documentElement;
if (typeof document === 'undefined')
return {};
const style = getComputedStyle(root);
const varNames = ['--md-bg', '--md-text', '--md-border', '--md-shadow', '--md-hover-shadow', '--md-toolbar-bg', '--md-textarea-bg', '--md-preview-bg', '--md-code-bg', '--md-code-text', '--md-accent', '--md-muted'];
const result = {};
varNames.forEach((name) => { const val = style.getPropertyValue(name).trim(); if (val)
result[name] = val; });
return result;
};
const getCSSVariable = (name, el) => {
const root = el || document.documentElement;
if (typeof document === 'undefined')
return '';
const fullName = name.startsWith('--') ? name : `--md-${name}`;
return getComputedStyle(root).getPropertyValue(fullName).trim();
};
// ============ Apply theme ============
const applyTheme = (theme) => {
globalCurrentTheme = 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', 'md-theme-warm');
document.documentElement.classList.add(`md-theme-${resolved}`);
setThemeVariables(getThemeConfig(theme));
}
notifyGlobalListeners(theme, resolved);
};
const applyThemeToElement = (theme, target) => {
if (!target || typeof document === 'undefined')
return;
const resolved = resolveTheme(theme);
target.setAttribute('data-md-theme', resolved);
target.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm');
if (resolved !== 'auto')
target.classList.add(`md-theme-${resolved}`);
setThemeVariables(getThemeConfig(theme), target);
};
const switchTheme = (theme) => { applyTheme(theme); saveTheme(theme); };
const toggleTheme = () => { const r = getResolvedTheme(); switchTheme(r === 'dark' ? 'light' : 'dark'); };
const resetToAuto = () => { switchTheme('auto'); };
const getCurrentTheme = () => globalCurrentTheme;
const getResolvedTheme = () => resolveTheme(globalCurrentTheme);
// ============ Persistence ============
const saveTheme = (theme) => {
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem('metona-editor-theme', theme);
}
catch (_) { }
}
};
const loadTheme = () => {
if (typeof localStorage !== 'undefined') {
try {
return localStorage.getItem('metona-editor-theme') || 'auto';
}
catch (_) { }
}
return 'auto';
};
// ============ Listeners ============
const notifyGlobalListeners = (theme, resolved) => {
globalThemeListeners.forEach((fn) => { try {
fn(theme, resolved);
}
catch (e) {
console.error('Theme listener error:', e);
} });
};
const addThemeListener = (fn) => {
globalThemeListeners.add(fn);
return () => { globalThemeListeners.delete(fn); };
};
const removeThemeListener = (fn) => { globalThemeListeners.delete(fn); };
const clearThemeListeners = () => { globalThemeListeners.clear(); };
// ============ System watch ============
const startSystemWatch = () => { watchSystemTheme(); };
// ============ Init ============
const initTheme = () => { applyTheme(loadTheme()); startSystemWatch(); };
// ============ External follow ============
const followExternalTheme = (options, onThemeChange) => {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined')
return () => { };
const { element = document.documentElement, attr = 'data-theme', classMap = null, callback = null } = options;
const detect = () => {
if (typeof callback === 'function')
return callback(element);
const attrVal = element.getAttribute(attr);
if (attrVal)
return attrVal;
if (classMap) {
for (const [cls, theme] of Object.entries(classMap)) {
if (element.classList.contains(cls))
return theme;
}
}
const mdTheme = element.getAttribute('data-md-theme');
if (mdTheme)
return mdTheme;
return null;
};
let lastTheme = detect();
if (lastTheme)
onThemeChange(lastTheme);
const observer = new MutationObserver(() => { const current = detect(); if (current && current !== lastTheme) {
lastTheme = current;
onThemeChange(current);
} });
observer.observe(element, { attributes: true, attributeFilter: [attr, 'class', 'data-md-theme'] });
return () => observer.disconnect();
};
const adoptFromParent = (container, onThemeDetected) => {
if (typeof document === 'undefined' || !container)
return () => { };
const detect = () => {
let el = container.parentElement;
while (el) {
const t = el.getAttribute('data-md-theme');
if (t)
return t;
if (el.classList.contains('theme-dark') || el.classList.contains('dark'))
return 'dark';
if (el.classList.contains('theme-light') || el.classList.contains('light'))
return 'light';
el = el.parentElement;
}
if (container.parentElement) {
const bg = getComputedStyle(container.parentElement).getPropertyValue('--md-bg').trim() || getComputedStyle(container.parentElement).backgroundColor;
if (bg) {
const rgb = bg.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const brightness = (parseInt(rgb[0]) * 299 + parseInt(rgb[1]) * 587 + parseInt(rgb[2]) * 114) / 1000;
return brightness < 128 ? 'dark' : 'light';
}
}
}
return null;
};
const detected = detect();
if (detected)
onThemeDetected(detected);
const observer = new MutationObserver(() => { const d = detect(); if (d)
onThemeDetected(d); });
let target = container.parentElement;
while (target) {
observer.observe(target, { attributes: true, attributeFilter: ['class', 'data-md-theme', 'data-theme'] });
target = target.parentElement;
if (target === document.documentElement)
break;
}
return () => observer.disconnect();
};
const watch = (source, onChange) => {
if (typeof source === 'function') {
const tick = () => { try {
const t = source();
if (t)
onChange(t);
}
catch (_) { } };
tick();
const id = setInterval(tick, 500);
return () => clearInterval(id);
}
if (typeof source === 'string' && typeof document !== 'undefined') {
const el = document.querySelector(source);
if (!el)
return () => { };
return followExternalTheme({ element: el, attr: 'data-theme' }, onChange);
}
return () => { };
};
// ============ Custom theme registration ============
const registerTheme = (name, config = {}) => {
const baseName = config.extends || 'light';
const base = (THEMES[baseName] || 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 || base.toolbarBg || base.bg,
textareaBg: config.textareaBg || base.textareaBg || base.bg,
previewBg: config.previewBg || base.previewBg || base.bg,
codeBg: config.codeBg || base.codeBg, codeText: config.codeText || base.codeText,
accent: config.accent || base.accent, muted: config.muted || base.muted,
progressBg: config.progressBg || base.progressBg,
closeHoverBg: config.closeHoverBg || base.closeHoverBg,
};
};
const unregisterTheme = (name) => {
if (name === 'light' || name === 'dark' || name === 'auto' || name === 'warm') {
console.warn('Cannot unregister built-in theme:', name);
return;
}
delete THEMES[name];
};
const getAllThemes = () => ({ ...THEMES });
const getThemeNames = () => Object.keys(THEMES);
const hasTheme = (name) => name in THEMES;
// ============ Instance theme ============
const createInstanceTheme = (editor) => {
let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto';
const apply = (theme) => {
instanceTheme = theme;
const resolved = resolveTheme(theme);
const config = getThemeConfig(theme);
if (editor.el) {
['me-theme-light', 'me-theme-dark', 'me-theme-warm'].forEach((c) => editor.el.classList.remove(c));
if (resolved !== 'auto')
editor.el.classList.add(`me-theme-${resolved}`);
editor.el.setAttribute('data-md-theme', resolved);
setThemeVariables(config, editor.el);
}
if (typeof editor._emit === 'function')
editor._emit('themeChange', { theme, resolved, config });
if (typeof editor.refresh === 'function')
editor.refresh();
return instanceTheme;
};
const get = () => instanceTheme;
const set = (theme) => apply(theme);
const toggle = () => { const r = resolveTheme(instanceTheme); return apply(r === 'dark' ? 'light' : 'dark'); };
const getResolved = () => resolveTheme(instanceTheme);
const getConfig = () => getThemeConfig(instanceTheme);
const getVars = () => editor.el ? exportCSSVars(editor.el) : {};
const syncWithElement = (element, opts = {}) => followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected));
const adopt = () => editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => { };
const watchExternal = (source) => watch(source, (theme) => apply(theme));
apply(instanceTheme);
return { apply, get, set, toggle, getResolved, getConfig, getVars, syncWithElement, adopt, watch: watchExternal };
};
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 },
};
// Ensure built-in themes in THEMES
for (const [name, theme] of Object.entries(presetThemes)) {
if (name !== 'auto' && theme.config !== 'auto')
THEMES[name] = theme.config;
}
const themeUtils = {
getSystemTheme, resolveTheme, getThemeConfig, watchSystemTheme, unwatchSystemTheme,
applyTheme, getCurrentTheme, getResolvedTheme, switchTheme, toggleTheme, resetToAuto, initTheme,
saveTheme, loadTheme, addThemeListener, removeThemeListener, clearThemeListeners,
registerTheme, unregisterTheme, getAllThemes, getThemeNames, hasTheme,
setThemeVariables, exportCSSVars, getCSSVariable, applyThemeToElement,
followExternalTheme, adoptFromParent, watch, createInstanceTheme,
};
/**
* MetonaEditor Core — MarkdownEditor class (TypeScript)
* @module core
* @version 0.2.0
*/
const MODES = ['edit', 'split', 'preview'];
const TOAST_ICONS = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' };
const BRACKET_PAIRS = {
'(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`', '*': '*', '_': '_',
};
const resolveThemeName = (theme) => {
if (theme && theme !== 'auto')
return theme;
return resolveTheme('auto');
};
class MarkdownEditor {
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);
} });
}
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 = { ...DEFAULTS.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._lastRenderedValue = null;
this._shortcuts = [];
this._contextMenuItems = [];
this._customActions = {};
this._outlineTimer = null;
this._zenMode = false;
this._wordWrap = true;
this._syncing = false;
this._zenMouseHandler = null;
this._floatingToolbar = null;
this._selectionTimer = null;
this._floatingEnabled = this.config.floatingToolbar !== 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._themeCtx = createInstanceTheme(this);
this._i18nCtx = createInstanceI18n(this);
if (options.theme && typeof document !== 'undefined') {
document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme));
}
const resolvedThemeConfig = getThemeConfig(this.config.theme);
setThemeVariables(resolvedThemeConfig, this.el);
this._buildToolbar();
this._updateModeButtons();
this._bindToolbarKeyboard();
this._initAriaLive();
this._bindEvents();
this._bindContextMenu();
this._trackOutlineScroll();
if (this.config.floatingToolbar !== false)
this._initFloatingToolbar();
this.textarea.value = this._value;
this._pushHistory();
this._render();
this._updateWordCount();
if (Array.isArray(this.config.plugins)) {
const plugins = this.config.plugins.map((p) => {
if (typeof p === 'string')
return { ...presetPlugins[p], _isStringRef: true };
return p;
}).filter(Boolean);
const sorted = topologicalSort(plugins);
sorted.forEach((p) => this.use(p));
}
if (this.config.autofocus && !this.config.readOnly)
this.focus();
if (typeof this.config.onCreate === 'function') {
try {
this.config.onCreate(this);
}
catch (e) {
console.error('onCreate error:', e);
}
}
MarkdownEditor.trigger('afterCreate', this);
}
_buildDOM() {
const wrapper = document.createElement('div');
wrapper.className = `me-wrapper me-theme-${resolveThemeName(this.config.theme)}`;
if (this.config.readOnly)
wrapper.classList.add('me-readonly');
wrapper.dataset.id = this.id;
wrapper.setAttribute('role', 'application');
wrapper.setAttribute('aria-label', t('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]) => { if (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 editorInner = document.createElement('div');
editorInner.className = 'me-editor-inner';
const gutter = document.createElement('div');
gutter.className = 'me-gutter';
if (!this.config.lineNumbers)
gutter.style.display = 'none';
editorInner.appendChild(gutter);
const textarea = document.createElement('textarea');
textarea.className = 'me-textarea';
textarea.spellcheck = !!this.config.spellcheck;
textarea.readOnly = !!this.config.readOnly;
textarea.style.tabSize = String(this.config.tabSize || 2);
textarea.placeholder = this.config.placeholder || t('placeholder');
textarea.setAttribute('aria-label', t('edit'));
editorInner.appendChild(textarea);
editorPane.appendChild(editorInner);
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', t('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.editorInner = editorInner;
this.gutter = gutter;
this.previewPane = previewPane;
this.dividerEl = divider;
this.textarea = textarea;
this.previewEl = preview;
this.statusEl = statusbar;
// Restore divider position if saved
this._restoreDividerPosition();
}
_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 = t(item) || item;
btn.title = label;
btn.setAttribute('aria-label', label);
btn.innerHTML = ICONS[item] || `${escapeHTML(item)} `;
return btn;
}
_bindEvents() {
const ta = this.textarea;
const onInput = () => {
const oldValue = this._value;
this._value = ta.value;
MarkdownEditor.trigger('beforeChange', this);
this._emit('beforeChange', oldValue, this._value);
this._scheduleRender();
this._scheduleHistory();
this._updateWordCount();
this._renderGutter();
this._updateOutline();
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);
}
}
this._emit('afterChange', this._value);
MarkdownEditor.trigger('afterChange', this);
};
ta.addEventListener('input', onInput);
const onKeydown = (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey)
this._handleSmartEnter(e);
this._handleKeydown(e);
};
ta.addEventListener('keydown', onKeydown);
const onKeypress = (e) => this._handleBracketAutoClose(e);
ta.addEventListener('keypress', onKeypress);
const onCursorActivity = () => { this._updateCurrentLine(); };
ta.addEventListener('click', onCursorActivity);
ta.addEventListener('keyup', onCursorActivity);
const onScroll = () => { this._syncScroll(); };
ta.addEventListener('scroll', onScroll);
const onPreviewScroll = () => {
if (!this.config.syncScroll || this._mode !== 'split')
return;
if (this._syncing)
return;
this._syncing = true;
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
if (pmax <= 0) {
this._syncing = false;
return;
}
const ratio = this.previewPane.scrollTop / pmax;
const tmax = ta.scrollHeight - ta.clientHeight;
ta.scrollTop = ratio * tmax;
if (this.gutter) {
const gmax = this.gutter.scrollHeight - this.gutter.clientHeight;
this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ratio * tmax;
}
requestAnimationFrame(() => { this._syncing = false; });
};
this.previewPane.addEventListener('scroll', onPreviewScroll);
const onFocus = () => {
this._emit('focus');
if (typeof this.config.onFocus === 'function') {
try {
this.config.onFocus(this);
}
catch (e) {
console.error(e);
}
}
};
const onBlur = () => {
this._emit('blur');
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 onPreviewClick = (e) => {
const a = e.target.closest('a');
if (!a)
return;
const href = a.getAttribute('href');
if (!href || href.startsWith('#'))
return;
e.preventDefault();
this._emit('linkClick', { href, text: a.textContent });
if (typeof this.config.onLinkClick === 'function') {
try {
this.config.onLinkClick(href, a.textContent, this);
}
catch (_) { }
return;
}
window.open(href, '_blank', 'noopener');
};
this.previewEl.addEventListener('click', onPreviewClick);
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._bindDragDrop();
this._cleanups.push(() => {
ta.removeEventListener('input', onInput);
ta.removeEventListener('keydown', onKeydown);
ta.removeEventListener('keypress', onKeypress);
ta.removeEventListener('scroll', onScroll);
this.previewPane.removeEventListener('scroll', onPreviewScroll);
ta.removeEventListener('click', onCursorActivity);
ta.removeEventListener('keyup', onCursorActivity);
ta.removeEventListener('focus', onFocus);
ta.removeEventListener('blur', onBlur);
this.toolbarEl.removeEventListener('click', onToolbarClick);
this.dividerEl.removeEventListener('pointerdown', onDividerDown);
});
}
_handleKeydown(e) {
if (e.key === 'Tab') {
e.preventDefault();
this._handleTab(e.shiftKey);
return;
}
if (this._shortcuts.length) {
for (const sc of this._shortcuts) {
const ctrlOk = sc.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
const shiftOk = sc.shift ? e.shiftKey : !e.shiftKey;
const altOk = sc.alt ? e.altKey : !e.altKey;
if (e.key.toLowerCase() === sc.key && ctrlOk && shiftOk && altOk) {
e.preventDefault();
if (typeof sc.handler === 'string')
this.exec(sc.handler);
else if (typeof sc.handler === 'function') {
try {
sc.handler(this);
}
catch (err) {
console.error('Shortcut error:', err);
}
}
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;
if (totalWidth <= 0)
return;
const divider = this.dividerEl;
const onMove = (ev) => {
const dx = ev.clientX - startX;
let newW = editorWidth + dx;
const min = 80;
const max = totalWidth - 80 - divider.offsetWidth;
newW = Math.max(min, Math.min(max, newW));
const pct = (newW / 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);
this._saveDividerPosition();
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
_saveDividerPosition() {
try {
const epFlex = this.editorPane.style.flex;
if (epFlex)
localStorage.setItem('metona-editor-divider', epFlex);
}
catch (_) { }
}
_restoreDividerPosition() {
try {
const saved = localStorage.getItem('metona-editor-divider');
if (saved && this._mode === 'split') {
this.editorPane.style.flex = saved;
this.previewPane.style.flex = '1 1 auto';
}
}
catch (_) { }
}
_scheduleRender() { if (this._renderRaf)
return; this._renderRaf = requestAnimationFrame(() => { this._renderRaf = null; this._render(); }); }
_render() {
if (this._mode === 'edit')
return;
if (this._lastRenderedValue === this._value)
return;
this._lastRenderedValue = this._value;
MarkdownEditor.trigger('beforeRender', this);
this._emit('beforeRender', this);
let html;
try {
html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() });
}
catch (err) {
console.error('MeEditor render error:', err);
html = `${t('renderError')}: ${escapeHTML(err.message)}
`;
}
if (typeof this.config.sanitize === 'function') {
try {
html = this.config.sanitize(html);
}
catch (e) {
console.error('sanitize error:', e);
}
}
this.previewEl.innerHTML = html;
this._renderGutter();
MarkdownEditor.trigger('afterRender', this);
this._emit('afterRender', this);
}
_renderGutter() {
if (!this.config.lineNumbers || !this.gutter)
return;
const lines = this._value ? this._value.split('\n').length : 1;
const current = this.gutter.children.length;
if (current === lines)
return;
if (current < lines) {
// Add new line numbers
let h = '';
for (let i = current + 1; i <= lines; i++)
h += `${i}
`;
this.gutter.insertAdjacentHTML('beforeend', h);
}
else {
// Remove excess line numbers
while (this.gutter.children.length > lines) {
this.gutter.removeChild(this.gutter.lastChild);
}
}
}
_updateCurrentLine() {
if (!this.gutter)
return;
const pos = this.textarea.selectionStart;
const lineNum = this._value.substring(0, pos).split('\n').length;
const prev = this.gutter.querySelector('.me-gutter-active');
if (prev)
prev.classList.remove('me-gutter-active');
const cur = this.gutter.children[lineNum - 1];
if (cur)
cur.classList.add('me-gutter-active');
}
_handleSmartEnter(e) {
const ta = this.textarea;
const start = ta.selectionStart;
const val = ta.value;
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
const line = val.slice(lineStart, start);
const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s(.*)/);
if (listMatch) {
const indent = listMatch[1];
const marker = listMatch[2];
const content = listMatch[3];
if (!content.trim()) {
e.preventDefault();
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value;
this._pushHistory();
this._renderGutter();
this._emit('change', this._value);
return;
}
let nextMarker = marker;
if (/^\d+\.$/.test(marker)) {
const num = parseInt(marker, 10);
if (!isNaN(num))
nextMarker = (num + 1) + '.';
}
e.preventDefault();
const insert = '\n' + indent + nextMarker + ' ';
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length;
this._value = ta.value;
this._pushHistory();
this._renderGutter();
this._emit('change', this._value);
return;
}
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
if (quoteMatch) {
const prefix = quoteMatch[1];
const content = quoteMatch[2];
if (!content.trim()) {
e.preventDefault();
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value;
this._pushHistory();
this._renderGutter();
this._emit('change', this._value);
return;
}
e.preventDefault();
const insert = '\n' + prefix;
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
ta.selectionStart = ta.selectionEnd = start + insert.length;
this._value = ta.value;
this._pushHistory();
this._renderGutter();
this._emit('change', this._value);
}
}
_handleBracketAutoClose(e) {
if (!this.config.autoBrackets)
return;
const ta = this.textarea;
const key = e.key;
const close = BRACKET_PAIRS[key];
if (!close)
return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
if (start !== end) {
e.preventDefault();
const selected = ta.value.slice(start, end);
const wrap = key + selected + close;
ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end);
ta.selectionStart = start + 1;
ta.selectionEnd = start + 1 + selected.length;
this._value = ta.value;
this._pushHistory();
this._emit('change', this._value);
return;
}
const nextChar = ta.value[start] || '';
if (key === close) {
if (nextChar === close) {
e.preventDefault();
ta.selectionStart = ta.selectionEnd = start + 1;
return;
}
if (/\w/.test(nextChar))
return;
}
e.preventDefault();
const pair = key + close;
ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 1;
this._value = ta.value;
this._pushHistory();
this._emit('change', this._value);
}
_bindDragDrop() {
const ta = this.textarea;
if (!ta)
return;
const onDragOver = (e) => { e.preventDefault(); if (e.dataTransfer)
e.dataTransfer.dropEffect = 'copy'; };
const onDrop = (e) => {
e.preventDefault();
const files = e.dataTransfer?.files;
if (files && files.length) {
Array.from(files).forEach((file) => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = () => { this.insert(`\n`); };
reader.readAsDataURL(file);
}
else if (file.type.startsWith('text/') || /\.(md|txt|js|ts|json|css|html|xml|yml|yaml)$/i.test(file.name)) {
const reader = new FileReader();
reader.onload = () => this.insert(reader.result);
reader.readAsText(file);
}
});
return;
}
const text = e.dataTransfer?.getData('text/plain');
if (text)
this.insert(text);
};
ta.addEventListener('dragover', onDragOver);
ta.addEventListener('drop', onDrop);
this._cleanups.push(() => { ta.removeEventListener('dragover', onDragOver); ta.removeEventListener('drop', onDrop); });
}
_buildOutline() {
if (!this.config.outline || !this.previewEl)
return;
const old = this.el.querySelector('.me-outline');
if (old)
old.remove();
const headings = [];
const headingRe = /]*>(.+?)<\/h\1>/gi;
let m;
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') });
}
if (!headings.length)
return;
const panel = document.createElement('div');
panel.className = 'me-outline';
panel.innerHTML = `${t('outline') || '大纲'}
`;
const buildTree = (items, minLevel) => {
let h = '';
let i = 0;
while (i < items.length) {
const item = items[i];
if (item.level < minLevel)
break;
h += `${escapeHTML(item.text)} `;
const sub = [];
let j = i + 1;
while (j < items.length && items[j].level > item.level) {
sub.push(items[j]);
j++;
}
if (sub.length)
h += buildTree(sub, item.level + 1);
h += ' ';
i = j > i + 1 ? j : i + 1;
}
return h + ' ';
};
panel.innerHTML += buildTree(headings, 1);
this.el.appendChild(panel);
panel.addEventListener('click', (e) => {
const a = e.target.closest('a');
if (!a)
return;
e.preventDefault();
const id = a.getAttribute('href').slice(1);
const target = this.previewEl.querySelector('#' + CSS.escape(id));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
const idx = this._value.indexOf(target.textContent || '');
if (idx !== -1) {
this.textarea.focus();
this.textarea.setSelectionRange(idx, idx);
}
}
});
}
_updateOutline() { if (!this.config.outline)
return; if (this._outlineTimer)
clearTimeout(this._outlineTimer); this._outlineTimer = setTimeout(() => this._buildOutline(), 300); }
_trackOutlineScroll() {
if (!this.config.outline || !this.previewPane)
return;
let ticking = false;
const onScroll = () => {
if (ticking)
return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
if (!this.el)
return;
const panel = this.el.querySelector('.me-outline');
if (!panel)
return;
const headings = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
let activeId = '';
const scrollTop = this.previewPane.scrollTop + 80; // offset for better UX
headings.forEach((h) => {
if (h.offsetTop <= scrollTop) {
activeId = h.id;
}
});
panel.querySelectorAll('a').forEach((a) => {
a.classList.toggle('me-outline-active', a.getAttribute('href') === '#' + activeId);
});
});
};
this.previewPane.addEventListener('scroll', onScroll, { passive: true });
this._cleanups.push(() => this.previewPane.removeEventListener('scroll', onScroll));
}
_scheduleHistory() { if (this._historyTimer)
clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 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; }
canUndo() { return this._historyIndex > 0; }
canRedo() { return this._historyIndex < this._history.length - 1; }
_applyHistory() {
this._value = this._history[this._historyIndex];
this.textarea.value = this._value;
this._render();
this._renderGutter();
this._updateWordCount();
if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop;
if (this.previewPane && this.config.syncScroll)
this.previewPane.scrollTop = 0;
this._emit('change', this._value);
if (typeof this.config.onChange === 'function') {
try {
this.config.onChange(this._value, this);
}
catch (e) {
console.error(e);
}
}
}
exec(action, ...args) {
const actions = {
bold: () => this._wrapSelection('**', '**'), italic: () => this._wrapSelection('*', '*'),
strikethrough: () => this._wrapSelection('~~', '~~'), underline: () => this._wrapSelection('', ' '),
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(),
zen: () => this.toggleZen(), wordwrap: () => this.toggleWordWrap(),
formatTable: () => this._formatTable(),
};
const fn = actions[action];
if (fn) {
fn.apply(this, args);
return this;
}
if (this._customActions && typeof this._customActions[action] === 'function') {
try {
this._customActions[action].apply(this, args);
}
catch (e) {
console.error('custom action error:', e);
}
}
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 || '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) || t('link') || 'link'; const url = 'https://'; const insert = `[${sel}](${url})`; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + 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) || t('image') || 'image'; const url = 'https://'; const insert = ``; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + 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) => `${t('tableCols') || '列'}${i + 1}`).join(' | '); const sep = Array.from({ length: cols }, () => '---').join(' | '); let md = `| ${header} |\n| ${sep} |\n`; for (let r = 1; r < rows; r++)
md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`; this._insertBlock('\n' + md); }
_formatTable() {
const ta = this.textarea;
const start = ta.selectionStart;
// Find table boundaries around cursor
const before = ta.value.substring(0, start);
const after = ta.value.substring(start);
const blockStart = before.lastIndexOf('\n\n');
const blockEnd = after.indexOf('\n\n');
const tableStart = blockStart === -1 ? 0 : blockStart + 2;
const tableEnd = blockEnd === -1 ? ta.value.length : start + blockEnd;
const tableText = ta.value.substring(tableStart, tableEnd);
const lines = tableText.split('\n').filter((l) => l.includes('|'));
if (lines.length < 2)
return; // need at least header + separator
// Parse columns
const splitRow = (r) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
const allCells = lines.map(splitRow);
const colCount = Math.max(...allCells.map((c) => c.length));
// Calculate max width per column
const colWidths = Array(colCount).fill(3);
allCells.forEach((cells) => {
cells.forEach((cell, ci) => {
colWidths[ci] = Math.max(colWidths[ci], cell.trim().length);
});
});
// Rebuild table
const pad = (s, w) => { const padLen = w - s.length; return s + ' '.repeat(Math.max(0, padLen)); };
const formatted = allCells.map((cells) => {
const padded = [];
for (let ci = 0; ci < colCount; ci++) {
padded.push(pad((cells[ci] || '').trim(), colWidths[ci]));
}
return '| ' + padded.join(' | ') + ' |';
});
// Replace in value
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
}
setMode(mode) { if (!MODES.includes(mode) || mode === this._mode)
return this; const taS = this.textarea.scrollTop; const pvS = this.previewPane.scrollTop; this._mode = mode; this.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit')
this._render(); this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${t(mode) || mode} 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() {
const isPreview = this._mode === 'preview';
const isReadonly = this.config.readOnly;
this.toolbarEl.querySelectorAll('.me-btn').forEach((b) => {
const btn = b;
const mode = btn.dataset.mode;
const action = btn.dataset.action;
if (mode) {
btn.classList.toggle('me-active', mode === this._mode);
btn.disabled = false;
return;
}
if (action === 'fullscreen') {
btn.disabled = false;
return;
}
if (isReadonly) {
btn.disabled = !(action === 'undo' || action === 'redo');
return;
}
if (isPreview) {
btn.disabled = !(action === 'undo' || action === 'redo');
return;
}
btn.disabled = false;
});
}
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 ? (t('fullscreenExit') || '退出全屏') : (t('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; }
toggleZen() {
this._zenMode = !this._zenMode;
if (this.el)
this.el.classList.toggle('me-zen', this._zenMode);
if (this._zenMode && this.toolbarEl) {
this._zenMouseHandler = (e) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; };
this.toolbarEl.style.transition = 'opacity 0.2s';
this.toolbarEl.style.opacity = '0';
this.toolbarEl.style.pointerEvents = 'none';
document.addEventListener('mousemove', this._zenMouseHandler);
if (this.statusEl)
this.statusEl.style.display = 'none';
}
else {
if (this._zenMouseHandler) {
document.removeEventListener('mousemove', this._zenMouseHandler);
this._zenMouseHandler = null;
}
if (this.toolbarEl) {
this.toolbarEl.style.opacity = '';
this.toolbarEl.style.pointerEvents = '';
this.toolbarEl.style.transition = '';
}
if (this.statusEl)
this.statusEl.style.display = '';
}
this._emit('zenChange', this._zenMode);
return this;
}
isZen() { return this._zenMode; }
toggleWordWrap() { this._wordWrap = !this._wordWrap; if (this.textarea)
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
setWordWrap(on) { this._wordWrap = !!on; if (this.textarea)
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
isWordWrap() { return this._wordWrap; }
_initFloatingToolbar() {
if (typeof document === 'undefined')
return;
// Inject floating toolbar CSS once globally
if (!document.getElementById('me-float-style')) {
const style = document.createElement('style');
style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;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);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`;
document.head.appendChild(style);
}
if (!this._floatingEnabled)
return;
const ta = this.textarea;
const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled)
return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
if (start === end) {
this._hideFloatingToolbar();
return;
}
if (!this._floatingToolbar || !this._floatingToolbar.parentNode)
this._buildFloatingToolbar();
// Position the toolbar near the selection
const rect = ta.getBoundingClientRect();
const textBefore = ta.value.substring(0, start);
const lines = textBefore.split('\n');
const currentLine = lines.length - 1;
const lineHeight = parseInt(getComputedStyle(ta).lineHeight, 10) || 22;
const paddingTop = parseInt(getComputedStyle(ta).paddingTop, 10) || 16;
const scrollOffset = ta.scrollTop;
// Line top relative to textarea content area
const lineTop = rect.top + paddingTop + currentLine * lineHeight - scrollOffset;
const toolbarHeight = 36;
const gap = 4;
// Try above first
let top = lineTop - toolbarHeight - gap;
// If not enough room above, place below the selection
if (top < rect.top + gap) {
top = lineTop + lineHeight + gap;
}
// Center horizontally near the middle of selection
let left = rect.left + (ta.selectionDirection !== 'backward' ? (ta.selectionEnd - ta.selectionStart) / 2 : 0) * 8 + 10;
if (left < rect.left)
left = rect.left + 4;
const maxLeft = rect.right - 200;
if (left > maxLeft)
left = maxLeft;
this._floatingToolbar.style.top = top + 'px';
this._floatingToolbar.style.left = left + 'px';
this._floatingToolbar.classList.add('me-visible');
};
const hide = () => { this._hideFloatingToolbar(); };
let lastSelStart = ta.selectionStart;
let lastSelEnd = ta.selectionEnd;
this._selectionTimer = setInterval(() => {
if (this._destroyed || !document.body.contains(ta) || !this._floatingEnabled)
return;
const s = ta.selectionStart;
const e = ta.selectionEnd;
if (s !== lastSelStart || e !== lastSelEnd) {
lastSelStart = s;
lastSelEnd = e;
const text = this._value.slice(s, e);
this._emit('selectionChange', { start: s, end: e, text });
}
}, 500);
this._cleanups.push(() => { if (this._selectionTimer) {
clearInterval(this._selectionTimer);
this._selectionTimer = null;
} });
ta.addEventListener('mouseup', () => { if (this._floatingEnabled)
setTimeout(show, 0); });
ta.addEventListener('keyup', () => {
if (!this._floatingEnabled)
return;
if (ta.selectionStart !== ta.selectionEnd)
setTimeout(show, 0);
else
setTimeout(hide, 0);
this._emit('cursorMove', this.getCursorPosition());
});
ta.addEventListener('blur', () => setTimeout(hide, 300));
ta.addEventListener('click', () => { if (ta.selectionStart === ta.selectionEnd)
setTimeout(hide, 0); });
}
toggleFloatingToolbar() {
this._floatingEnabled = !this._floatingEnabled;
if (this._floatingEnabled) {
// Re-enable: restart the interval
if (!this._selectionTimer) {
const ta = this.textarea;
this._selectionTimer = setInterval(() => {
if (this._destroyed || !this._floatingEnabled)
return;
ta.selectionStart;
ta.selectionEnd;
}, 500);
this._cleanups.push(() => { if (this._selectionTimer) {
clearInterval(this._selectionTimer);
this._selectionTimer = null;
} });
}
}
else {
// Disable: clear interval and hide toolbar
if (this._selectionTimer) {
clearInterval(this._selectionTimer);
this._selectionTimer = null;
}
if (this._floatingToolbar) {
if (this._floatingToolbar.parentNode)
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
}
}
return this;
}
isFloatingToolbar() { return this._floatingEnabled; }
_buildFloatingToolbar() {
const bar = document.createElement('div');
bar.className = 'me-float-toolbar';
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => {
const btn = this._createBtn(action);
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
this.exec(action);
// Keep selection after exec
setTimeout(() => this.textarea.focus(), 0);
});
bar.appendChild(btn);
});
this.editorPane.appendChild(bar);
this._floatingToolbar = bar;
}
_hideFloatingToolbar() {
if (this._floatingToolbar) {
this._floatingToolbar.classList.remove('me-visible');
}
}
_bindToolbarKeyboard() {
if (!this.toolbarEl)
return;
const onKeydown = (e) => {
const btns = [...this.toolbarEl.querySelectorAll('.me-btn:not(:disabled)')];
if (!btns.length)
return;
const idx = btns.indexOf(document.activeElement);
if (idx === -1)
return;
if (e.key === 'ArrowRight') {
e.preventDefault();
btns[(idx + 1) % btns.length].focus();
}
else if (e.key === 'ArrowLeft') {
e.preventDefault();
btns[(idx - 1 + btns.length) % btns.length].focus();
}
else if (e.key === 'Home') {
e.preventDefault();
btns[0].focus();
}
else if (e.key === 'End') {
e.preventDefault();
btns[btns.length - 1].focus();
}
};
this.toolbarEl.addEventListener('keydown', onKeydown);
this._cleanups.push(() => this.toolbarEl.removeEventListener('keydown', onKeydown));
}
_initAriaLive() { if (!this.el)
return; const live = document.createElement('div'); live.className = 'me-sr-only'; live.setAttribute('aria-live', 'polite'); live.setAttribute('aria-atomic', 'true'); this.el.appendChild(live); this._ariaLive = live; }
_announce(msg) { if (this._ariaLive) {
this._ariaLive.textContent = '';
requestAnimationFrame(() => { this._ariaLive.textContent = msg; });
} }
_syncScroll() {
if (this._syncing)
return;
this._syncing = true;
const ta = this.textarea;
if (!ta) {
this._syncing = false;
return;
}
const tmax = ta.scrollHeight - ta.clientHeight;
if (tmax <= 0) {
this._syncing = false;
return;
}
const ratio = ta.scrollTop / tmax;
if (this.gutter) {
const gmax = this.gutter.scrollHeight - this.gutter.clientHeight;
this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ta.scrollTop;
}
if (this.config.syncScroll && this._mode === 'split' && this.previewPane) {
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
if (pmax > 0)
this.previewPane.scrollTop = ratio * pmax;
}
requestAnimationFrame(() => { this._syncing = false; });
}
_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));
this.statusEl.textContent = [`${t('characters')}: ${chars}`, `${t('words')}: ${words}`, `${t('lines')}: ${lines}`, `${t('readingTime')}: ${readingMin} ${t('minutes')}`].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)), }; }
getSelectedText() {
if (!this.textarea)
return '';
return this._value.slice(this.textarea.selectionStart, this.textarea.selectionEnd);
}
getCursorPosition() {
if (!this.textarea)
return { line: 1, column: 0 };
const pos = this.textarea.selectionStart;
const before = this._value.substring(0, pos);
const line = before.split('\n').length;
const column = pos - before.lastIndexOf('\n') - 1;
return { line, column: column < 0 ? 0 : column };
}
setCursorPosition(line, column = 0) {
if (!this.textarea)
return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
let pos = 0;
for (let i = 0; i < clampedLine - 1; i++)
pos += lines[i].length + 1;
pos += Math.min(column, lines[clampedLine - 1]?.length || 0);
this.textarea.focus();
this.textarea.setSelectionRange(pos, pos);
return this;
}
scrollToLine(line) {
if (!this.textarea)
return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
const lineHeight = 22; // approximate
this.textarea.scrollTop = (clampedLine - 1) * lineHeight;
if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop;
return this;
}
selectLine(line) {
if (!this.textarea)
return this;
const lines = this._value.split('\n');
const clampedLine = Math.max(1, Math.min(line, lines.length));
let startPos = 0;
for (let i = 0; i < clampedLine - 1; i++)
startPos += lines[i].length + 1;
const endPos = startPos + lines[clampedLine - 1].length;
this.textarea.focus();
this.textarea.setSelectionRange(startPos, endPos);
return this;
}
selectAll() { if (this.textarea) {
this.textarea.focus();
this.textarea.select();
} return this; }
replaceAll(search, replace, caseSensitive = true) {
if (!search)
return 0;
const flags = caseSensitive ? 'g' : 'gi';
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escaped, flags);
const matches = this._value.match(re);
if (!matches)
return 0;
this._value = this._value.replace(re, replace);
this.textarea.value = this._value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
return matches.length;
}
replaceAllRegex(pattern, replace) {
if (!pattern)
return 0;
const re = new RegExp(pattern, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
const matches = this._value.match(re);
if (!matches)
return 0;
this._value = this._value.replace(re, replace);
this.textarea.value = this._value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
return matches.length;
}
lineCount() {
return this._value ? this._value.split('\n').length : 0;
}
getLine(line) {
const lines = this._value.split('\n');
const idx = line - 1;
if (idx < 0 || idx >= lines.length)
return '';
return lines[idx];
}
getValue() { return this._destroyed ? '' : this._value; }
setValue(md, opts = {}) {
if (this._destroyed)
return this;
MarkdownEditor.trigger('beforeChange', this);
this._emit('beforeChange', this._value, md);
this._value = md || '';
this.textarea.value = this._value;
if (!opts.silent)
this._pushHistory();
this._render();
this._renderGutter();
this._updateWordCount();
this._updateOutline();
if (this.gutter)
this.gutter.scrollTop = 0;
this.textarea.scrollTop = 0;
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);
}
}
}
this._emit('afterChange', this._value);
MarkdownEditor.trigger('afterChange', this);
return this;
}
getHTML() { MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this); let html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); if (typeof this.config.sanitize === 'function') {
try {
html = this.config.sanitize(html);
}
catch (e) {
console.error(e);
}
} MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this); return html; }
refresh() { this._lastRenderedValue = null; this._render(); return this; }
copyAsMarkdown() {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'markdown' })).catch(() => { });
}
return this;
}
copyAsHTML() {
const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) {
const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
}
return this;
}
insert(text, opts = {}) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
ta.value = ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start);
ta.focus();
ta.selectionStart = ta.selectionEnd = start + text.length;
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() { if (this.textarea)
this.textarea.focus(); return this; }
blur() { if (this.textarea)
this.textarea.blur(); return this; }
enable() { if (this.textarea)
this.textarea.disabled = false; if (this.el)
this.el.classList.remove('me-disabled'); return this; }
disable() { if (this.textarea)
this.textarea.disabled = true; if (this.el)
this.el.classList.add('me-disabled'); return this; }
isDisabled() { return this.textarea ? this.textarea.disabled : false; }
setReadOnly(readOnly) { this.config.readOnly = !!readOnly; if (this.textarea)
this.textarea.readOnly = !!readOnly; if (this.el)
this.el.classList.toggle('me-readonly', !!readOnly); this._updateModeButtons(); return this; }
isReadOnly() { return !!this.config.readOnly; }
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);
} }); }
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 {
const result = merged.install(this, options);
if (result && typeof result.then === 'function') {
result.catch((e) => console.error(`MeEditor: async plugin "${merged.name}" error:`, e));
}
}
catch (e) {
console.error(`MeEditor: plugin "${merged.name}" install error:`, e);
}
}
this._plugins.push(merged);
return this;
}
unuse(name) { const idx = this._plugins.findIndex((p) => p.name === name); if (idx === -1) {
console.warn(`MeEditor: plugin "${name}" not found`);
return this;
} const plugin = this._plugins[idx]; if (typeof plugin.destroy === 'function') {
try {
plugin.destroy(this);
}
catch (e) {
console.error(`MeEditor: plugin "${name}" destroy error:`, e);
}
} this._plugins.splice(idx, 1); 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.title = config.title || config.action;
btn.setAttribute('aria-label', config.title || config.action);
btn.innerHTML = config.icon || `${escapeHTML(config.text || config.action)} `;
if (typeof config.onClick === 'function') {
btn.addEventListener('click', (e) => { e.stopPropagation(); config.onClick(this); });
}
else if (config.action) {
btn.dataset.action = config.action;
this._customActions = this._customActions || {};
this._customActions[config.action] = config.handler;
}
this.toolbarEl.insertBefore(btn, this.toolbarEl.querySelector('.me-toolbar-group') || null);
return this;
}
registerShortcut(combo, handler, description = '') {
if (!combo)
return this;
const parts = combo.toLowerCase().split('+');
const key = parts.pop();
const ctrl = parts.includes('ctrl') || parts.includes('cmd');
const shift = parts.includes('shift');
const alt = parts.includes('alt');
const meta = parts.includes('meta');
this._shortcuts.push({ combo, key, ctrl, shift, alt, meta, handler, description });
return this;
}
unregisterShortcut(combo) { this._shortcuts = this._shortcuts.filter((s) => s.combo !== combo); return this; }
getShortcuts() { return [...this._shortcuts]; }
configureToolbar(tools) { if (!this.toolbarEl)
return this; this.config.toolbar = tools; this.toolbarEl.innerHTML = ''; this._buildToolbar(); return this; }
removeToolbarButton(action) { if (!this.toolbarEl)
return this; const btn = this.toolbarEl.querySelector(`.me-btn[data-action="${action}"], .me-btn[data-mode="${action}"]`); if (btn)
btn.remove(); return this; }
registerContextMenu(items = []) { this._contextMenuItems = items; return this; }
_bindContextMenu() {
if (!this.el)
return;
const onContextMenu = (e) => {
const existing = document.querySelector('.me-context-menu');
if (existing)
existing.remove();
this._showContextMenu(e);
};
this.el.addEventListener('contextmenu', onContextMenu);
this._cleanups.push(() => this.el.removeEventListener('contextmenu', onContextMenu));
}
_showContextMenu(e) {
e.preventDefault();
const ta = this.textarea;
const hasSelection = ta && ta.selectionStart !== ta.selectionEnd;
const defaultItems = [
{ label: t('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() },
{ label: t('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() },
{ sep: true },
{ label: t('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
{ label: t('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
{ label: t('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
{ label: t('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' },
];
const items = [...defaultItems];
if (this._contextMenuItems.length) {
items.push({ sep: true });
this._contextMenuItems.forEach((item) => items.push(item));
}
const menu = document.createElement('div');
menu.className = 'me-context-menu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
// Adjust if off-screen
requestAnimationFrame(() => {
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth)
menu.style.left = (e.clientX - rect.width) + 'px';
if (rect.bottom > window.innerHeight)
menu.style.top = (e.clientY - rect.height) + 'px';
});
items.forEach((item) => {
if (item.sep) {
const sep = document.createElement('div');
sep.className = 'me-context-menu-sep';
menu.appendChild(sep);
return;
}
const el = document.createElement('div');
el.className = 'me-context-menu-item';
if (item.disabled)
el.classList.add('me-disabled');
el.innerHTML = `${item.label} ${item.shortcut ? `` : ''}`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
if (item.disabled)
return;
if (item.onClick) {
item.onClick();
}
else if (item.action)
this._execContextAction(item.action);
this._hideContextMenu();
});
menu.appendChild(el);
});
document.body.appendChild(menu);
const close = (ev) => {
if (!menu.contains(ev.target)) {
this._hideContextMenu();
}
};
document.addEventListener('click', close, { once: true });
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape')
this._hideContextMenu(); }, { once: true });
}
_hideContextMenu() {
const menu = document.querySelector('.me-context-menu');
if (menu)
menu.remove();
}
_execContextAction(action) {
const ta = this.textarea;
if (!ta)
return;
switch (action) {
case 'undo':
this.undo();
break;
case 'redo':
this.redo();
break;
case 'cut':
document.execCommand('cut');
break;
case 'copy':
document.execCommand('copy');
break;
case 'paste':
document.execCommand('paste');
break;
case 'selectAll':
ta.focus();
ta.select();
break;
default:
this.exec(action);
break;
}
}
toast(message, opts = {}) {
if (!this.el || typeof document === 'undefined')
return this;
const { type = 'info', duration = 3000, animation = 'fade' } = opts;
const toast = document.createElement('div');
toast.className = `me-toast me-toast-${type} me-toast-${animation}`;
toast.innerHTML = `${TOAST_ICONS[type] || ''} ${escapeHTML(String(message))} `;
this.el.appendChild(toast);
requestAnimationFrame(() => { toast.classList.add('me-toast-enter'); });
const remove = () => { toast.classList.remove('me-toast-enter'); toast.classList.add('me-toast-leave'); setTimeout(() => { if (toast.parentNode)
toast.parentNode.removeChild(toast); }, 300); };
if (duration > 0)
setTimeout(remove, duration);
toast.addEventListener('click', remove);
return this;
}
setLocale(locale) { if (this._i18nCtx)
this._i18nCtx.set(locale); return this; }
getLocale() { if (this._i18nCtx)
return this._i18nCtx.get(); return getCurrentLocale(); }
t(key, params) { if (this._i18nCtx)
return this._i18nCtx.t(key, params); return t(key, params); }
setTheme(theme) { if (!theme)
return this; this.config.theme = theme; if (this._themeCtx)
this._themeCtx.set(theme); return this; }
getTheme() { if (this._themeCtx)
return this._themeCtx.get(); return this.config.theme || 'auto'; }
getThemeContext() { return this._themeCtx || null; }
getStatus() { return { id: this.id, mode: this._mode, theme: this.getTheme(), locale: getCurrentLocale(), fullscreen: this._fullscreen, readOnly: this.config.readOnly || false, disabled: this.textarea ? this.textarea.disabled : false, destroyed: this._destroyed, plugins: this._plugins.map((p) => p.name || 'custom'), }; }
destroy() {
if (this._destroyed)
return;
MarkdownEditor.trigger('beforeDestroy', this);
if (this._renderRaf)
cancelAnimationFrame(this._renderRaf);
if (this._historyTimer)
clearTimeout(this._historyTimer);
if (this._outlineTimer)
clearTimeout(this._outlineTimer);
if (this._selectionTimer) {
clearInterval(this._selectionTimer);
this._selectionTimer = null;
}
this._cleanups.forEach((fn) => { try {
fn();
}
catch (_) { } });
this._cleanups = [];
this._shortcuts = [];
this._contextMenuItems = [];
this._customActions = {};
if (this._floatingToolbar && this._floatingToolbar.parentNode)
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
if (this.el)
this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
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);
}
}
this._emit('destroy');
this._listeners = {};
MarkdownEditor.trigger('afterDestroy', this);
}
isDestroyed() { return this._destroyed; }
}
MarkdownEditor._hooks = new Map();
/**
* MetonaEditor Animations — animation metadata
* @module animations
* @version 0.2.0
*/
const animationMap = new Map();
// Register built-in animations
for (const [name, config] of Object.entries(ANIMATIONS)) {
animationMap.set(name, config);
}
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 animations managed by browser natively
},
reset() {
animationMap.clear();
for (const [name, config] of Object.entries(ANIMATIONS)) {
animationMap.set(name, config);
}
},
destroy() {
animationMap.clear();
},
};
/**
* MetonaEditor — Type-safe, lightweight Markdown Editor
* @module metona-editor
* @version 0.2.3
*/
const VERSION = '0.2.3';
const globalPlugins = [];
MarkdownEditor.on('beforeCreate', (editor) => {
if (!editor || editor.isDestroyed())
return;
globalPlugins.forEach((p) => { try {
editor.use(p);
}
catch (e) {
console.error('MeEditor global plugin install error:', e);
} });
});
function create(container, options = {}) {
return new MarkdownEditor(container, options);
}
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;
}
function on(name, fn) { return MarkdownEditor.on(name, fn); }
function off(name, fn) { MarkdownEditor.off(name, fn); return api; }
function setTheme(theme, _options) { if (themeUtils && typeof themeUtils.switchTheme === 'function')
themeUtils.switchTheme(theme); return api; }
function setLocale(locale) { if (i18nUtils && typeof i18nUtils.setCurrentLocale === 'function')
i18nUtils.setCurrentLocale(locale); return api; }
function destroy() {
if (themeUtils) {
try {
themeUtils.clearThemeListeners();
}
catch (_) { }
try {
themeUtils.unwatchSystemTheme();
}
catch (_) { }
}
if (i18nUtils) {
try {
i18nUtils.clearLocaleListeners();
}
catch (_) { }
}
if (animationUtils) {
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 || {}), };
}
try {
if (typeof themeUtils === 'object' && typeof themeUtils.initTheme === 'function')
themeUtils.initTheme();
if (typeof i18nUtils === 'object' && typeof i18nUtils.initI18n === 'function')
i18nUtils.initI18n();
}
catch (_) { }
const api = {
VERSION, version: VERSION,
MarkdownEditor, Editor: MarkdownEditor,
create, use, on, off, setTheme, setLocale, destroy, getStatus,
parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler,
themes: themeUtils, i18n: i18nUtils, animations: animationUtils, plugins: pluginUtils, presetPlugins,
topologicalSort, validateConfig, createInstanceI18n, loadRemote,
exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme,
DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS,
};
if (typeof window !== 'undefined') {
window.MeEditor = api;
}
export { DEFAULTS, DEFAULT_TOOLBAR, EDIT_MODES, MarkdownEditor as Editor, ICONS, MarkdownEditor, api as MeEditor, THEMES, TOOLBAR_ACTIONS, VERSION, adoptFromParent, animationUtils, api, clearRenderCache, create, createInstanceI18n, createInstanceTheme, api as default, destroy, exportCSSVars, followExternalTheme, getCSSVariable, getStatus, i18nUtils, loadRemote, api as meEditor, off, on, parseMarkdown, parseTokens, pluginUtils, presetPlugins, registerBlockHandler, renderTokens, safeUrl, setLocale, setTheme, slugify, themeUtils, topologicalSort, use, validateConfig };
//# sourceMappingURL=metona-editor.esm.js.map