Files
MetonaEditor/dist/metona-editor.cjs
T
thzxx 67f234676c
CI / test-parser (push) Successful in 9m25s
CI / test-core (push) Successful in 9m33s
CI / test-rest (push) Successful in 9m27s
CI / e2e (push) Successful in 9m43s
CI / verify (18.x) (push) Successful in 9m52s
CI / verify (20.x) (push) Successful in 9m52s
CI / verify (24.x) (push) Successful in 9m45s
build: 提交 dist 构建产物(v0.4.3 完整 5 格式 + sourcemap + 类型声明)
2026-08-20 20:43:07 +08:00

5445 lines
251 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;
};
/** HTML-escape a string for use inside a double-quoted attribute value */
const escapeAttr$1 = (s) => {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
};
/** 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: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB',
formatToolbar: '格式化选区', pasteBlocked: '无法直接粘贴,请使用 Ctrl+V',
},
'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',
regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)',
formatToolbar: 'Format selection', pasteBlocked: 'Cannot paste directly, use Ctrl+V',
},
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: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB',
formatToolbar: '選択範囲を整形', pasteBlocked: '直接貼り付けできません。Ctrl+V を使用してください',
},
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: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다 ({size}KB > {max}KB)',
formatToolbar: '선택 영역 서식', pasteBlocked: '직접 붙여넣을 수 없습니다. Ctrl+V를 사용하세요',
},
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',
regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)',
formatToolbar: 'Formater la sélection', pasteBlocked: 'Impossible de coller directement, utilisez Ctrl+V',
},
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',
regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)',
formatToolbar: 'Auswahl formatieren', pasteBlocked: 'Direktes Einfügen nicht möglich, verwenden Sie Strg+V',
},
};
/**
* 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,
zenMaxWidth: 960,
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-hl-keyword{color:#c678dd}
.me-hl-string{color:#98c379}
.me-hl-comment{color:#7f848e;font-style:italic}
.me-hl-number{color:#d19a66}
.me-hl-builtin{color:#e5c07b}
.me-hl-function{color:#61afef}
.me-theme-light .me-hl-keyword{color:#9c4cc0}
.me-theme-light .me-hl-string{color:#4f9d4f}
.me-theme-light .me-hl-comment{color:#8b8b8b}
.me-theme-light .me-hl-number{color:#b06a22}
.me-theme-light .me-hl-builtin{color:#9a7d0f}
.me-theme-light .me-hl-function{color:#2a7fd4}
.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:var(--md-zen-max-width,960px);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);
}
}
});
}
// 语言切换即时刷新已渲染的 UI(状态栏统计标签、大纲标题),无需等待下一次输入
if (typeof editor._updateWordCount === 'function') {
try {
editor._updateWordCount();
}
catch (_) { }
}
if (editor.el && typeof editor.el.querySelector === 'function') {
const outlineTitle = editor.el.querySelector('.me-outline-title');
if (outlineTitle)
outlineTitle.textContent = instanceT('outline') || 'Outline';
}
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;
/**
* 引用链接 `[x][r]` 的解析依赖 env.refs,若仅以文本为缓存 key
* 不同文档中相同文本会命中彼此的缓存导致链接串数据。
* 故缓存 key 附加 refs 指纹(parseMarkdown 预计算 `_refsFp`
* 直接调用 renderTokens 时兜底现场计算)。
*/
const refsFingerprint = (env) => {
let fp = env._refsFp;
if (fp === undefined) {
const keys = env.refs ? Object.keys(env.refs) : [];
fp = keys.length > 0 ? JSON.stringify(env.refs) : '';
}
return fp || '';
};
const cachedRenderInline = (text, env) => {
if (!text)
return '';
if (!env.highlight && text.length < 600) {
const fp = refsFingerprint(env);
const key = fp ? text + '\u0001' + fp : text;
const cached = renderCache.get(key);
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(key, 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';
};
/** Restore backslash-escaped punctuation in title text: \" -> " */
const unescapePunct = (text) => text.replace(/\\([!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])/g, '$1');
/**
* Safe double-quoted attribute value. Inline-sourced text is already HTML-escaped
* (entities are safe inside quoted attributes and decode correctly), so only the
* quote character that would terminate the attribute needs escaping.
*/
const attrSafe = (v) => v.replace(/"/g, '&quot;').replace(/\r?\n/g, ' ');
/**
* Same as `attrSafe` but also restores backslash-escaped punctuation first
* (used for title attributes where \" is a markdown escape).
*/
const titleAttr = (title) => {
if (!title)
return '';
return ` title="${unescapePunct(title).replace(/"/g, '&quot;').replace(/\r?\n/g, ' ')}"`;
};
// ============ 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)) {
// 防止死循环:若该行匹配 RE_FENCE_START 但被 fencedCode handler 拒绝(info string 含反引号),
// 且 para 为空,则强制当作段落首行收集,否则主循环将无限重复处理此行
if (para.length === 0 && RE_FENCE_START.test(l) && l.includes('`')) {
para.push(l);
i++;
continue;
}
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') });
else if (i < lines.length && !RE_EMPTY.test(lines[i]))
i++; // 兜底:跳过无法处理的非空行
}
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) => {
const safeId = attrSafe(id);
html += `<li id="fn-${safeId}" class="me-footnote-item"><a href="#fnref-${safeId}" 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);
const resolvedEnv = { ...env, refs };
if (refs && Object.keys(refs).length > 0)
resolvedEnv._refsFp = JSON.stringify(refs);
return renderTokens(tokens, resolvedEnv, 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-${escapeAttr$1(lang)}"` : '';
const titleHtml = attrs?.title ? `<div class="me-code-title">${escapeAttr$1(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`;
});
// 白名单裸标签透传:仅 `<u>` / `</u>`(无任何属性),供 Ctrl+U 下划线命令使用。
// 带属性的 `<u onclick=...>` 不匹配,仍会被 escapeHTML 转义,不引入注入面。
s = s.replace(/<\/?u\s*>/gi, (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);
return `<img src="${attrSafe(u)}" alt="${attrSafe(m[1])}"${titleAttr(m[3])} 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);
return `<img src="${attrSafe(u)}" alt="${attrSafe(m[1])}"${titleAttr(escapeHTML(ref.title))} loading="lazy"/>`;
}
catch (_) {
return `<img src="" alt="${attrSafe(m[1])}" class="me-img-ref"/>`;
}
}
return `<img src="" alt="${attrSafe(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 linkText = renderInline(m[1], env);
return `<a href="${attrSafe(u)}"${titleAttr(m[3])} 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);
return `<a href="${attrSafe(u)}"${titleAttr(escapeHTML(ref.title))} 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="${attrSafe(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);
const safeId = attrSafe(fnId);
return `<sup class="me-footnote-ref"><a href="#fn-${safeId}" id="fnref-${safeId}">[${safeId}]</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 state keys (Symbols avoid property-name collisions) ============
const K_AUTOSAVE = Symbol('me-plugin:autoSave');
const K_SEARCH = Symbol('me-plugin:searchReplace');
const K_IMAGE_PASTE = Symbol('me-plugin:imagePaste');
const K_SHORTCUT = Symbol('me-plugin:shortcutHelp');
const K_FILESYSTEM = Symbol('me-plugin:fileSystem');
const pluginState = (editor, key) => editor[key];
const setPluginState = (editor, key, state) => { editor[key] = state; };
const deletePluginState = (editor, key) => { delete editor[key]; };
// ============ 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;');
/** 实例级翻译:优先用 editor.t(实例 locale),否则退回全局 t。
* 插件面板文案跟随实例语言而非全局语言。 */
const instanceT = (editor) => (key, params) => (typeof editor.t === 'function' ? editor.t(key, params) : t(key, params));
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;
if (typeof editor.on === 'function') {
editor.on('change', _onInput);
editor.on('blur', _onBlur);
editor.on('save', _onSave);
}
editor.restoreDraft = () => { try {
const v = localStorage.getItem(key);
if (v != null && typeof editor.setValue === 'function')
editor.setValue(v);
return v;
}
catch (_) {
return null;
} };
editor.clearDraft = () => { try {
localStorage.removeItem(key);
}
catch (_) { } return editor; };
editor.getDraftKey = () => key;
setPluginState(editor, K_AUTOSAVE, { state, _onInput, _onBlur, _onSave, save });
},
destroy(editor) {
const cleanup = pluginState(editor, K_AUTOSAVE);
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);
}
deletePluginState(editor, K_AUTOSAVE);
}
},
};
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())}`; };
const e = editor;
const buildHTML = (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>` : '';
return `<!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>`;
};
e.exportMarkdown = (filename) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; };
e.exportHTML = (filename, opts = {}) => {
download(filename || `metona-${stamp()}.html`, buildHTML(opts), 'text/html');
return editor;
};
e.exportPDF = (opts = {}) => {
if (typeof document === 'undefined' || !editor.el)
return editor;
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.right = '0';
iframe.style.bottom = '0';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = '0';
document.body.appendChild(iframe);
const doc = iframe.contentDocument;
if (!doc) {
iframe.remove();
return editor;
}
doc.open();
doc.write(buildHTML(opts));
doc.close();
setTimeout(() => {
try {
iframe.contentWindow?.print();
}
catch (_) { }
setTimeout(() => { if (iframe.parentNode)
iframe.parentNode.removeChild(iframe); }, 1000);
}, 50);
return editor;
};
},
destroy(editor) {
if (editor) {
delete editor.exportMarkdown;
delete editor.exportHTML;
delete editor.exportPDF;
}
},
};
const searchReplacePlugin = {
name: 'searchReplace', version: '0.2.1', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const tt = instanceT(editor);
// 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:10px;right:16px;z-index:20;display:flex;flex-direction:column;gap:8px;padding:12px;background:var(--md-bg,#1c2029);border:1px solid var(--md-border,rgba(255,255,255,0.12));border-radius:10px;box-shadow:0 12px 40px -8px rgba(0,0,0,0.4),0 0 0 1px rgba(255,255,255,0.05);font-size:13px;min-width:300px;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);animation:meSearchIn .18s ease-out}@keyframes meSearchIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}.me-search-row{display:flex;gap:6px;align-items:center}.me-search-row+.me-search-row{margin-top:2px}.me-search input{flex:1;min-width:0;padding:7px 10px;border:1px solid var(--md-border,rgba(255,255,255,0.12));border-radius:6px;background:var(--md-textarea-bg,rgba(255,255,255,0.04));color:var(--md-text,#e6e8eb);font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s}.me-search input::placeholder{color:var(--md-muted,#6b7280)}.me-search input:focus{border-color:var(--md-accent,#3b82f6);box-shadow:0 0 0 3px rgba(59,130,246,0.15)}.me-search .me-search-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid rgba(255,255,255,0.08);border-radius:5px;background:rgba(255,255,255,0.04);color:var(--md-muted,#9ca3af);cursor:pointer;transition:all .12s;flex-shrink:0}.me-search .me-search-btn:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-btn:active{transform:scale(.92)}.me-search .me-search-btn.me-active{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-btn svg{width:14px;height:14px}.me-search .me-search-count{color:var(--md-muted,#9ca3af);font-size:11.5px;min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.me-search .me-search-close{margin-left:2px;font-size:16px;line-height:1}.me-search .me-search-close:hover{background:#ef4444;border-color:#ef4444}.me-search .me-search-replace-one,.me-search .me-search-replace-all{width:auto;padding:5px 10px;font-size:12px;font-weight:500;letter-spacing:.01em}.me-search .me-search-replace-all{background:rgba(59,130,246,0.12);border-color:rgba(59,130,246,0.3);color:var(--md-accent,#60a5fa)}.me-search .me-search-replace-all:hover{background:var(--md-accent,#3b82f6);color:#fff}`;
document.head.appendChild(style);
}
const state = {
_panel: null,
_regexMode: false,
_matchCase: true,
_wholeWord: false,
_cleanup: null,
};
const WORD_CLASS = '[\\w\\u4e00-\\u9fff]';
const escapeRegExpText = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const buildPattern = (q) => {
if (!q)
return null;
let src = state._regexMode ? q : escapeRegExpText(q);
if (state._wholeWord)
src = `(?<!${WORD_CLASS})(?:${src})(?!${WORD_CLASS})`;
const flags = 'g' + (state._matchCase ? '' : 'i');
try {
return new RegExp(src, flags);
}
catch (_) {
return null;
}
};
const hasWordBoundary = (text, idx, len) => {
const isWord = (c) => !!c && /[\w\u4e00-\u9fff]/.test(c);
return !isWord(text[idx - 1]) && !isWord(text[idx + len]);
};
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="${tt('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-btn me-search-prev" title="${tt('findPrev') || 'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${tt('findNext') || 'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${tt('matchCase') || 'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${tt('wholeWord') || 'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="${tt('regex') || 'Regex'}">.*</button><button class="me-search-btn me-search-close" title="${tt('close') || 'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${tt('replacePlaceholder') || 'Replace'}"/><button class="me-search-btn me-search-replace-one">${tt('replace') || 'Replace'}</button><button class="me-search-btn me-search-replace-all">${tt('replaceAll') || 'All'}</button></div>`;
editor.el.appendChild(panel);
state._panel = panel;
_updateReplaceVisible();
state._regexMode = false;
state._matchCase = true;
state._wholeWord = 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');
const caseBtn = panel.querySelector('.me-search-case');
const wordBtn = panel.querySelector('.me-search-word');
regexBtn.addEventListener('click', () => {
state._regexMode = !state._regexMode;
regexBtn.classList.toggle('me-active', state._regexMode);
lastIdxs = findAll();
});
caseBtn.addEventListener('click', () => {
state._matchCase = !state._matchCase;
caseBtn.classList.toggle('me-active', state._matchCase);
lastIdxs = findAll();
});
wordBtn.addEventListener('click', () => {
state._wholeWord = !state._wholeWord;
wordBtn.classList.toggle('me-active', state._wholeWord);
lastIdxs = findAll();
});
const findAll = () => {
const q = fi.value;
if (!q) {
ce.textContent = '';
return [];
}
const idxs = [];
const re = buildPattern(q);
if (!re) {
ce.textContent = 'err';
return [];
}
let m;
while ((m = re.exec(editor.textarea.value)) !== null) {
idxs.push(m.index);
if (m[0].length === 0)
re.lastIndex++;
}
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 matchLenAt = (idx) => {
const text = editor.textarea.value;
const q = fi.value;
if (!q)
return 0;
if (!state._regexMode) {
if (state._matchCase && text.slice(idx, idx + q.length) !== q)
return 0;
if (!state._matchCase && text.slice(idx, idx + q.length).toLowerCase() !== q.toLowerCase())
return 0;
if (state._wholeWord && !hasWordBoundary(text, idx, q.length))
return 0;
return q.length;
}
const re = buildPattern(q);
if (!re)
return 0;
re.lastIndex = idx;
const m = re.exec(text);
return m && m.index === idx ? m[0].length : 0;
};
const findNext = () => {
lastIdxs = findAll();
if (!lastIdxs.length)
return;
const cur = editor.textarea.selectionEnd;
let next = lastIdxs.find((i) => i >= cur);
if (next == null)
next = lastIdxs[0];
selectAt(next, matchLenAt(next) || fi.value.length);
};
const findPrev = () => {
lastIdxs = findAll();
if (!lastIdxs.length)
return;
const cur = editor.textarea.selectionStart;
let prev = -1;
for (let i = lastIdxs.length - 1; i >= 0; i--) {
if (lastIdxs[i] < cur) {
prev = lastIdxs[i];
break;
}
}
if (prev === -1)
prev = lastIdxs[lastIdxs.length - 1];
selectAt(prev, matchLenAt(prev) || fi.value.length);
};
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);
let matched = false;
if (state._regexMode) {
const re = buildPattern(q);
if (re) {
re.lastIndex = s;
const m = re.exec(ta.value);
matched = !!(m && m.index === s);
}
}
else {
const sameCase = state._matchCase ? matchText === q : matchText.toLowerCase() === q.toLowerCase();
matched = sameCase && (!state._wholeWord || hasWordBoundary(ta.value, s, q.length));
}
if (matched) {
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
ta.setSelectionRange(s, s + r.length);
}
const oldV = editor._value || '';
editor._value = ta.value;
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
}
else {
if (typeof editor._pushHistory === 'function')
editor._pushHistory();
if (typeof editor._render === 'function')
editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
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;
const re = buildPattern(q);
if (!re)
return;
const oldV = editor._value || '';
ta.value = ta.value.replace(re, () => r);
ta.setSelectionRange(0, 0);
editor._value = ta.value;
if (typeof editor._afterProgrammaticEdit === 'function') {
editor._afterProgrammaticEdit(oldV);
}
else {
if (typeof editor._pushHistory === 'function')
editor._pushHistory();
if (typeof editor._render === 'function')
editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
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 via Symbol-keyed state (no public-property pollution)
setPluginState(editor, K_SEARCH, { state, _open, _close, _onKeydown });
},
destroy(editor) {
const exposed = pluginState(editor, K_SEARCH);
if (exposed) {
const state = exposed.state;
if (state) {
if (state._cleanup) {
state._cleanup();
state._cleanup = null;
}
state._panel = null;
}
if (exposed._onKeydown && editor && editor.textarea) {
editor.textarea.removeEventListener('keydown', exposed._onKeydown);
}
deletePluginState(editor, K_SEARCH);
}
},
};
const imagePastePlugin = {
name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60,
install(editor, options = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const tt = instanceT(editor);
const maxSizeKB = options.maxSizeKB || 500;
const _onPaste = (e) => {
const items = e.clipboardData?.items;
if (!items)
return;
for (const item of items) {
if (item.type?.startsWith('image/')) {
const file = item.getAsFile();
if (!file)
continue;
const sizeKB = file.size / 1024;
if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault();
if (typeof editor.toast === 'function')
editor.toast(tt('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break;
}
e.preventDefault();
const reader = new FileReader();
reader.onload = () => { if (typeof editor.insert === 'function')
editor.insert(`![image-${Date.now().toString(36)}.png](${reader.result})\n`); };
reader.readAsDataURL(file);
break;
}
}
};
editor.textarea.addEventListener('paste', _onPaste);
setPluginState(editor, K_IMAGE_PASTE, _onPaste);
},
destroy(editor) {
const _onPaste = pluginState(editor, K_IMAGE_PASTE);
if (_onPaste && editor?.textarea)
editor.textarea.removeEventListener('paste', _onPaste);
deletePluginState(editor, K_IMAGE_PASTE);
},
};
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;
const tt = instanceT(editor);
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);
}
// 面板引用放入 holder:install 时无法预知未来打开的面板,
// 卸载时经 holder 取实时引用,避免面板残留 DOM。
const st = { panel: null };
const _close = () => { if (st.panel) {
st.panel.remove();
st.panel = null;
} };
const _open = () => {
if (st.panel) {
_close();
return;
}
// i18n-aware shortcut labels(跟随实例语言)
const builtin = [
['Ctrl+B', tt('bold') || 'Bold'], ['Ctrl+I', tt('italic') || 'Italic'],
['Ctrl+U', tt('underline') || 'Underline'], ['Ctrl+K', tt('link') || 'Link'],
['Ctrl+E', tt('code') || 'Code'], ['Ctrl+1/2/3', tt('h1') || 'Heading'],
['Ctrl+Q', tt('quote') || 'Quote'], ['Ctrl+Z', tt('undo') || 'Undo'],
['Ctrl+Y', tt('redo') || 'Redo'], ['Ctrl+S', tt('save') || 'Save'],
['Ctrl+F', tt('search') || 'Search'], ['Ctrl+H', tt('replace') || 'Replace'],
['Tab', tt('indent') || 'Indent'], ['Shift+Tab', tt('outdent') || 'Outdent'],
['?', tt('shortcuts') || '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>⌨️ ${tt('shortcuts') || '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);
st.panel = overlay;
};
const _onKeydown = (e) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
_open();
}
if (e.key === 'Escape' && st.panel)
_close();
};
editor.textarea.addEventListener('keydown', _onKeydown);
setPluginState(editor, K_SHORTCUT, { state: st, _open, _close, _onKeydown });
},
destroy(editor) {
const exposed = pluginState(editor, K_SHORTCUT);
if (exposed) {
if (exposed.state && exposed.state.panel) {
exposed.state.panel.remove();
exposed.state.panel = null;
}
}
const _onKeydown = exposed?._onKeydown;
if (_onKeydown && editor?.textarea)
editor.textarea.removeEventListener('keydown', _onKeydown);
deletePluginState(editor, K_SHORTCUT);
},
};
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) {
if (typeof editor.toast === 'function')
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();
if (typeof editor.setValue === 'function')
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) {
if (typeof editor.toast === 'function')
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;
setPluginState(editor, K_FILESYSTEM, { getHandle: () => _fileHandle });
},
destroy(editor) {
if (editor) {
delete editor.openFile;
delete editor.saveFile;
delete editor.saveFileAs;
delete editor.getFileHandle;
}
deletePluginState(editor, K_FILESYSTEM);
},
};
// ============ 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';
// 外部跟随订阅列表,随实例销毁断开,避免 observer/定时器泄漏
const unsubs = [];
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 = {}) => {
const unsub = followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected));
unsubs.push(unsub);
return unsub;
};
const adopt = () => {
const unsub = editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => { };
unsubs.push(unsub);
return unsub;
};
const watchExternal = (source) => {
const unsub = watch(source, (theme) => apply(theme));
unsubs.push(unsub);
return unsub;
};
const dispose = () => { unsubs.forEach((fn) => { try {
fn();
}
catch (_) { } }); unsubs.length = 0; };
apply(instanceTheme);
return { apply, get, set, toggle, getResolved, getConfig, getVars, syncWithElement, adopt, watch: watchExternal, dispose };
};
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 Commands — editing command implementations
* @module commands
* @version 0.4.0
*/
// ============ Selection wrapping ============
function 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;
}
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
}
// ============ Line prefix toggling ============
function 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;
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
}
// ============ Block insertion ============
function 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;
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
}
function insertLink() {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = ta.value.slice(start, end) || this.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;
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
}
function insertImage() {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = ta.value.slice(start, end) || this.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;
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
}
function insertTable(rows = 3, cols = 3) {
const header = Array.from({ length: cols }, (_, i) => `${this.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`;
insertBlock.call(this, '\n' + md);
}
// ============ Table auto-format ============
function formatTable() {
const ta = this.textarea;
const start = ta.selectionStart;
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;
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));
const colWidths = Array(colCount).fill(3);
allCells.forEach((cells) => {
cells.forEach((cell, ci) => {
colWidths[ci] = Math.max(colWidths[ci], cell.trim().length);
});
});
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(' | ') + ' |';
});
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
}
// ============ Prototype installation ============
const installCommands = (proto) => {
proto._wrapSelection = wrapSelection;
proto._toggleLinePrefix = toggleLinePrefix;
proto._insertBlock = insertBlock;
proto._insertLink = insertLink;
proto._insertImage = insertImage;
proto._insertTable = insertTable;
proto._formatTable = formatTable;
};
/**
* MetonaEditor Floating Toolbar — selection format toolbar
* @module floating-toolbar
* @version 0.4.3
*
* 定位策略(v0.4.3 重做):mirror 镜像测量。
* 创建与 textarea 排版样式一致的隐藏镜像层,在选区锚点(文档序中点)处
* 插入零宽字符 marker,读取其 offsetLeft/offsetTop 得到像素级精确坐标,
* 彻底替代旧版按字符宽度估算的方案(CJK、软换行、tab 宽度、字体
* fallback 的误差全部消除)。锚点滚出可视区自动隐藏;textarea 滚动时
* rAF 节流重定位;divider 拖拽结束 / 窗口 resize 后隐藏待重新触发。
*/
/** 浮动工具栏与选区锚点之间的间距(px) */
const ANCHOR_GAP = 6;
function 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;visibility:hidden;transform:translateY(4px);transition:opacity .15s,transform .15s,visibility .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;visibility:visible;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}.me-float-mirror{position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;z-index:-1;border:0;margin:0}`;
document.head.appendChild(style);
}
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();
const anchor = this._measureSelectionAnchor();
if (!anchor) {
this._hideFloatingToolbar();
return;
}
// 选区锚点行已滚出 textarea 可视区时隐藏(跟随滚动的重定位会再次显示)
if (anchor.y < -parseFloat(getComputedStyle(ta).lineHeight || '22') || anchor.y > ta.clientHeight) {
this._hideFloatingToolbar();
return;
}
const bar = this._floatingToolbar;
// 实测尺寸定位(替代旧版 -80 / 36 / 200 等魔法数字)
const barRect = bar.getBoundingClientRect();
const barW = barRect.width || 170;
const barH = barRect.height || 38;
const taStyle = getComputedStyle(ta);
const lineHeight = parseFloat(taStyle.lineHeight) || 22;
const paneW = this.editorPane.getBoundingClientRect().width;
let top = anchor.y - barH - ANCHOR_GAP;
if (top < ANCHOR_GAP)
top = anchor.y + lineHeight + ANCHOR_GAP;
let left = anchor.x - barW / 2;
left = Math.max(4, Math.min(left, paneW - barW - 4));
if (left < 4)
left = 4; // 极窄面板兜底
bar.style.top = top + 'px';
bar.style.left = left + 'px';
bar.classList.add('me-visible');
};
const hide = () => { this._hideFloatingToolbar(); };
// 滚动跟随:rAF 节流重定位(保持纯事件驱动,不做轮询)
let rafPending = false;
const reposition = () => {
if (rafPending)
return;
rafPending = true;
requestAnimationFrame(() => { rafPending = false; show(); });
};
const onMouseUp = () => setTimeout(show, 0);
const onKeyup = () => {
// selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射
if (ta.selectionStart !== ta.selectionEnd)
setTimeout(show, 0);
else
setTimeout(hide, 0);
};
const onBlur = () => setTimeout(() => {
// 焦点快速抖动场景(如自动化 fill 后还原焦点再 focus 回来、
// 宿主脚本焦点切换):延迟到期时焦点已回到 textarea 则不隐藏
if (document.activeElement === ta)
return;
hide();
}, 300);
const onClick = () => {
if (ta.selectionStart === ta.selectionEnd)
hide();
};
const onScroll = reposition;
// 布局变化后旧坐标失效,直接隐藏(下次选区交互重新显示)
const onResize = () => hide();
ta.addEventListener('mouseup', onMouseUp);
ta.addEventListener('keyup', onKeyup);
ta.addEventListener('blur', onBlur);
ta.addEventListener('click', onClick);
ta.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);
// 事件绑定与启用状态解耦:floatingToolbar:false 构造的实例,
// toggleFloatingToolbar() 打开后仍可正常显示(修复“死开关”)。
this._cleanups.push(() => {
ta.removeEventListener('mouseup', onMouseUp);
ta.removeEventListener('keyup', onKeyup);
ta.removeEventListener('blur', onBlur);
ta.removeEventListener('click', onClick);
ta.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onResize);
});
}
/**
* 镜像测量选区锚点坐标(相对 editorPane 的局部坐标)。
* 锚点 = 选区文档序中点:单行选区即视觉中部,跨行选区约在选区垂直中心。
*/
function measureSelectionAnchor() {
const ta = this.textarea;
if (!ta || !this.editorPane)
return null;
const start = ta.selectionStart;
const end = ta.selectionEnd;
if (start === end)
return null;
const anchor = Math.floor((start + end) / 2);
// 懒创建镜像层并复制 textarea 排版样式(font/行高/padding/换行/tab
let mirror = this._floatMirror;
if (!mirror || !mirror.parentNode) {
mirror = document.createElement('div');
mirror.className = 'me-float-mirror';
this.editorPane.appendChild(mirror);
this._floatMirror = mirror;
}
const cs = getComputedStyle(ta);
const props = [
['fontFamily', cs.fontFamily], ['fontSize', cs.fontSize], ['fontWeight', cs.fontWeight],
['fontStyle', cs.fontStyle], ['letterSpacing', cs.letterSpacing], ['lineHeight', cs.lineHeight],
['paddingTop', cs.paddingTop], ['paddingRight', cs.paddingRight],
['paddingBottom', cs.paddingBottom], ['paddingLeft', cs.paddingLeft],
['whiteSpace', cs.whiteSpace], ['overflowWrap', cs.overflowWrap],
['wordBreak', cs.wordBreak], ['tabSize', cs.tabSize], ['boxSizing', 'border-box'],
];
props.forEach(([k, v]) => { mirror.style[k] = v; });
// 宽度对齐 textarea 文本排版区(clientWidth 含 padding 不含滚动条)
mirror.style.width = ta.clientWidth + 'px';
// 仅需锚点前缀文本:其后内容不影响锚点位置(折行由前缀决定)
mirror.textContent = '';
mirror.appendChild(document.createTextNode(ta.value.slice(0, anchor)));
const marker = document.createElement('span');
marker.textContent = '\u200b';
mirror.appendChild(marker);
// marker 坐标相对 mirrorposition:absolute 的 offsetParentborder 0),
// 即字符相对 textarea border box 的位置;再扣除滚动、平移到 pane 局部坐标
const x = marker.offsetLeft - ta.scrollLeft;
const y = marker.offsetTop - ta.scrollTop;
const taRect = ta.getBoundingClientRect();
const paneRect = this.editorPane.getBoundingClientRect();
return { x: x + taRect.left - paneRect.left, y: y + taRect.top - paneRect.top };
}
function toggleFloatingToolbar() {
this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) {
if (this._floatingToolbar) {
if (this._floatingToolbar.parentNode)
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
}
}
return this;
}
function isFloatingToolbar() { return this._floatingEnabled; }
function buildFloatingToolbar() {
const bar = document.createElement('div');
bar.className = 'me-float-toolbar';
bar.setAttribute('role', 'toolbar');
bar.setAttribute('aria-label', this.t('formatToolbar') || '格式化选区');
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
actions.forEach((action) => {
const btn = this._createBtn(action);
// mousedown 阻止夺焦(textarea 选区与焦点保持);命令执行统一走 click,
// 键盘 Enter/Space 触发的 click 同样生效(可访问性)
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
});
btn.addEventListener('click', () => {
this.exec(action);
this._hideFloatingToolbar();
// Keep selection after exec(延迟回调时实例可能已销毁)
setTimeout(() => { if (!this._destroyed && this.textarea)
this.textarea.focus(); }, 0);
});
bar.appendChild(btn);
});
this.editorPane.appendChild(bar);
this._floatingToolbar = bar;
}
function hideFloatingToolbar() {
if (this._floatingToolbar) {
this._floatingToolbar.classList.remove('me-visible');
}
}
// ============ Prototype installation ============
const installFloatingToolbar = (proto) => {
proto._initFloatingToolbar = initFloatingToolbar;
proto.toggleFloatingToolbar = toggleFloatingToolbar;
proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar;
proto._measureSelectionAnchor = measureSelectionAnchor;
};
/**
* MetonaEditor Context Menu — right-click menu
* @module context-menu
* @version 0.4.0
*/
function registerContextMenu(items = []) {
this._contextMenuItems = items;
return this;
}
function 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));
}
function showContextMenu(e) {
e.preventDefault();
const ta = this.textarea;
const hasSelection = ta && ta.selectionStart !== ta.selectionEnd;
const defaultItems = [
{ label: this.t('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() },
{ label: this.t('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() },
{ sep: true },
{ label: this.t('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
{ label: this.t('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
{ label: this.t('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
{ label: this.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');
// 自定义菜单项的 label / shortcut 经转义后拼接(防 HTML 注入)
el.innerHTML = `<span>${escapeHTML(item.label)}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${escapeHTML(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 });
}
function hideContextMenu() {
const menu = document.querySelector('.me-context-menu');
if (menu)
menu.remove();
}
function 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': {
// 现代浏览器出于安全限制,execCommand('paste') 通常静默失败 —— 提示改用 Ctrl+V
const ok = document.execCommand('paste');
if (!ok)
this.toast(this.t('pasteBlocked') || '无法直接粘贴,请使用 Ctrl+V', { type: 'info' });
break;
}
case 'selectAll':
ta.focus();
ta.select();
break;
default:
this.exec(action);
break;
}
}
// ============ Prototype installation ============
const installContextMenu = (proto) => {
proto.registerContextMenu = registerContextMenu;
proto._bindContextMenu = bindContextMenu;
proto._showContextMenu = showContextMenu;
proto._hideContextMenu = hideContextMenu;
proto._execContextAction = execContextAction;
};
/**
* MetonaEditor Outline — heading navigation panel
* @module outline
* @version 0.4.0
*/
/** querySelector 用的 id 转义:优先原生 CSS.escape,环境缺失时退化为简易转义 */
const cssEscape = (id) => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function')
return CSS.escape(id);
return id.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
};
function 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, ''), srcLine: -1 });
}
if (!headings.length)
return;
// 计算每个渲染标题在源码中的行号(跳过围栏代码块内部),
// 点击时按行号精确定位光标,重复标题文本也不会串位。
const srcLines = [];
const lines = this._value.split('\n');
let inFence = false;
let fenceChar = '';
const isSetextText = (l) => /\S/.test(l) && !/^\s{0,3}([-*+]|\d+\.)\s/.test(l) && !/^\s{0,3}>/.test(l) && !/^\s{0,3}(#{1,6}\s|`{3,}|~{3,})/.test(l);
for (let li = 0; li < lines.length; li++) {
const l = lines[li];
const fence = l.match(/^\s{0,3}(`{3,}|~{3,})/);
if (fence) {
if (!inFence) {
inFence = true;
fenceChar = fence[1][0];
}
else if (fence[1][0] === fenceChar)
inFence = false;
continue;
}
if (inFence)
continue;
if (/^ {0,3}(?:>\s*)*#{1,6}\s+\S/.test(l)) {
srcLines.push(li);
continue;
}
const next = lines[li + 1] || '';
if (li + 1 < lines.length && /^ {0,3}(={3,}|-{3,})\s*$/.test(next) && isSetextText(l))
srcLines.push(li);
}
headings.forEach((h, i) => { h.srcLine = i < srcLines.length ? srcLines[i] : -1; });
const panel = document.createElement('div');
panel.className = 'me-outline';
panel.innerHTML = `<div class="me-outline-title">${this.t('outline') || '大纲'}</div>`;
// 迭代式树构建(栈模拟嵌套 ul),O(n) 而非递归的 O(n²)
const buildTree = (items, minLevel) => {
let h = '';
const stack = [];
const closeTo = (targetLevel) => {
while (stack.length && stack[stack.length - 1] >= targetLevel) {
h += '</li></ul>';
stack.pop();
}
};
for (const item of items) {
if (item.level < minLevel)
continue;
closeTo(item.level);
h += '<ul>';
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.srcLine >= 0 ? item.srcLine + 1 : ''}">${escapeHTML(item.text)}</a>`;
stack.push(item.level);
}
closeTo(-1);
return h;
};
panel.innerHTML += buildTree(headings, 1);
this.el.appendChild(panel);
panel.addEventListener('click', (e) => {
const a = e.target.closest('a');
if (!a)
return;
e.preventDefault();
const id = a.getAttribute('href').slice(1);
const target = this.previewEl.querySelector('#' + cssEscape(id));
if (target)
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
// 光标定位:优先用构建时记录的源码行号(重复标题也正确),兜底 indexOf
const ord = Array.prototype.indexOf.call(panel.querySelectorAll('a'), a);
const lineIdx = ord > -1 && ord < headings.length ? headings[ord].srcLine : -1;
if (lineIdx >= 0) {
const lineText = lines[lineIdx] || '';
const col = (lineText.match(/^ {0,3}(?:>\s*)*#{1,6}\s+/) || lineText.match(/^ {0,3}/) || [''])[0].length;
this.setCursorPosition(lineIdx + 1, col);
}
else {
const idx = this._value.indexOf(target?.textContent || '');
if (idx !== -1) {
this.textarea.focus();
this.textarea.setSelectionRange(idx, idx);
}
}
});
}
function updateOutline() {
if (!this.config.outline)
return;
if (this._outlineTimer)
clearTimeout(this._outlineTimer);
this._outlineTimer = setTimeout(() => this._buildOutline(), 300);
}
function trackOutlineScroll() {
if (!this.config.outline || !this.previewPane)
return;
// 防重复绑定:构造时绑定过、setOutline(true) 再次调用时跳过
if (this._outlineScrollBound)
return;
this._outlineScrollBound = true;
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));
}
// ============ Prototype installation ============
const installOutline = (proto) => {
proto._buildOutline = buildOutline;
proto._updateOutline = updateOutline;
proto._trackOutlineScroll = trackOutlineScroll;
};
/**
* 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 = {}) {
this._statsCache = null;
this._pendingCursor = null;
this._lastSelStart = 0;
this._lastSelEnd = 0;
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 = this.config.zenMode === true;
this._wordWrap = this.config.wordWrap !== false;
this._syncing = false;
this._zenMouseHandler = null;
this._floatingToolbar = null;
this._floatingEnabled = this.config.floatingToolbar !== false;
this._floatMirror = null;
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();
// 实例 i18n 需在 _buildDOM 之前创建:DOM 构建与工具栏渲染会读取实例语言
this._i18nCtx = createInstanceI18n(this);
this._buildDOM();
this._applyZenWidth();
this._themeCtx = createInstanceTheme(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();
// 无条件绑定事件(show 内部检查 _floatingEnabled):
// floatingToolbar:false 构造的实例 toggleFloatingToolbar() 打开后仍可显示
this._initFloatingToolbar();
this.textarea.value = this._value;
if (!this._wordWrap)
this.textarea.style.whiteSpace = 'pre';
this._pushHistory();
this._render();
this._updateWordCount();
this._buildOutline();
if (this._zenMode)
this._setZen(true);
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', this.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;
if (this.config.maxLength && this.config.maxLength > 0)
textarea.maxLength = this.config.maxLength;
textarea.style.tabSize = String(this.config.tabSize || 2);
textarea.placeholder = this.config.placeholder || this.t('placeholder');
textarea.setAttribute('aria-label', this.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', this.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 = this.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(); this._emitCursorEvents(); };
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);
// 全屏模式按 Esc 退出(与 shortcutHelp 面板的 Esc 关闭互不冲突:条件互斥)
const onDocKeydown = (e) => {
if (e.key === 'Escape' && this._fullscreen)
this.exitFullscreen();
};
document.addEventListener('keydown', onDocKeydown);
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);
document.removeEventListener('keydown', onDocKeydown);
});
}
_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;
}
const old = this._value;
this._value = ta.value;
this._pushHistory();
this._afterProgrammaticEdit(old);
}
_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();
// pane 宽度变化导致折行重排,旧定位坐标失效,隐藏待重新触发
this._hideFloatingToolbar();
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}
_saveDividerPosition() {
try {
const epFlex = this.editorPane.style.flex;
if (epFlex)
localStorage.setItem(`metona-editor-divider-${this.id}`, epFlex);
}
catch (_) { }
}
_restoreDividerPosition() {
try {
const saved = localStorage.getItem(`metona-editor-divider-${this.id}`);
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') {
this._renderGutter();
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: this.getLocale() });
}
catch (err) {
console.error('MeEditor render error:', err);
html = `<p style="color:#ef4444">${this.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 stats = this.getStats();
const lines = stats.lines > 0 ? stats.lines : 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');
}
/** 选区/光标事件:与浮动工具栏解耦,任何实例都会触发 */
_emitCursorEvents() {
if (!this.textarea)
return;
const s = this.textarea.selectionStart;
const e = this.textarea.selectionEnd;
if (s !== this._lastSelStart || e !== this._lastSelEnd) {
this._lastSelStart = s;
this._lastSelEnd = e;
this._emit('selectionChange', { start: s, end: e, text: this._value.slice(s, e) });
}
this._emit('cursorMove', this.getCursorPosition());
}
_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();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
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;
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
return;
}
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
if (quoteMatch) {
const prefix = quoteMatch[1];
const content = quoteMatch[2];
if (!content.trim()) {
e.preventDefault();
const oldV = this._value;
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
ta.selectionStart = ta.selectionEnd = lineStart;
this._value = ta.value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
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;
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
}
}
_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 oldV = this._value;
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._afterProgrammaticEdit(oldV);
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 oldV = this._value;
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._afterProgrammaticEdit(oldV);
}
_bindDragDrop() {
const ta = this.textarea;
if (!ta)
return;
const DROP_IMAGE_MAX_KB = 500;
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 sizeKB = file.size / 1024;
if (sizeKB > DROP_IMAGE_MAX_KB) {
this.toast(this.t('imageTooLarge', { size: sizeKB.toFixed(0), max: DROP_IMAGE_MAX_KB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${DROP_IMAGE_MAX_KB}KB)`, { type: 'warning' });
return;
}
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); });
}
_scheduleHistory() { if (this._historyTimer)
clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
/**
* 程序化编辑路径(命令 / 快捷键 / 插件 / API 直改 textarea)的统一刷新管线。
* 此前 8 条路径的 预览/行号/字数/大纲 刷新与事件发射参差不齐,
* 现统一为:渲染 + 行号 + 字数 + 大纲 + input/change 事件 + 回调 + before/afterChange 钩子。
*/
_afterProgrammaticEdit(oldValue) {
MarkdownEditor.trigger('beforeChange', this);
this._emit('beforeChange', oldValue, this._value);
this._render();
this._renderGutter();
this._updateWordCount();
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);
}
_pushHistory() {
// Enforce maxLength as a last-resort guard for programmatic edit paths
// (tab/indent, smart-enter, bracket auto-close, exec commands) that write
// to the textarea directly and bypass the native maxlength attribute.
if (this.config.maxLength && this.config.maxLength > 0 && this._value.length > this.config.maxLength) {
this._value = this._value.slice(0, this.config.maxLength);
if (this.textarea)
this.textarea.value = this._value;
}
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._captureCursor(); this._historyIndex--; this._applyHistory(); this._restoreCapturedCursor(); return this; }
redo() { if (this._historyIndex >= this._history.length - 1)
return this; this._captureCursor(); this._historyIndex++; this._applyHistory(); this._restoreCapturedCursor(); return this; }
canUndo() { return this._historyIndex > 0; }
canRedo() { return this._historyIndex < this._history.length - 1; }
_applyHistory() {
// 撤销/重做前记录预览区滚动比例,重渲染后按比例恢复,不再跳顶
const pv = this.previewPane;
const pvMax = pv ? pv.scrollHeight - pv.clientHeight : 0;
const pvRatio = pvMax > 0 ? pv.scrollTop / pvMax : null;
const oldV = this._value;
this._value = this._history[this._historyIndex];
this.textarea.value = this._value;
this._afterProgrammaticEdit(oldV);
if (this.gutter)
this.gutter.scrollTop = this.textarea.scrollTop;
if (pv && pvRatio != null) {
const max = pv.scrollHeight - pv.clientHeight;
if (max > 0)
pv.scrollTop = pvRatio * max;
}
}
_captureCursor() {
if (!this.textarea)
return;
this._pendingCursor = { start: this.textarea.selectionStart, end: this.textarea.selectionEnd };
}
_restoreCapturedCursor() {
if (!this._pendingCursor || !this.textarea)
return;
const len = this._value.length;
const start = Math.min(this._pendingCursor.start, len);
const end = Math.min(this._pendingCursor.end, len);
try {
this.textarea.setSelectionRange(start, end);
}
catch { }
this._pendingCursor = null;
}
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;
}
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._buildOutline();
} this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${this.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 ? (this.t('fullscreenExit') || '退出全屏') : (this.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._setZen(!this._zenMode); return this; }
_setZen(on) {
this._zenMode = !!on;
if (!this.el)
return;
this.el.classList.toggle('me-zen', this._zenMode);
this._applyZenWidth();
if (this._zenMode && this.toolbarEl) {
if (!this._zenMouseHandler) {
this._zenMouseHandler = (e) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; };
document.addEventListener('mousemove', this._zenMouseHandler);
}
this.toolbarEl.style.transition = 'opacity 0.2s';
this.toolbarEl.style.opacity = '0';
this.toolbarEl.style.pointerEvents = 'none';
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);
}
isZen() { return this._zenMode; }
_applyZenWidth() {
if (!this.el)
return;
const width = this.config.zenMaxWidth;
if (width == null || width === false || width === '') {
this.el.style.removeProperty('--md-zen-max-width');
}
else {
this.el.style.setProperty('--md-zen-max-width', typeof width === 'number' ? `${width}px` : String(width));
}
}
setZenMaxWidth(width) {
this.config.zenMaxWidth = width;
this._applyZenWidth();
return this;
}
getZenMaxWidth() { return this.config.zenMaxWidth ?? 960; }
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; }
/** 运行时开关大纲面板(公开 API,替代直接操作 config/_buildOutline 的私有用法) */
setOutline(on) {
this.config.outline = !!on;
if (this._destroyed || !this.el)
return this;
if (this.config.outline) {
this._buildOutline();
this._trackOutlineScroll();
}
else {
const panel = this.el.querySelector('.me-outline');
if (panel)
panel.remove();
}
return this;
}
isOutline() { return !!this.config.outline; }
_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 stats = this.getStats();
this.statusEl.textContent = [`${this.t('characters')}: ${stats.characters}`, `${this.t('words')}: ${stats.words}`, `${this.t('lines')}: ${stats.lines}`, `${this.t('readingTime')}: ${stats.readingTime} ${this.t('minutes')}`].join(' · ');
}
getStats() {
const text = this._value || '';
if (this._statsCache && this._statsCache.value === text)
return this._statsCache.stats;
const stats = this._computeStats(text);
this._statsCache = { value: text, stats };
return stats;
}
/**
* 增量统计:基于上一次统计 + 差异区间(共同前缀/后缀之间的片段)计算,
* 避免每次击键对全文做 O(n) 正则。差异区间左右扩展至单词边界,
* 保证英文单词 / 中文串不会被边界切断,diff 结果与全量计算一致。
*/
_computeStats(text) {
const prev = this._statsCache;
if (!prev) {
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;
return { characters: text.length, words, chineseChars: cnChars, englishWords: enWords, lines, readingTime: Math.max(1, Math.ceil(words / 300)) };
}
const oldText = prev.value;
const oldStats = prev.stats;
const isWordChar = (c) => !!c && /[\w\u4e00-\u9fff]/.test(c);
let p = 0;
const maxP = Math.min(oldText.length, text.length);
while (p < maxP && oldText[p] === text[p])
p++;
let s = 0;
const maxS = maxP - p;
while (s < maxS && oldText[oldText.length - 1 - s] === text[text.length - 1 - s])
s++;
if (p === oldText.length && p === text.length)
return oldStats;
let chunkStart = p;
while (chunkStart > 0 && (isWordChar(oldText[chunkStart - 1]) || isWordChar(text[chunkStart - 1])))
chunkStart--;
let chunkEndOld = oldText.length - s;
let chunkEndNew = text.length - s;
while (chunkEndOld < oldText.length && isWordChar(oldText[chunkEndOld]))
chunkEndOld++;
while (chunkEndNew < text.length && isWordChar(text[chunkEndNew]))
chunkEndNew++;
const oldChunk = oldText.slice(chunkStart, chunkEndOld);
const newChunk = text.slice(chunkStart, chunkEndNew);
const cnDelta = (newChunk.match(/[\u4e00-\u9fa5]/g) || []).length - (oldChunk.match(/[\u4e00-\u9fa5]/g) || []).length;
const enDelta = (newChunk.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length - (oldChunk.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
const lineDelta = (newChunk.match(/\n/g) || []).length - (oldChunk.match(/\n/g) || []).length;
const chineseChars = oldStats.chineseChars + cnDelta;
const englishWords = Math.max(0, oldStats.englishWords + enDelta);
// 行数 = 基础行(空文本 0 / 非空 1) + \n 计数;\n 计数增量更新
const oldBase = oldText ? 1 : 0;
const lines = (text ? 1 : 0) + (oldStats.lines - oldBase) + lineDelta;
const words = chineseChars + englishWords;
return { characters: text.length, words, chineseChars, englishWords, lines, readingTime: Math.max(1, Math.ceil(words / 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 taStyle = getComputedStyle(this.textarea);
const lineHeight = parseFloat(taStyle.lineHeight) || 22;
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;
// 函数式替换:替换文本按字面处理,$ 无特殊语义
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, () => replace));
this.textarea.value = this._value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
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;
const oldV = this._value;
this._value = this._limitLength(this._value.replace(re, replace));
this.textarea.value = this._value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
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];
}
_limitLength(value) {
const max = this.config.maxLength;
return max && max > 0 ? value.slice(0, max) : value;
}
getValue() { return this._destroyed ? '' : this._value; }
setValue(md, opts = {}) {
if (this._destroyed)
return this;
md = this._limitLength(md || '');
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: this.getLocale() }); 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) {
// ClipboardItem 在 Firefox 等环境缺失,构造即抛 TypeError —— 降级为纯文本复制
if (typeof ClipboardItem !== 'undefined') {
try {
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;
}
catch (_) { /* fallthrough 降级 */ }
}
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
}
return this;
}
insert(text, opts = {}) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const inserted = this._limitLength(ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start));
ta.value = inserted;
ta.focus();
ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length);
const oldV = this._value;
this._value = ta.value;
this._pushHistory();
this._afterProgrammaticEdit(oldV);
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 (this._plugins.some((x) => x.name === merged.name)) {
console.warn(`MeEditor: plugin "${merged.name}" already installed, skipped`);
return this;
}
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.toLowerCase() !== combo.toLowerCase()); 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; }
toast(message, opts = {}) {
if (!this.el || typeof document === 'undefined')
return this;
const { type = 'info', duration = 3000, animation = 'fade' } = opts;
const anim = ANIMATIONS[animation] || ANIMATIONS.fade;
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>`;
toast.style.transition = `opacity ${anim.duration}ms ${anim.easing}, transform ${anim.duration}ms ${anim.easing}, filter ${anim.duration}ms ${anim.easing}`;
for (const [k, v] of Object.entries(anim.enter))
toast.style[k] = v;
this.el.appendChild(toast);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
for (const k of Object.keys(anim.enter))
toast.style[k] = '';
});
});
const remove = () => {
for (const [k, v] of Object.entries(anim.leave))
toast.style[k] = v;
setTimeout(() => { if (toast.parentNode)
toast.parentNode.removeChild(toast); }, anim.duration);
};
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: this.getLocale(), 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);
this._cleanups.forEach((fn) => { try {
fn();
}
catch (_) { } });
this._cleanups = [];
if (this._zenMouseHandler) {
document.removeEventListener('mousemove', this._zenMouseHandler);
this._zenMouseHandler = null;
}
if (this._themeCtx && typeof this._themeCtx.dispose === 'function') {
try {
this._themeCtx.dispose();
}
catch (_) { }
}
this._shortcuts = [];
this._contextMenuItems = [];
this._customActions = {};
if (this._floatingToolbar && this._floatingToolbar.parentNode)
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
if (this._floatMirror && this._floatMirror.parentNode)
this._floatMirror.parentNode.removeChild(this._floatMirror);
this._floatMirror = 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();
// Install split-out modules onto the prototype (keeps `this` binding and
// preserves the existing instance API surface for consumers and tests)
installCommands(MarkdownEditor.prototype);
installFloatingToolbar(MarkdownEditor.prototype);
installContextMenu(MarkdownEditor.prototype);
installOutline(MarkdownEditor.prototype);
/**
* 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 Highlight — built-in lightweight syntax highlighter
* @module highlight
* @version 0.4.0
*
* Zero-dependency tokenizer for common languages. Safe by construction:
* input is HTML-escaped first, then annotated with <span> classes on the
* escaped text (no raw HTML can leak through).
*/
/**
* Environment-agnostic escaping for tokenizer input.
* Unlike the `escapeHTML` util (whose quote handling differs between browser
* DOM and Node), we only escape `& < >` here so string/comment token rules
* behave identically in both environments. Quote characters are safe inside
* text nodes and do not need escaping.
*/
const escapeText = (s) => {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
};
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const buildRules = (lang) => {
const rules = [];
// Comments (highest priority; protects keywords/strings inside)
if (lang.hasHashComments) {
rules.push({ cls: 'me-hl-comment', re: /#[^\n]*/y });
}
else {
rules.push({ cls: 'me-hl-comment', re: /\/\/[^\n]*/y });
}
if (lang.hasBlocks)
rules.push({ cls: 'me-hl-comment', re: /\/\*[\s\S]*?\*\//y });
// Strings (double + single quotes with backslash escapes)
const strRe = lang.singleQuoteStrings === false
? /"(?:\\.|[^"\\\n])*"/y
: /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/y;
rules.push({ cls: 'me-hl-string', re: strRe });
if (lang.hasTemplateStrings)
rules.push({ cls: 'me-hl-string', re: /`(?:\\.|[^`\\])*`/y });
// Numbers
rules.push({ cls: 'me-hl-number', re: /\b0x[\da-fA-F_]+\b|\b\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?\b/y });
// Keywords
if (lang.keywords.length) {
rules.push({ cls: 'me-hl-keyword', re: new RegExp(`\\b(?:${lang.keywords.map(escapeRe).join('|')})\\b`, 'y') });
}
// Builtins / globals
if (lang.builtins.length) {
rules.push({ cls: 'me-hl-builtin', re: new RegExp(`\\b(?:${lang.builtins.map(escapeRe).join('|')})\\b`, 'y') });
}
// Function calls: identifier immediately followed by (
rules.push({ cls: 'me-hl-function', re: /[A-Za-z_$][\w$]*(?=\s*\()/y });
return rules;
};
const tokenize = (text, rules) => {
let out = '';
let pos = 0;
const len = text.length;
while (pos < len) {
let matched = false;
for (const rule of rules) {
rule.re.lastIndex = pos;
const m = rule.re.exec(text);
if (m && m.index === pos && m[0].length > 0) {
out += `<span class="${rule.cls}">${m[0]}</span>`;
pos += m[0].length;
matched = true;
break;
}
}
if (!matched) {
out += text[pos];
pos++;
}
}
return out;
};
// ============ Rule cache ============
// 规则正则与语言定义绑定且只读(tokenize 同步执行,每次先重置 lastIndex,
// 共享正则实例在单线程环境安全)。避免每次 highlight 调用重建正则。
const ruleCache = new Map();
const getRules = (lang) => {
let rules = ruleCache.get(lang);
if (!rules) {
const def = LANGUAGES[lang];
rules = def ? buildRules(def) : [];
ruleCache.set(lang, rules);
}
return rules;
};
// ============ Language definitions ============
const LANGUAGES = {
javascript: {
keywords: ['async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'from', 'function', 'get', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'of', 'return', 'set', 'static', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield'],
builtins: ['console', 'document', 'window', 'globalThis', 'Math', 'JSON', 'Promise', 'Object', 'Array', 'String', 'Number', 'Boolean', 'Map', 'Set', 'Symbol', 'RegExp', 'Date', 'Error', 'parseInt', 'parseFloat', 'isNaN', 'setTimeout', 'setInterval', 'fetch', 'require', 'module', 'exports', 'process', 'Buffer'],
hasBlocks: true,
hasTemplateStrings: true,
},
typescript: {
keywords: ['abstract', 'any', 'as', 'async', 'await', 'boolean', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'declare', 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'finally', 'for', 'from', 'function', 'get', 'if', 'implements', 'import', 'in', 'infer', 'instanceof', 'interface', 'is', 'keyof', 'let', 'namespace', 'never', 'new', 'of', 'override', 'private', 'protected', 'public', 'readonly', 'return', 'satisfies', 'set', 'static', 'string', 'super', 'switch', 'symbol', 'this', 'throw', 'try', 'type', 'typeof', 'undefined', 'unique', 'unknown', 'var', 'void', 'while', 'with', 'yield'],
builtins: ['console', 'document', 'window', 'globalThis', 'Math', 'JSON', 'Promise', 'Object', 'Array', 'String', 'Number', 'Boolean', 'Map', 'Set', 'Symbol', 'RegExp', 'Date', 'Error', 'parseInt', 'parseFloat', 'setTimeout', 'setInterval', 'fetch', 'require', 'module', 'exports', 'process', 'Buffer'],
hasBlocks: true,
hasTemplateStrings: true,
},
jsx: {
keywords: ['async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', 'do', 'else', 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'of', 'return', 'static', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', 'useState', 'useEffect', 'useRef', 'useMemo', 'useCallback'],
builtins: ['console', 'document', 'window', 'Math', 'JSON', 'Promise', 'Object', 'Array', 'String', 'Number', 'Map', 'Set', 'React'],
hasBlocks: true,
hasTemplateStrings: true,
},
tsx: {
keywords: ['abstract', 'any', 'as', 'async', 'await', 'boolean', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', 'declare', 'do', 'else', 'enum', 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'implements', 'import', 'in', 'infer', 'instanceof', 'interface', 'is', 'keyof', 'let', 'namespace', 'never', 'new', 'of', 'override', 'private', 'protected', 'public', 'readonly', 'return', 'static', 'string', 'super', 'switch', 'symbol', 'this', 'throw', 'try', 'type', 'typeof', 'undefined', 'unknown', 'var', 'void', 'while', 'with', 'yield', 'useState', 'useEffect', 'useRef'],
builtins: ['console', 'document', 'window', 'Math', 'JSON', 'Promise', 'Object', 'Array', 'String', 'Number', 'Map', 'Set', 'React'],
hasBlocks: true,
hasTemplateStrings: true,
},
python: {
keywords: ['and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'False', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'None', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'True', 'try', 'while', 'with', 'yield'],
builtins: ['print', 'len', 'range', 'type', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', 'bool', 'enumerate', 'zip', 'map', 'filter', 'sum', 'min', 'max', 'abs', 'round', 'open', 'input', 'repr', 'sorted', 'object', 'super', 'self'],
hasHashComments: true,
},
bash: {
keywords: ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'until', 'do', 'done', 'case', 'esac', 'function', 'in', 'return', 'break', 'continue', 'export', 'local', 'readonly', 'set', 'unset', 'shift', 'source', 'alias', 'declare', 'echo', 'exit'],
builtins: ['echo', 'printf', 'cd', 'pwd', 'ls', 'mkdir', 'rm', 'cp', 'mv', 'cat', 'grep', 'sed', 'awk', 'curl', 'wget', 'npm', 'node', 'git', 'docker', 'sudo', 'find', 'xargs', 'tar', 'zip', 'unzip', 'chmod', 'chown', 'touch', 'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tee', 'ps', 'kill', 'top', 'clear', 'history', 'man', 'which'],
hasHashComments: true,
singleQuoteStrings: false,
},
shell: { hasHashComments: true, keywords: ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'in', 'function', 'return', 'break', 'continue', 'export', 'local', 'echo', 'exit'], builtins: ['echo', 'cd', 'pwd', 'ls', 'rm', 'cp', 'mv', 'cat', 'grep', 'sed', 'awk', 'curl', 'wget', 'git', 'sudo', 'find', 'chmod', 'chown', 'touch', 'head', 'tail', 'wc', 'sort', 'tee', 'ps', 'kill', 'clear', 'man', 'which'], singleQuoteStrings: false },
css: {
keywords: ['important', 'inherit', 'initial', 'unset', 'none', 'auto', 'absolute', 'relative', 'fixed', 'static', 'sticky', 'flex', 'grid', 'block', 'inline', 'inline-block', 'hidden', 'visible', 'bold', 'normal', 'italic', 'solid', 'dashed', 'dotted', 'transparent', 'center', 'left', 'right', 'top', 'bottom', 'wrap', 'nowrap', 'repeat', 'cover', 'contain', 'pointer', 'auto'],
builtins: ['var', 'calc', 'min', 'max', 'clamp', 'rgb', 'rgba', 'hsl', 'hsla', 'url', 'linear-gradient', 'radial-gradient', 'translate', 'scale', 'rotate', 'repeat', 'minmax', 'fit-content', 'attr', 'counter'],
hasBlocks: true,
singleQuoteStrings: false,
},
html: {
keywords: ['html', 'head', 'body', 'div', 'span', 'p', 'a', 'img', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'section', 'article', 'nav', 'header', 'footer', 'main', 'aside', 'form', 'input', 'button', 'select', 'option', 'textarea', 'label', 'script', 'style', 'link', 'meta', 'title', 'iframe', 'video', 'audio', 'canvas', 'svg', 'strong', 'em', 'code', 'pre', 'blockquote', 'br', 'hr', 'template'],
builtins: [],
singleQuoteStrings: false,
},
json: {
keywords: ['true', 'false', 'null'],
builtins: [],
singleQuoteStrings: false,
},
yaml: {
keywords: ['true', 'false', 'null', 'yes', 'no', 'on', 'off'],
builtins: [],
hasHashComments: true,
singleQuoteStrings: false,
},
markdown: {
keywords: [],
builtins: [],
hasHashComments: true,
singleQuoteStrings: false,
},
md: { keywords: [], builtins: [], hasHashComments: true, singleQuoteStrings: false },
java: {
keywords: ['abstract', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class', 'const', 'continue', 'default', 'do', 'double', 'else', 'enum', 'extends', 'final', 'finally', 'float', 'for', 'goto', 'if', 'implements', 'import', 'instanceof', 'int', 'interface', 'long', 'native', 'new', 'package', 'private', 'protected', 'public', 'return', 'short', 'static', 'strictfp', 'super', 'switch', 'synchronized', 'this', 'throw', 'throws', 'transient', 'try', 'void', 'volatile', 'while', 'var'],
builtins: ['System', 'String', 'Integer', 'Double', 'Math', 'Object', 'Class', 'Exception', 'Thread', 'Arrays', 'List', 'ArrayList', 'Map', 'HashMap', 'Set', 'HashSet', 'Optional', 'Stream', 'Collectors', 'Objects'],
hasBlocks: true,
},
go: {
keywords: ['break', 'case', 'chan', 'const', 'continue', 'default', 'defer', 'else', 'fallthrough', 'for', 'func', 'go', 'goto', 'if', 'import', 'interface', 'map', 'package', 'range', 'return', 'select', 'struct', 'switch', 'type', 'var'],
builtins: ['fmt', 'len', 'cap', 'make', 'new', 'append', 'copy', 'panic', 'recover', 'error', 'string', 'int', 'int64', 'uint', 'bool', 'byte', 'rune', 'float64', 'nil', 'print', 'println', 'Close', 'Error'],
hasBlocks: true,
singleQuoteStrings: false,
},
rust: {
keywords: ['as', 'async', 'await', 'break', 'const', 'continue', 'crate', 'dyn', 'else', 'enum', 'extern', 'false', 'fn', 'for', 'if', 'impl', 'in', 'let', 'loop', 'match', 'mod', 'move', 'mut', 'pub', 'ref', 'return', 'self', 'Self', 'static', 'struct', 'super', 'trait', 'true', 'type', 'unsafe', 'use', 'where', 'while'],
builtins: ['println', 'print', 'eprintln', 'format', 'vec', 'String', 'str', 'Option', 'Result', 'Some', 'None', 'Ok', 'Err', 'Vec', 'Box', 'Rc', 'Arc', 'HashMap', 'Iterator', 'match'],
hasBlocks: true,
singleQuoteStrings: false,
},
};
// ============ Public API ============
const aliasMap = {
js: 'javascript', ts: 'typescript', tsx: 'tsx', jsx: 'jsx',
py: 'python', sh: 'bash', shell: 'shell', zsh: 'bash',
yml: 'yaml', mjs: 'javascript', cjs: 'javascript', md: 'markdown',
htm: 'html', c: 'go', cpp: 'go', cs: 'java',
};
/** Map a code-block language hint to the canonical language name */
const normalizeLanguage = (lang) => {
const l = (lang || '').trim().toLowerCase();
if (!l)
return '';
return aliasMap[l] || (LANGUAGES[l] ? l : '');
};
/** Highlight code with the built-in tokenizer. Falls back to escaped plain text. */
const highlight = (code, lang) => {
const canonical = normalizeLanguage(lang);
const def = LANGUAGES[canonical];
const escaped = escapeText(code);
if (!def)
return escaped;
return tokenize(escaped, getRules(canonical));
};
/** Register or override a language definition */
const registerLanguage = (name, def) => {
LANGUAGES[name.toLowerCase()] = def;
ruleCache.delete(name.toLowerCase());
};
const getSupportedLanguages = () => Object.keys(LANGUAGES);
/**
* MetonaEditor — Type-safe, lightweight Markdown Editor
* @module metona-editor
* @version 0.4.3
*/
const VERSION = '0.4.3';
const globalPlugins = [];
// 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件
// searchReplace / shortcutHelp / imagePaste)才能正常生效。
const injectGlobalPlugins = (editor) => {
if (!editor || editor.isDestroyed())
return;
globalPlugins.forEach((p) => { try {
editor.use(p);
}
catch (e) {
console.error('MeEditor global plugin install error:', e);
} });
};
MarkdownEditor.on('afterCreate', injectGlobalPlugins);
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;
// 清理全局静态钩子(beforeCreate/afterCreate/beforeChange 等),整体复位;
// 随后重建内部 afterCreate 插件注入钩子,保证 destroy 后 MeEditor.use() 仍可用。
try {
MarkdownEditor._hooks.clear();
}
catch (_) { }
MarkdownEditor.on('afterCreate', injectGlobalPlugins);
}
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,
highlight, normalizeLanguage, registerLanguage, getSupportedLanguages,
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.getSupportedLanguages = getSupportedLanguages;
exports.highlight = highlight;
exports.i18nUtils = i18nUtils;
exports.loadRemote = loadRemote;
exports.meEditor = api;
exports.normalizeLanguage = normalizeLanguage;
exports.off = off;
exports.on = on;
exports.parseMarkdown = parseMarkdown;
exports.parseTokens = parseTokens;
exports.pluginUtils = pluginUtils;
exports.presetPlugins = presetPlugins;
exports.registerBlockHandler = registerBlockHandler;
exports.registerLanguage = registerLanguage;
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.map