Files
MetonaEditor/dist/metona-editor.cjs.js
T
thzxx 01c9807817
CI / test (18.x) (push) Canceled after 0s
CI / test (20.x) (push) Canceled after 0s
CI / test (22.x) (push) Canceled after 0s
CI / test (24.x) (push) Canceled after 0s
fix: 浮动工具栏贴近选中文字 + 构建最新 dist
2026-07-25 15:12:47 +08:00

4558 lines
207 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
/**
* 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>`,
italic: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>`,
underline: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>`,
strikethrough: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.3 19c.6-1.2 1-2.5 1-3.8 0-4.3-3.5-7.2-7.3-7.2-3.8 0-7.3 2.9-7.3 7.2 0 1.3.4 2.6 1 3.8"/><line x1="4" y1="12" x2="20" y2="12"/></svg>`,
h1: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17 12l3-2v8"/></svg>`,
h2: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M19 12v4"/><path d="M22 12h-4"/></svg>`,
h3: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-2 1-3 3-3s2 1 2 3"/></svg>`,
quote: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>`,
code: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`,
link: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
image: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
table: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>`,
ul: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
ol: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 14h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>`,
indent: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 8 7 12 3 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/></svg>`,
outdent: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 8 3 12 7 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/></svg>`,
hr: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/></svg>`,
undo: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>`,
redo: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`,
preview: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`,
split: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>`,
edit: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
fullscreen: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>`,
theme: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`,
};
/**
* 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<div class="me-footnotes"><hr/><ol>';
fnIds.forEach((id) => {
html += `<li id="fn-${id}" class="me-footnote-item"><a href="#fnref-${id}" class="me-footnote-backref">↩</a> ${cachedRenderInline(footnotes[id], env)}</li>`;
});
html += '</ol></div>';
}
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 `<h${tok.level} id="${id}">${cachedRenderInline(tok.text, env)}</h${tok.level}>`;
}
case 'paragraph': return `<p>${cachedRenderInline(tok.text, env)}</p>`;
case 'hr': return '<hr/>';
case 'quote': return `<blockquote>${parseMarkdown(tok.content, env)}</blockquote>`;
case 'code': return renderCode(tok.content, tok.lang, env, tok.attrs);
case 'ul': return `<ul>${tok.items.map((it) => renderListItem(it, env)).join('')}</ul>`;
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 `<ol${sa}>${body}</ol>`;
}
case 'table': return renderTable(tok, env);
case 'defList': {
let h = '<dl>';
h += `<dt>${cachedRenderInline(tok.term, env)}</dt>`;
tok.defs.forEach((d) => { h += `<dd>${cachedRenderInline(d, env)}</dd>`; });
return h + '</dl>';
}
case 'mathBlock': return `<div class="me-math-block">${escapeHTML(tok.content)}</div>`;
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 `<div class="${typeClass}">${content}</div>`;
}
}
};
const renderListItem = (it, env, idx) => {
if (it.task) {
const checked = it.checked ? ' checked' : '';
let h = `<li class="me-task-item"><input type="checkbox" disabled${checked}/> ${cachedRenderInline(it.text, env)}`;
if (it.subTokens)
h += '\n' + it.subTokens.map((st) => renderToken(st, env)).join('\n');
return h + '</li>';
}
let h = idx != null ? `<li value="${idx}">` : '<li>';
h += cachedRenderInline(it.text, env);
if (it.subTokens)
h += '\n' + it.subTokens.map((st) => renderToken(st, env)).join('\n');
return h + '</li>';
};
const renderCode = (code, lang, env, attrs) => {
if (lang === 'mermaid')
return `<div class="me-mermaid"><pre class="mermaid">${escapeHTML(code)}</pre></div>`;
const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : '';
const titleHtml = attrs?.title ? `<div class="me-code-title">${escapeHTML(attrs.title)}</div>` : '';
if (env.highlight && typeof env.highlight === 'function' && lang) {
try {
const highlighted = env.highlight(code, lang);
if (typeof highlighted === 'string')
return `${titleHtml}<pre><code${langClass}>${highlighted}</code></pre>`;
}
catch (e) {
console.error('MeEditor highlight error:', e);
}
}
return `${titleHtml}<pre><code${langClass}>${escapeHTML(code)}</code></pre>`;
};
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 = '<div class="me-table-wrap"><table><thead><tr>';
h += headers.map((hd, i) => `<th${as(i)}>${cachedRenderInline(hd, env)}</th>`).join('');
h += '</tr></thead><tbody>';
h += tok.rows.map((r) => {
const cells = splitRow(r);
return `<tr>${cells.map((c, i) => `<td${as(i)}>${cachedRenderInline(c, env)}</td>`).join('')}</tr>`;
}).join('');
return h + '</tbody></table></div>';
};
// ============ 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(/<!--[\s\S]*?-->/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 `<code>${escapeHTML(code.content)}</code>`;
});
s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m);
s = s.replace(/ \n/g, '<br/>\n');
s = s.replace(/\n/g, '<br/>');
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]*?___)',
'|(!\\[[^\\]]*\\]\\([^)]+\\))',
'|(?<!!)(\\[[^\\]]+\\]\\([^)]+\\))',
'|(!\\[[^\\]]*\\]\\[[^\\]]*\\])',
'|(?<!!)(\\[[^\\]]+\\]\\[[^\\]]*\\])',
'|(&lt;https?:\\/\\/[^\\s&]+&gt;)',
'|(\\*\\*[^*\\n][^*\\n]*?\\*\\*)',
'|(__[^_\\n][^_\\n]*?__)',
'|(~~[^\\n]+?~~)',
'|(==[^\\n]+?==)',
'|((?<=^|[^*])\\*(?!\\*)([^*\\n]+?)\\*(?!\\*))',
'|((?<=^|[^_])_(?!_)([^_\\n]+?)_(?!_))',
'|(\\^[^\\s^][^\\s^]*?\\^)',
'|(~[^\\s~][^\\s~]*?~)',
'|(?<!\\$)(\\$(?!\\$)[^\\n]+?\\$(?!\\$))',
'|(\\[\\^[^\\]]+\\])',
'|(\\\\.)',
].join(''), 'g');
const scanInline = (s, _codes, env) => {
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 `<em><strong>${match.slice(3, -3)}</strong></em>`;
if (match.startsWith('___'))
return `<em><strong>${match.slice(3, -3)}</strong></em>`;
if (match.startsWith('![') && match.includes('](')) {
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 `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`;
}
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 `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`;
}
catch (_) {
return `<img src="" alt="${m[1]}" class="me-img-ref"/>`;
}
}
return `<img src="" alt="${m[1]}" class="me-img-ref"/>`;
}
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 `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
}
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 `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
}
catch (_) {
return escapeHTML(match);
}
}
return escapeHTML(match);
}
if (match.startsWith('&lt;http')) {
const url = match.slice(4, -4);
const u = safeUrl(url);
return `<a href="${u}" target="_blank" rel="noopener noreferrer">${url}</a>`;
}
if (match.startsWith('**'))
return `<strong>${match.slice(2, -2)}</strong>`;
if (match.startsWith('__'))
return `<strong>${match.slice(2, -2)}</strong>`;
if (match.startsWith('~~'))
return `<del>${match.slice(2, -2)}</del>`;
if (match.startsWith('=='))
return `<mark>${match.slice(2, -2)}</mark>`;
if (match.startsWith('*') && !match.startsWith('**') && !match.startsWith('***'))
return `<em>${match.slice(1, -1)}</em>`;
if (match.startsWith('_') && !match.startsWith('__') && !match.startsWith('___'))
return `<em>${match.slice(1, -1)}</em>`;
if (match.startsWith('^') && !match.startsWith('^^'))
return `<sup>${match.slice(1, -1)}</sup>`;
if (match.startsWith('~') && !match.startsWith('~~'))
return `<sub>${match.slice(1, -1)}</sub>`;
if (match.startsWith('$') && !match.startsWith('$$'))
return `<span class="me-math-inline">${escapeHTML(match.slice(1, -1))}</span>`;
if (/^\[\^/.test(match)) {
const fnId = match.slice(2, -1);
return `<sup class="me-footnote-ref"><a href="#fn-${fnId}" id="fnref-${fnId}">[${fnId}]</a></sup>`;
}
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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 ? `<style>
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;font-size:14px;line-height:1.6;color:#1f2937;max-width:860px;margin:0 auto;padding:20px}
h1,h2,h3,h4,h5,h6{margin:1.4em 0 .6em;font-weight:650;line-height:1.3}
h1{font-size:1.9em;padding-bottom:.3em;border-bottom:1px solid #e5e7eb}
h2{font-size:1.55em;padding-bottom:.3em;border-bottom:1px solid #e5e7eb}
h3{font-size:1.3em}h4{font-size:1.12em}h5{font-size:1em}h6{font-size:.9em;color:#6b7280}
p{margin:.7em 0}a{color:#3b82f6;text-decoration:none}a:hover{text-decoration:underline}
strong{font-weight:650}em{font-style:italic}del{text-decoration:line-through;opacity:.75}
ul,ol{margin:.6em 0;padding-left:1.6em}li{margin:.25em 0}
li.me-task-item{list-style:none;margin-left:-1.4em}
li.me-task-item input{margin-right:.5em;vertical-align:middle}
blockquote{margin:.8em 0;padding:.4em 1em;border-left:3px solid #3b82f6;background:#f3f4f6;border-radius:0 6px 6px 0}
hr{border:0;height:1px;background:#e5e7eb;margin:1.6em 0}
code{font-family:"SF Mono",Consolas,monospace;font-size:.88em;padding:.15em .4em;background:#f3f4f6;border-radius:4px}
pre{margin:.9em 0;padding:14px 16px;background:#f3f4f6;border-radius:8px;overflow-x:auto;border:1px solid #e5e7eb}
pre code{padding:0;background:transparent;font-size:.9em;line-height:1.6;border-radius:0}
img{max-width:100%;height:auto;border-radius:6px}
table{border-collapse:collapse;width:100%;font-size:.93em;display:block;overflow-x:auto}
th,td{border:1px solid #e5e7eb;padding:7px 12px;text-align:left}
th{background:#f3f4f6;font-weight:600}
tr:nth-child(even) td{background:#f9fafb}
mark{background:rgba(250,204,21,.3);color:inherit;padding:.1em .2em;border-radius:3px}
sup{font-size:.75em}sub{font-size:.75em}
.me-math-block{display:block;margin:1.2em 0;padding:12px 16px;background:#f3f4f6;border-radius:8px;overflow-x:auto;font-family:monospace;text-align:center}
.me-math-inline{font-family:monospace}
dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;color:#4b5563}
.me-footnote-ref a{font-size:.75em;vertical-align:super;text-decoration:none;color:#3b82f6}
.me-footnotes{margin-top:2em;border-top:1px solid #e5e7eb;padding-top:.8em;font-size:.9em;color:#6b7280}
.me-footnotes hr{display:none}
.me-footnotes ol{padding-left:1.2em}
.me-footnote-item{margin:.3em 0}
.me-footnote-backref{text-decoration:none;color:#3b82f6;margin-right:.4em}
.me-table-wrap{overflow-x:auto;margin:.9em 0}
</style>` : '';
download(filename || `metona-${stamp()}.html`, `<!DOCTYPE html>\n<html lang="${opts.lang || 'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css ? `<style>${css}</style>` : ''}${embedCSS}\n</head>\n<body>\n${body}\n</body>\n</html>`, '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 = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-prev">↑</button><button class="me-search-next">↓</button><span class="me-search-count"></span><button class="me-search-regex" title="Regex">.*</button><button class="me-search-close">×</button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder') || 'Replace'}"/><button class="me-search-replace-one">${t('replace') || 'Replace'}</button><button class="me-search-replace-all">${t('replaceAll') || 'All'}</button></div>`;
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(`![image-${Date.now().toString(36)}.png](${reader.result})\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 += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div');
overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('close') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
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] || `<span>${escapeHTML(item)}</span>`;
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 = `<p style="color:#ef4444">${t('renderError')}: ${escapeHTML(err.message)}</p>`;
}
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 += `<div class="me-gutter-line">${i}</div>`;
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(`![${file.name}](${reader.result})\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-6])\s+id="([^"]+)"[^>]*>(.+?)<\/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 = `<div class="me-outline-title">${t('outline') || '大纲'}</div>`;
const buildTree = (items, minLevel) => {
let h = '<ul>';
let i = 0;
while (i < items.length) {
const item = items[i];
if (item.level < minLevel)
break;
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
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 += '</li>';
i = j > i + 1 ? j : i + 1;
}
return h + '</ul>';
};
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('<u>', '</u>'),
code: () => this._wrapSelection('`', '`'), h1: () => this._toggleLinePrefix('# '),
h2: () => this._toggleLinePrefix('## '), h3: () => this._toggleLinePrefix('### '),
quote: () => this._toggleLinePrefix('> '), ul: () => this._toggleLinePrefix('- '),
ol: () => this._toggleLinePrefix('1. '), indent: () => this._handleTab(false),
outdent: () => this._handleTab(true), hr: () => this._insertBlock('\n---\n'),
link: () => this._insertLink(), image: () => this._insertImage(), table: () => this._insertTable(),
undo: () => this.undo(), redo: () => this.redo(),
edit: () => this.setMode('edit'), split: () => this.setMode('split'),
preview: () => this.setMode('preview'), fullscreen: () => this.toggleFullscreen(),
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 = `![${sel}](${url})`; 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;
// Place toolbar just above the selected line (4px gap)
const toolbarHeight = 36;
let top = rect.top + paddingTop + currentLine * lineHeight - scrollOffset - toolbarHeight - 4;
// Clamp: don't go above textarea
if (top < rect.top)
top = rect.top + 4;
if (top + toolbarHeight > rect.bottom)
top = rect.bottom - toolbarHeight - 4;
// 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 || `<span>${escapeHTML(config.text || config.action)}</span>`;
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 = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`;
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 = `<span class="me-toast-icon">${TOAST_ICONS[type] || ''}</span><span class="me-toast-msg">${escapeHTML(String(message))}</span>`;
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;
}
exports.DEFAULTS = DEFAULTS;
exports.DEFAULT_TOOLBAR = DEFAULT_TOOLBAR;
exports.EDIT_MODES = EDIT_MODES;
exports.Editor = MarkdownEditor;
exports.ICONS = ICONS;
exports.MarkdownEditor = MarkdownEditor;
exports.MeEditor = api;
exports.THEMES = THEMES;
exports.TOOLBAR_ACTIONS = TOOLBAR_ACTIONS;
exports.VERSION = VERSION;
exports.adoptFromParent = adoptFromParent;
exports.animationUtils = animationUtils;
exports.api = api;
exports.clearRenderCache = clearRenderCache;
exports.create = create;
exports.createInstanceI18n = createInstanceI18n;
exports.createInstanceTheme = createInstanceTheme;
exports.default = api;
exports.destroy = destroy;
exports.exportCSSVars = exportCSSVars;
exports.followExternalTheme = followExternalTheme;
exports.getCSSVariable = getCSSVariable;
exports.getStatus = getStatus;
exports.i18nUtils = i18nUtils;
exports.loadRemote = loadRemote;
exports.meEditor = api;
exports.off = off;
exports.on = on;
exports.parseMarkdown = parseMarkdown;
exports.parseTokens = parseTokens;
exports.pluginUtils = pluginUtils;
exports.presetPlugins = presetPlugins;
exports.registerBlockHandler = registerBlockHandler;
exports.renderTokens = renderTokens;
exports.safeUrl = safeUrl;
exports.setLocale = setLocale;
exports.setTheme = setTheme;
exports.slugify = slugify;
exports.themeUtils = themeUtils;
exports.topologicalSort = topologicalSort;
exports.use = use;
exports.validateConfig = validateConfig;
//# sourceMappingURL=metona-editor.cjs.js.map