4527 lines
206 KiB
JavaScript
4527 lines
206 KiB
JavaScript
'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, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
};
|
||
/** Detect dark mode preference */
|
||
const prefersDark = () => {
|
||
if (typeof window === 'undefined' || !window.matchMedia)
|
||
return false;
|
||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||
};
|
||
/** Check if running in browser */
|
||
const isBrowser = () => {
|
||
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
||
};
|
||
|
||
/**
|
||
* MetonaEditor Icons — SVG toolbar icons
|
||
* @module icons
|
||
* @version 0.2.0
|
||
*/
|
||
const ICONS = {
|
||
bold: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>`,
|
||
italic: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>`,
|
||
underline: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 0 0 6 6 6 6 0 0 0 6-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>`,
|
||
strikethrough: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17.3 19c.6-1.2 1-2.5 1-3.8 0-4.3-3.5-7.2-7.3-7.2-3.8 0-7.3 2.9-7.3 7.2 0 1.3.4 2.6 1 3.8"/><line x1="4" y1="12" x2="20" y2="12"/></svg>`,
|
||
h1: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17 12l3-2v8"/></svg>`,
|
||
h2: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M19 12v4"/><path d="M22 12h-4"/></svg>`,
|
||
h3: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-2 1-3 3-3s2 1 2 3"/></svg>`,
|
||
quote: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>`,
|
||
code: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`,
|
||
link: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`,
|
||
image: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
|
||
table: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>`,
|
||
ul: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
|
||
ol: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 14h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>`,
|
||
indent: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 8 7 12 3 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/></svg>`,
|
||
outdent: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="7 8 3 12 7 16"/><line x1="21" y1="12" x2="11" y2="12"/><line x1="21" y1="6" x2="11" y2="6"/><line x1="21" y1="18" x2="11" y2="18"/></svg>`,
|
||
hr: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"/></svg>`,
|
||
undo: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>`,
|
||
redo: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`,
|
||
preview: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`,
|
||
split: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>`,
|
||
edit: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
|
||
fullscreen: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>`,
|
||
theme: `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`,
|
||
};
|
||
|
||
/**
|
||
* MetonaEditor Locales — translation data
|
||
* @module locales
|
||
* @version 0.2.3
|
||
*/
|
||
const LOCALES = {
|
||
'zh-CN': {
|
||
bold: '粗体', italic: '斜体', underline: '下划线', strikethrough: '删除线',
|
||
h1: '标题1', h2: '标题2', h3: '标题3', quote: '引用', code: '代码',
|
||
link: '链接', image: '图片', table: '表格', ul: '无序列表', ol: '有序列表',
|
||
indent: '增加缩进', outdent: '减少缩进', hr: '分隔线', undo: '撤销', redo: '重做',
|
||
edit: '编辑', split: '分屏', preview: '预览', fullscreen: '全屏',
|
||
fullscreenExit: '退出全屏', theme: '主题', light: '亮色', dark: '暗色',
|
||
auto: '自动', warm: '暖色', wordCount: '字数统计', characters: '字符',
|
||
words: '词数', lines: '行数', readingTime: '阅读', minutes: '分钟',
|
||
placeholder: '开始输入 Markdown...', empty: '暂无内容', copied: '已复制',
|
||
copyContent: '复制内容', copyHTML: '复制 HTML', copySuccess: '复制成功',
|
||
copyFailed: '复制失败', clearContent: '清空内容', clearConfirm: '确定要清空所有内容吗?',
|
||
linkPlaceholder: '请输入链接地址', imagePlaceholder: '请输入图片地址',
|
||
altPlaceholder: '请输入替代文本', tableRows: '行数', tableCols: '列数',
|
||
confirm: '确认', cancel: '取消', exportMarkdown: '导出 Markdown',
|
||
exportHTML: '导出 HTML', search: '搜索', replace: '替换', replaceAll: '全部替换',
|
||
searchPlaceholder: '查找内容', replacePlaceholder: '替换为',
|
||
findNext: '查找下一个', findPrev: '查找上一个', matchCase: '区分大小写',
|
||
wholeWord: '全字匹配', close: '关闭', open: '打开', save: '保存',
|
||
saved: '已保存', saving: '保存中...', delete: '删除', confirmDelete: '确定要删除吗?',
|
||
unsavedChanges: '有未保存的更改', error: '错误', success: '成功',
|
||
warning: '警告', info: '信息', loading: '加载中...', retry: '重试',
|
||
renderError: '渲染失败',
|
||
outline: '大纲',
|
||
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
|
||
},
|
||
'en-US': {
|
||
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
|
||
h1: 'Heading 1', h2: 'Heading 2', h3: 'Heading 3', quote: 'Quote', code: 'Code',
|
||
link: 'Link', image: 'Image', table: 'Table', ul: 'Bullet List', ol: 'Numbered List',
|
||
indent: 'Indent', outdent: 'Outdent', hr: 'Horizontal Rule', undo: 'Undo', redo: 'Redo',
|
||
edit: 'Edit', split: 'Split', preview: 'Preview', fullscreen: 'Fullscreen',
|
||
fullscreenExit: 'Exit Fullscreen', theme: 'Theme', light: 'Light', dark: 'Dark',
|
||
auto: 'Auto', warm: 'Warm', wordCount: 'Word Count', characters: 'Characters',
|
||
words: 'Words', lines: 'Lines', readingTime: 'Reading', minutes: 'min',
|
||
placeholder: 'Start typing Markdown...', empty: 'No content', copied: 'Copied',
|
||
copyContent: 'Copy Content', copyHTML: 'Copy HTML', copySuccess: 'Copied successfully',
|
||
copyFailed: 'Copy failed', clearContent: 'Clear Content', clearConfirm: 'Clear all content?',
|
||
linkPlaceholder: 'Enter link URL', imagePlaceholder: 'Enter image URL',
|
||
altPlaceholder: 'Enter alt text', tableRows: 'Rows', tableCols: 'Columns',
|
||
confirm: 'Confirm', cancel: 'Cancel', exportMarkdown: 'Export Markdown',
|
||
exportHTML: 'Export HTML', search: 'Search', replace: 'Replace', replaceAll: 'Replace All',
|
||
searchPlaceholder: 'Find', replacePlaceholder: 'Replace with',
|
||
findNext: 'Find Next', findPrev: 'Find Previous', matchCase: 'Match Case',
|
||
wholeWord: 'Whole Word', close: 'Close', open: 'Open', save: 'Save',
|
||
saved: 'Saved', saving: 'Saving...', delete: 'Delete', confirmDelete: 'Are you sure you want to delete?',
|
||
unsavedChanges: 'You have unsaved changes', error: 'Error', success: 'Success',
|
||
warning: 'Warning', info: 'Info', loading: 'Loading...', retry: 'Retry',
|
||
renderError: 'Render failed',
|
||
outline: 'Outline',
|
||
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
|
||
},
|
||
ja: {
|
||
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
|
||
h1: '見出し1', h2: '見出し2', h3: '見出し3', quote: '引用', code: 'コード',
|
||
link: 'リンク', image: '画像', table: '表', ul: '箇条書き', ol: '番号付きリスト',
|
||
indent: 'インデント', outdent: 'インデント解除', hr: '水平線', undo: '元に戻す', redo: 'やり直し',
|
||
edit: '編集', split: '分割', preview: 'プレビュー', fullscreen: '全画面',
|
||
fullscreenExit: '全画面解除', theme: 'テーマ', light: 'ライト', dark: 'ダーク',
|
||
auto: '自動', warm: 'ウォーム', wordCount: '文字数', characters: '文字',
|
||
words: '単語', lines: '行', readingTime: '読了時間', minutes: '分',
|
||
placeholder: 'Markdownを入力...', empty: '内容なし', copied: 'コピー済み',
|
||
copyContent: '内容をコピー', copyHTML: 'HTMLをコピー', copySuccess: 'コピー成功',
|
||
copyFailed: 'コピー失敗', clearContent: '内容をクリア', clearConfirm: 'すべての内容をクリアしますか?',
|
||
linkPlaceholder: 'リンクURLを入力', imagePlaceholder: '画像URLを入力',
|
||
altPlaceholder: '代替テキストを入力', tableRows: '行', tableCols: '列',
|
||
confirm: '確認', cancel: 'キャンセル', exportMarkdown: 'Markdownエクスポート',
|
||
exportHTML: 'HTMLエクスポート', search: '検索', replace: '置換', replaceAll: 'すべて置換',
|
||
searchPlaceholder: '検索', replacePlaceholder: '置換後',
|
||
findNext: '次を検索', findPrev: '前を検索', matchCase: '大文字小文字',
|
||
wholeWord: '単語単位', close: '閉じる', open: '開く', save: '保存',
|
||
saved: '保存済み', saving: '保存中...', delete: '削除', confirmDelete: '削除してもよろしいですか?',
|
||
unsavedChanges: '未保存の変更があります', error: 'エラー', success: '成功',
|
||
warning: '警告', info: '情報', loading: '読み込み中...', retry: '再試行',
|
||
renderError: 'レンダリング失敗',
|
||
outline: 'アウトライン',
|
||
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
|
||
},
|
||
ko: {
|
||
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
|
||
h1: '제목1', h2: '제목2', h3: '제목3', quote: '인용', code: '코드',
|
||
link: '링크', image: '이미지', table: '표', ul: '순서 없는 목록', ol: '순서 있는 목록',
|
||
indent: '들여쓰기', outdent: '내어쓰기', hr: '수평선', undo: '실행 취소', redo: '다시 실행',
|
||
edit: '편집', split: '분할', preview: '미리보기', fullscreen: '전체 화면',
|
||
fullscreenExit: '전체 화면 종료', theme: '테마', light: '라이트', dark: '다크',
|
||
auto: '자동', warm: '웜', wordCount: '글자 수', characters: '글자',
|
||
words: '단어', lines: '줄', readingTime: '읽기 시간', minutes: '분',
|
||
placeholder: 'Markdown 입력...', empty: '내용 없음', copied: '복사됨',
|
||
copyContent: '내용 복사', copyHTML: 'HTML 복사', copySuccess: '복사 성공',
|
||
copyFailed: '복사 실패', clearContent: '내용 지우기', clearConfirm: '모든 내용을 지우시겠습니까?',
|
||
linkPlaceholder: '링크 URL 입력', imagePlaceholder: '이미지 URL 입력',
|
||
altPlaceholder: '대체 텍스트 입력', tableRows: '행', tableCols: '열',
|
||
confirm: '확인', cancel: '취소', exportMarkdown: 'Markdown 내보내기',
|
||
exportHTML: 'HTML 내보내기', search: '검색', replace: '바꾸기', replaceAll: '모두 바꾸기',
|
||
searchPlaceholder: '찾기', replacePlaceholder: '바꿀 내용',
|
||
findNext: '다음 찾기', findPrev: '이전 찾기', matchCase: '대소문자 구분',
|
||
wholeWord: '단어 단위', close: '닫기', open: '열기', save: '저장',
|
||
saved: '저장됨', saving: '저장 중...', delete: '삭제', confirmDelete: '삭제하시겠습니까?',
|
||
unsavedChanges: '저장되지 않은 변경 사항이 있습니다', error: '오류', success: '성공',
|
||
warning: '경고', info: '정보', loading: '로딩 중...', retry: '재시도',
|
||
renderError: '렌더링 실패',
|
||
outline: '개요',
|
||
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
|
||
},
|
||
fr: {
|
||
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
|
||
h1: 'Titre 1', h2: 'Titre 2', h3: 'Titre 3', quote: 'Citation', code: 'Code',
|
||
link: 'Lien', image: 'Image', table: 'Tableau', ul: 'Liste à puces', ol: 'Liste numérotée',
|
||
indent: 'Indenter', outdent: 'Désindenter', hr: 'Ligne horizontale', undo: 'Annuler', redo: 'Rétablir',
|
||
edit: 'Éditer', split: 'Fractionné', preview: 'Aperçu', fullscreen: 'Plein écran',
|
||
fullscreenExit: 'Quitter plein écran', theme: 'Thème', light: 'Clair', dark: 'Sombre',
|
||
auto: 'Auto', warm: 'Chaud', wordCount: 'Nombre de mots', characters: 'Caractères',
|
||
words: 'Mots', lines: 'Lignes', readingTime: 'Lecture', minutes: 'min',
|
||
placeholder: 'Commencez à taper Markdown...', empty: 'Aucun contenu', copied: 'Copié',
|
||
copyContent: 'Copier le contenu', copyHTML: 'Copier HTML', copySuccess: 'Copié avec succès',
|
||
copyFailed: 'Échec de la copie', clearContent: 'Effacer le contenu', clearConfirm: 'Effacer tout le contenu ?',
|
||
linkPlaceholder: 'Entrez l\'URL du lien', imagePlaceholder: 'Entrez l\'URL de l\'image',
|
||
altPlaceholder: 'Entrez le texte alternatif', tableRows: 'Lignes', tableCols: 'Colonnes',
|
||
confirm: 'Confirmer', cancel: 'Annuler', exportMarkdown: 'Exporter Markdown',
|
||
exportHTML: 'Exporter HTML', search: 'Rechercher', replace: 'Remplacer', replaceAll: 'Tout remplacer',
|
||
searchPlaceholder: 'Rechercher', replacePlaceholder: 'Remplacer par',
|
||
findNext: 'Suivant', findPrev: 'Précédent', matchCase: 'Respecter la casse',
|
||
wholeWord: 'Mot entier', close: 'Fermer', open: 'Ouvrir', save: 'Enregistrer',
|
||
saved: 'Enregistré', saving: 'Enregistrement...', delete: 'Supprimer', confirmDelete: 'Confirmer la suppression ?',
|
||
unsavedChanges: 'Modifications non enregistrées', error: 'Erreur', success: 'Succès',
|
||
warning: 'Avertissement', info: 'Info', loading: 'Chargement...', retry: 'Réessayer',
|
||
renderError: 'Échec du rendu',
|
||
outline: 'Plan',
|
||
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
|
||
},
|
||
de: {
|
||
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
|
||
h1: 'Überschrift 1', h2: 'Überschrift 2', h3: 'Überschrift 3', quote: 'Zitat', code: 'Code',
|
||
link: 'Link', image: 'Bild', table: 'Tabelle', ul: 'Aufzählung', ol: 'Nummerierte Liste',
|
||
indent: 'Einrücken', outdent: 'Ausrücken', hr: 'Trennlinie', undo: 'Rückgängig', redo: 'Wiederholen',
|
||
edit: 'Bearbeiten', split: 'Geteilt', preview: 'Vorschau', fullscreen: 'Vollbild',
|
||
fullscreenExit: 'Vollbild beenden', theme: 'Design', light: 'Hell', dark: 'Dunkel',
|
||
auto: 'Auto', warm: 'Warm', wordCount: 'Wörter', characters: 'Zeichen',
|
||
words: 'Wörter', lines: 'Zeilen', readingTime: 'Lesezeit', minutes: 'min',
|
||
placeholder: 'Markdown eingeben...', empty: 'Kein Inhalt', copied: 'Kopiert',
|
||
copyContent: 'Inhalt kopieren', copyHTML: 'HTML kopieren', copySuccess: 'Erfolgreich kopiert',
|
||
copyFailed: 'Kopieren fehlgeschlagen', clearContent: 'Inhalt löschen', clearConfirm: 'Gesamten Inhalt löschen?',
|
||
linkPlaceholder: 'Link-URL eingeben', imagePlaceholder: 'Bild-URL eingeben',
|
||
altPlaceholder: 'Alternativtext eingeben', tableRows: 'Zeilen', tableCols: 'Spalten',
|
||
confirm: 'Bestätigen', cancel: 'Abbrechen', exportMarkdown: 'Markdown exportieren',
|
||
exportHTML: 'HTML exportieren', search: 'Suchen', replace: 'Ersetzen', replaceAll: 'Alle ersetzen',
|
||
searchPlaceholder: 'Suchen', replacePlaceholder: 'Ersetzen mit',
|
||
findNext: 'Nächstes', findPrev: 'Vorheriges', matchCase: 'Groß-/Kleinschreibung',
|
||
wholeWord: 'Ganzes Wort', close: 'Schließen', open: 'Öffnen', save: 'Speichern',
|
||
saved: 'Gespeichert', saving: 'Speichern...', delete: 'Löschen', confirmDelete: 'Wirklich löschen?',
|
||
unsavedChanges: 'Ungespeicherte Änderungen', error: 'Fehler', success: 'Erfolg',
|
||
warning: 'Warnung', info: 'Info', loading: 'Laden...', retry: 'Wiederholen',
|
||
renderError: 'Rendern fehlgeschlagen',
|
||
outline: 'Gliederung',
|
||
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
|
||
},
|
||
};
|
||
|
||
/**
|
||
* MetonaEditor Constants — default configs, themes, animations
|
||
* @module constants
|
||
* @version 0.2.0
|
||
*/
|
||
/** Default toolbar button sequence */
|
||
const DEFAULT_TOOLBAR = [
|
||
'bold', 'italic', 'strikethrough', 'code', '|',
|
||
'h1', 'h2', 'h3', '|',
|
||
'quote', 'ul', 'ol', '|',
|
||
'link', 'image', 'table', 'hr', '|',
|
||
'undo', 'redo', '|',
|
||
'edit', 'split', 'preview', 'fullscreen',
|
||
];
|
||
/** Default options */
|
||
const DEFAULTS = Object.freeze({
|
||
value: '',
|
||
placeholder: '',
|
||
mode: 'split',
|
||
height: 400,
|
||
toolbar: DEFAULT_TOOLBAR,
|
||
wordCount: true,
|
||
autofocus: false,
|
||
spellcheck: false,
|
||
historyLimit: 100,
|
||
historyDebounce: 400,
|
||
syncScroll: true,
|
||
tabSize: 2,
|
||
readOnly: false,
|
||
lineNumbers: true,
|
||
outline: false,
|
||
autoBrackets: true,
|
||
zenMode: false,
|
||
wordWrap: true,
|
||
maxLength: 0,
|
||
theme: 'auto',
|
||
locale: 'zh-CN',
|
||
render: null,
|
||
highlight: null,
|
||
sanitize: null,
|
||
className: '',
|
||
style: {},
|
||
plugins: [],
|
||
onChange: null,
|
||
onInput: null,
|
||
onFocus: null,
|
||
onBlur: null,
|
||
onSave: null,
|
||
onModeChange: null,
|
||
onFullscreen: null,
|
||
onCreate: null,
|
||
onDestroy: null,
|
||
onLinkClick: null,
|
||
floatingToolbar: true,
|
||
});
|
||
const THEMES = {
|
||
light: {
|
||
bg: 'rgba(255, 255, 255, 0.96)',
|
||
text: '#1f2937',
|
||
border: 'rgba(0, 0, 0, 0.08)',
|
||
shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.18), 0 4px 14px -4px rgba(0, 0, 0, 0.08)',
|
||
hoverShadow: '0 14px 48px -10px rgba(0, 0, 0, 0.22), 0 6px 18px -4px rgba(0, 0, 0, 0.10)',
|
||
toolbarBg: 'rgba(248, 249, 250, 0.92)',
|
||
textareaBg: '#ffffff',
|
||
previewBg: '#ffffff',
|
||
codeBg: 'rgba(243, 244, 246, 1)',
|
||
codeText: '#1f2937',
|
||
accent: '#3b82f6',
|
||
muted: '#6b7280',
|
||
},
|
||
dark: {
|
||
bg: 'rgba(28, 32, 40, 0.94)',
|
||
text: '#e6e8eb',
|
||
border: 'rgba(255, 255, 255, 0.1)',
|
||
shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.6), 0 4px 14px -4px rgba(0, 0, 0, 0.4)',
|
||
hoverShadow: '0 14px 48px -8px rgba(0, 0, 0, 0.6), 0 6px 18px -4px rgba(0, 0, 0, 0.4)',
|
||
toolbarBg: 'rgba(22, 26, 33, 0.92)',
|
||
textareaBg: '#1c2028',
|
||
previewBg: '#1c2028',
|
||
codeBg: 'rgba(15, 18, 24, 1)',
|
||
codeText: '#e6e8eb',
|
||
accent: '#60a5fa',
|
||
muted: '#9ca3af',
|
||
},
|
||
auto: 'auto',
|
||
warm: {
|
||
bg: 'rgba(255, 251, 235, 0.96)',
|
||
text: '#78350f',
|
||
border: 'rgba(245, 158, 11, 0.2)',
|
||
shadow: '0 10px 36px -10px rgba(245, 158, 11, 0.18), 0 4px 14px -4px rgba(245, 158, 11, 0.08)',
|
||
hoverShadow: '0 14px 48px -10px rgba(245, 158, 11, 0.22), 0 6px 18px -4px rgba(245, 158, 11, 0.10)',
|
||
toolbarBg: 'rgba(254, 243, 199, 0.92)',
|
||
textareaBg: '#fffbeb',
|
||
previewBg: '#fffbeb',
|
||
codeBg: 'rgba(254, 215, 170, 0.6)',
|
||
codeText: '#78350f',
|
||
accent: '#d97706',
|
||
muted: '#a16207',
|
||
progressBg: 'rgba(217, 119, 6, 0.15)',
|
||
closeHoverBg: 'rgba(217, 119, 6, 0.2)',
|
||
},
|
||
};
|
||
const ANIMATIONS = {
|
||
slide: { enter: { transform: 'translateX(80px)', opacity: '0' }, leave: { transform: 'translateX(120%)', opacity: '0' }, duration: 400, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
|
||
fade: { enter: { opacity: '0', filter: 'blur(3px)' }, leave: { opacity: '0', filter: 'blur(3px)' }, duration: 500, easing: 'ease' },
|
||
scale: { enter: { transform: 'scale(0.55)', opacity: '0' }, leave: { transform: 'scale(0.55)', opacity: '0' }, duration: 450, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
|
||
bounce: { enter: { transform: 'translateY(-80px)', opacity: '0' }, leave: { transform: 'translateY(20px)', opacity: '0' }, duration: 650, easing: 'ease' },
|
||
flip: { enter: { transform: 'perspective(500px) rotateX(-90deg)', opacity: '0' }, leave: { transform: 'perspective(500px) rotateX(90deg)', opacity: '0' }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
|
||
rotate: { enter: { transform: 'rotate(-25deg) scale(0.6)', opacity: '0' }, leave: { transform: 'rotate(25deg) scale(0.6)', opacity: '0' }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
|
||
zoom: { enter: { transform: 'scale(0.1)', opacity: '0' }, leave: { transform: 'scale(0.1)', opacity: '0' }, duration: 500, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
|
||
};
|
||
const EDIT_MODES = ['edit', 'split', 'preview'];
|
||
const TOOLBAR_ACTIONS = [
|
||
'bold', 'italic', 'strikethrough', 'underline', 'code',
|
||
'h1', 'h2', 'h3', 'quote', 'ul', 'ol', 'indent', 'outdent', 'hr',
|
||
'link', 'image', 'table', 'undo', 'redo',
|
||
'edit', 'split', 'preview', 'fullscreen',
|
||
];
|
||
|
||
/**
|
||
* MetonaEditor Styles — CSS-in-JS injection
|
||
* @module styles
|
||
* @version 0.2.0
|
||
*/
|
||
let styleElement = null;
|
||
const generateCSS = () => {
|
||
return `:root{--md-bg:#ffffff;--md-text:#1f2937;--md-border:rgba(0,0,0,0.08);--md-shadow:0 10px 36px -10px rgba(0,0,0,0.18),0 4px 14px -4px rgba(0,0,0,0.08);--md-hover-shadow:0 14px 48px -10px rgba(0,0,0,0.22),0 6px 18px -4px rgba(0,0,0,0.10);--md-toolbar-bg:rgba(248,249,250,0.92);--md-textarea-bg:#ffffff;--md-preview-bg:#ffffff;--md-code-bg:rgba(243,244,246,1);--md-code-text:#1f2937;--md-accent:#3b82f6;--md-muted:#6b7280;--md-radius:10px;--md-font:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei","Hiragino Sans GB",sans-serif;--md-mono:"SF Mono","Cascadia Code","Consolas","Liberation Mono","Courier New",monospace}
|
||
.me-wrapper{display:flex;flex-direction:column;box-sizing:border-box;border:1px solid var(--md-border);border-radius:var(--md-radius);overflow:hidden;background:var(--md-bg);color:var(--md-text);box-shadow:var(--md-shadow);font-family:var(--md-font);font-size:14px;line-height:1.6;position:relative;transition:box-shadow .25s,border-color .25s;width:100%}
|
||
.me-wrapper:hover{box-shadow:var(--md-hover-shadow)}
|
||
.me-wrapper.me-disabled{opacity:.6;pointer-events:none}
|
||
.me-wrapper *{box-sizing:border-box}
|
||
.me-wrapper.me-fullscreen{position:fixed;top:0;left:0;right:0;bottom:0;width:100vw;height:100vh;z-index:9999;border-radius:0;border:0}
|
||
.me-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:6px 8px;background:var(--md-toolbar-bg);border-bottom:1px solid var(--md-border);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);min-height:40px}
|
||
.me-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--md-text);cursor:pointer;transition:background .15s,color .15s,transform .1s;flex-shrink:0}
|
||
.me-btn:hover{background:var(--md-code-bg);color:var(--md-accent)}
|
||
.me-btn:active{transform:scale(.92)}
|
||
.me-btn.me-active{background:var(--md-accent);color:#fff}
|
||
.me-btn:disabled{opacity:.35;cursor:not-allowed;pointer-events:none}
|
||
.me-btn:focus-visible{outline:2px solid var(--md-accent);outline-offset:2px}
|
||
.me-btn:focus:not(:focus-visible){outline:none}
|
||
.me-btn svg{width:17px;height:17px;display:block}
|
||
.me-btn span{font-size:12px;font-weight:500}
|
||
.me-toolbar-sep{display:inline-block;width:1px;height:20px;background:var(--md-border);margin:0 4px;flex-shrink:0}
|
||
.me-toolbar-group{display:inline-flex;gap:2px;margin-left:auto;padding-left:6px;border-left:1px solid var(--md-border)}
|
||
.me-toolbar-group .me-btn{width:auto;padding:0 8px}
|
||
.me-body{display:flex;flex:1;min-height:0;position:relative}
|
||
.me-editor-pane,.me-preview-pane{flex:1 1 50%;min-width:0;overflow:hidden;position:relative}
|
||
.me-editor-inner{display:flex;height:100%;overflow:hidden;position:relative}
|
||
.me-gutter{flex:0 0 auto;min-width:36px;padding:16px 8px 16px 6px;overflow:hidden;background:var(--md-code-bg);border-right:1px solid var(--md-border);color:var(--md-muted);font-family:var(--md-mono);font-size:13.5px;line-height:1.7;text-align:right;user-select:none;white-space:pre}
|
||
.me-gutter-line{padding-right:4px}
|
||
.me-gutter-active{color:var(--md-accent);font-weight:600;background:rgba(59,130,246,.08);border-radius:3px}
|
||
.me-divider{flex:0 0 1px;background:var(--md-border);cursor:col-resize;position:relative}
|
||
.me-divider::after{content:'';position:absolute;top:0;bottom:0;left:-3px;right:-3px}
|
||
.me-divider:hover{background:var(--md-accent)}
|
||
.me-textarea{width:100%;height:100%;padding:16px 18px;border:0;outline:0;resize:none;background:var(--md-textarea-bg);color:var(--md-text);font-family:var(--md-mono);font-size:13.5px;line-height:1.7;display:block;white-space:pre-wrap;word-wrap:break-word;position:relative;z-index:1}
|
||
.me-textarea::placeholder{color:var(--md-muted);opacity:.7}
|
||
.me-textarea:focus{outline:0}
|
||
.me-preview-pane{overflow:auto;background:var(--md-preview-bg)}
|
||
.me-preview{padding:18px 22px;max-width:100%;word-wrap:break-word;overflow-wrap:break-word}
|
||
.me-body.me-mode-edit .me-preview-pane,.me-body.me-mode-edit .me-divider{display:none}
|
||
.me-body.me-mode-edit .me-editor-pane{flex:1 1 100%}
|
||
.me-body.me-mode-preview .me-editor-pane,.me-body.me-mode-preview .me-divider{display:none}
|
||
.me-body.me-mode-preview .me-preview-pane{flex:1 1 100%}
|
||
.me-preview>:first-child{margin-top:0}
|
||
.me-preview>:last-child{margin-bottom:0}
|
||
.me-preview h1,.me-preview h2,.me-preview h3,.me-preview h4,.me-preview h5,.me-preview h6{margin:1.4em 0 .6em;font-weight:650;line-height:1.3}
|
||
.me-preview h1{font-size:1.9em;padding-bottom:.3em;border-bottom:1px solid var(--md-border)}
|
||
.me-preview h2{font-size:1.55em;padding-bottom:.3em;border-bottom:1px solid var(--md-border)}
|
||
.me-preview h3{font-size:1.3em}
|
||
.me-preview h4{font-size:1.12em}
|
||
.me-preview h5{font-size:1em}
|
||
.me-preview h6{font-size:.9em;color:var(--md-muted)}
|
||
.me-preview p{margin:.7em 0}
|
||
.me-preview a{color:var(--md-accent);text-decoration:none}
|
||
.me-preview a:hover{text-decoration:underline}
|
||
.me-preview strong{font-weight:650}
|
||
.me-preview em{font-style:italic}
|
||
.me-preview del{text-decoration:line-through;opacity:.75}
|
||
.me-preview ul,.me-preview ol{margin:.6em 0;padding-left:1.6em}
|
||
.me-preview li{margin:.25em 0}
|
||
.me-preview li.me-task-item{list-style:none;margin-left:-1.4em}
|
||
.me-preview li.me-task-item input{margin-right:.5em;vertical-align:middle}
|
||
.me-preview blockquote{margin:.8em 0;padding:.4em 1em;border-left:3px solid var(--md-accent);background:var(--md-code-bg);border-radius:0 6px 6px 0;color:var(--md-text)}
|
||
.me-preview blockquote>:first-child{margin-top:0}
|
||
.me-preview blockquote>:last-child{margin-bottom:0}
|
||
.me-preview hr{border:0;height:1px;background:var(--md-border);margin:1.6em 0}
|
||
.me-preview code{font-family:var(--md-mono);font-size:.88em;padding:.15em .4em;background:var(--md-code-bg);color:var(--md-code-text);border-radius:4px}
|
||
.me-preview pre{margin:.9em 0;padding:14px 16px;background:var(--md-code-bg);border-radius:8px;overflow-x:auto;border:1px solid var(--md-border)}
|
||
.me-code-title{font-family:var(--md-mono);font-size:.85em;padding:8px 14px;background:var(--md-border);border-radius:8px 8px 0 0;color:var(--md-text);margin:.9em 0 -0.9em;font-weight:600}
|
||
.me-code-title+pre{margin-top:0;border-radius:0 0 8px 8px;border-top:none}
|
||
.me-preview pre code{padding:0;background:transparent;color:var(--md-code-text);font-size:.9em;line-height:1.6;border-radius:0}
|
||
.me-preview img{max-width:100%;height:auto;border-radius:6px;vertical-align:middle}
|
||
.me-table-wrap{overflow-x:auto;margin:.9em 0}
|
||
.me-preview table{border-collapse:collapse;width:100%;font-size:.93em;display:block}
|
||
.me-preview th,.me-preview td{border:1px solid var(--md-border);padding:7px 12px;text-align:left}
|
||
.me-preview th{background:var(--md-code-bg);font-weight:600}
|
||
.me-preview tr:nth-child(even) td{background:var(--md-code-bg)}
|
||
.me-statusbar{display:flex;align-items:center;justify-content:flex-end;gap:14px;padding:4px 12px;border-top:1px solid var(--md-border);background:var(--md-toolbar-bg);color:var(--md-muted);font-size:12px;min-height:26px}
|
||
.me-statusbar:empty{display:none}
|
||
.me-wrapper:focus-within{border-color:var(--md-accent)}
|
||
.me-textarea:focus-visible{outline:0}
|
||
@media(prefers-reduced-motion:reduce){.me-wrapper,.me-btn,.me-textarea{transition:none!important}}
|
||
.me-wrapper.me-fullscreen .me-preview,.me-wrapper.me-fullscreen .me-textarea{font-size:15px}
|
||
.me-wrapper.me-readonly .me-textarea{background:var(--md-code-bg);cursor:default;opacity:.88}
|
||
.me-wrapper.me-readonly .me-toolbar{opacity:.75}
|
||
.me-wrapper.me-readonly .me-btn:disabled{opacity:.3;cursor:not-allowed;pointer-events:none}
|
||
.me-preview mark{background:rgba(250,204,21,.3);color:inherit;padding:.1em .2em;border-radius:3px}
|
||
.me-preview sup{font-size:.75em;vertical-align:super;line-height:1}
|
||
.me-preview sub{font-size:.75em;vertical-align:sub;line-height:1}
|
||
.me-preview .me-math-block{display:block;margin:1.2em 0;padding:12px 16px;background:var(--md-code-bg);border-radius:8px;overflow-x:auto;font-family:var(--md-mono);font-size:.95em;text-align:center}
|
||
.me-preview .me-math-inline{font-family:var(--md-mono);font-size:.95em;padding:.05em .2em}
|
||
.me-preview dl{margin:.8em 0}
|
||
.me-preview dt{font-weight:650;margin:.6em 0 .2em}
|
||
.me-preview dd{margin:0 0 .3em 1.6em;color:var(--md-text)}
|
||
.me-preview .me-footnote-ref a{font-size:.75em;vertical-align:super;text-decoration:none;color:var(--md-accent)}
|
||
.me-preview .me-footnotes{margin-top:2em;border-top:1px solid var(--md-border);padding-top:.8em;font-size:.9em;color:var(--md-muted)}
|
||
.me-preview .me-footnotes hr{display:none}
|
||
.me-preview .me-footnotes ol{padding-left:1.2em}
|
||
.me-preview .me-footnote-item{margin:.3em 0}
|
||
.me-preview .me-footnote-backref{text-decoration:none;color:var(--md-accent);margin-right:.4em}
|
||
.me-toast{position:absolute;bottom:16px;right:16px;z-index:30;display:flex;align-items:center;gap:8px;padding:10px 16px;border-radius:8px;font-size:13px;pointer-events:auto;cursor:pointer;opacity:0;transform:translateY(12px);transition:opacity .3s,transform .3s;box-shadow:0 6px 20px rgba(0,0,0,.15);max-width:360px}
|
||
.me-toast.me-toast-enter{opacity:1;transform:translateY(0)}
|
||
.me-toast.me-toast-leave{opacity:0;transform:translateY(12px)}
|
||
.me-toast-success{background:#10b981;color:#fff}
|
||
.me-toast-error{background:#ef4444;color:#fff}
|
||
.me-toast-warning{background:#f59e0b;color:#fff}
|
||
.me-toast-info{background:var(--md-accent,#3b82f6);color:#fff}
|
||
.me-toast-icon{font-weight:700;font-size:16px;flex-shrink:0}
|
||
.me-toast-msg{line-height:1.4}
|
||
.me-context-menu{position:fixed;z-index:40;min-width:180px;padding:4px 0;background:var(--md-bg);border:1px solid var(--md-border);border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.18);font-size:13px}
|
||
.me-context-menu-item{display:flex;align-items:center;justify-content:space-between;padding:6px 14px;cursor:pointer;color:var(--md-text);transition:background .1s}
|
||
.me-context-menu-item:hover{background:var(--md-code-bg);color:var(--md-accent)}
|
||
.me-context-menu-sep{height:1px;background:var(--md-border);margin:4px 0}
|
||
.me-context-menu-shortcut{color:var(--md-muted);font-size:11px;margin-left:24px}
|
||
.me-context-menu-item.me-disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
|
||
.me-outline{position:absolute;top:0;right:0;width:220px;height:100%;overflow-y:auto;background:var(--md-preview-bg);border-left:1px solid var(--md-border);padding:12px 14px;font-size:13px;z-index:15}
|
||
.me-outline-title{font-weight:650;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--md-border);color:var(--md-text)}
|
||
.me-outline ul{list-style:none;padding:0;margin:0}
|
||
.me-outline li{margin:2px 0}
|
||
.me-outline a{color:var(--md-muted);text-decoration:none;display:block;padding:2px 6px;border-radius:4px;transition:background .1s,color .1s}
|
||
.me-outline a:hover{background:var(--md-code-bg);color:var(--md-accent)}
|
||
.me-outline a.me-outline-active{background:var(--md-accent);color:#fff;font-weight:600}
|
||
.me-outline-l1 a{font-weight:600;color:var(--md-text)}
|
||
.me-outline-l2 a{padding-left:12px}
|
||
.me-outline-l3 a{padding-left:20px;font-size:12px}
|
||
.me-outline-l4 a{padding-left:28px;font-size:12px}
|
||
.me-wrapper.me-zen .me-body{max-width:820px;margin:0 auto}
|
||
.me-wrapper.me-zen .me-textarea{font-size:15px;line-height:1.8}
|
||
.me-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||
@media print{.me-wrapper{border:0!important;box-shadow:none!important}
|
||
.me-toolbar,.me-statusbar,.me-divider{display:none!important}
|
||
.me-body.me-mode-split .me-editor-pane{display:none}
|
||
.me-body.me-mode-split .me-preview-pane{flex:1 1 100%!important}
|
||
.me-preview{padding:0!important;font-size:11pt}
|
||
.me-wrapper.me-fullscreen{position:static!important;width:auto!important;height:auto!important}}
|
||
[dir=rtl] .me-gutter{border-right:none;border-left:1px solid var(--md-border);text-align:left}
|
||
[dir=rtl] .me-gutter-line{padding-right:0;padding-left:4px}
|
||
[dir=rtl] .me-toolbar-group{margin-left:0;margin-right:auto;padding-left:0;padding-right:6px;border-left:none;border-right:1px solid var(--md-border)}
|
||
[dir=rtl] .me-outline{right:auto;left:0;border-left:none;border-right:1px solid var(--md-border)}
|
||
[dir=rtl] .me-outline-l2 a{padding-left:0;padding-right:12px}
|
||
[dir=rtl] .me-outline-l3 a{padding-left:0;padding-right:20px}
|
||
[dir=rtl] .me-outline-l4 a{padding-left:0;padding-right:28px}
|
||
[dir=rtl] .me-preview blockquote{border-left:none;border-right:3px solid var(--md-accent);border-radius:6px 0 0 6px}
|
||
[dir=rtl] .me-preview ul,[dir=rtl] .me-preview ol{padding-left:0;padding-right:1.6em}
|
||
[dir=rtl] .me-preview li.me-task-item{margin-left:0;margin-right:-1.4em}
|
||
[dir=rtl] .me-preview dd{margin-left:0;margin-right:1.6em}
|
||
[dir=rtl] .me-preview .me-footnotes ol{padding-left:0;padding-right:1.2em}
|
||
[dir=rtl] .me-preview .me-footnote-backref{margin-right:0;margin-left:.4em}
|
||
[dir=rtl] .me-search{right:auto;left:12px}
|
||
[dir=rtl] .me-toast{right:auto;left:16px}
|
||
[dir=rtl] .me-context-menu-shortcut{margin-left:0;margin-right:24px}
|
||
[dir=rtl] .me-statusbar{justify-content:flex-start}`;
|
||
};
|
||
const injectStyles = () => {
|
||
if (typeof document === 'undefined')
|
||
return;
|
||
if (styleElement && document.getElementById('metona-editor-styles'))
|
||
return;
|
||
const css = generateCSS();
|
||
styleElement = document.createElement('style');
|
||
styleElement.id = 'metona-editor-styles';
|
||
styleElement.textContent = css;
|
||
document.head.appendChild(styleElement);
|
||
};
|
||
|
||
/**
|
||
* MetonaEditor i18n — internationalization
|
||
* @module i18n
|
||
* @version 0.2.0
|
||
*/
|
||
let currentLocale = 'zh-CN';
|
||
let localeListeners = new Set();
|
||
let fallbackLocale = 'zh-CN';
|
||
const pluralRules = {
|
||
zh: () => 'other',
|
||
en: (n) => n === 1 ? 'one' : 'other',
|
||
ru: (n) => {
|
||
const m = n % 10, h = n % 100;
|
||
if (m === 1 && h !== 11)
|
||
return 'one';
|
||
if (m >= 2 && m <= 4 && !(h >= 12 && h <= 14))
|
||
return 'few';
|
||
if (m === 0 || (m >= 5 && m <= 9) || (h >= 11 && h <= 14))
|
||
return 'many';
|
||
return 'other';
|
||
},
|
||
};
|
||
const getPluralForm = (locale, count) => {
|
||
const lang = locale.split('-')[0].toLowerCase();
|
||
const rule = pluralRules[lang] || pluralRules.en;
|
||
return rule(Math.abs(count));
|
||
};
|
||
const getCurrentLocale = () => currentLocale;
|
||
const setCurrentLocale = (locale) => {
|
||
let target = locale;
|
||
if (!LOCALES[target]) {
|
||
console.warn(`Locale "${target}" not found, falling back to "${fallbackLocale}"`);
|
||
target = fallbackLocale;
|
||
}
|
||
currentLocale = target;
|
||
notifyLocaleListeners(target);
|
||
saveLocale(target);
|
||
};
|
||
const t = (key, params = {}, locale) => {
|
||
const loc = locale || currentLocale;
|
||
let translation = getTranslation(loc, key);
|
||
if (translation && typeof translation === 'object' && params.count !== undefined) {
|
||
const form = getPluralForm(loc, params.count);
|
||
translation = translation[form] || translation.other || key;
|
||
}
|
||
if (typeof translation === 'string')
|
||
return interpolate(translation, params);
|
||
if (loc !== fallbackLocale) {
|
||
let fb = getTranslation(fallbackLocale, key);
|
||
if (fb && typeof fb === 'object' && params.count !== undefined) {
|
||
const form = getPluralForm(fallbackLocale, params.count);
|
||
fb = fb[form] || fb.other;
|
||
}
|
||
if (typeof fb === 'string')
|
||
return interpolate(fb, params);
|
||
}
|
||
console.warn(`Translation missing for key "${key}" in locale "${loc}"`);
|
||
return key;
|
||
};
|
||
const getTranslation = (locale, key) => {
|
||
const localeData = LOCALES[locale];
|
||
if (!localeData)
|
||
return undefined;
|
||
const keys = key.split('.');
|
||
let result = localeData;
|
||
for (const k of keys) {
|
||
if (result && typeof result === 'object' && k in result) {
|
||
result = result[k];
|
||
}
|
||
else
|
||
return undefined;
|
||
}
|
||
return result;
|
||
};
|
||
const interpolate = (str, params) => {
|
||
return str.replace(/\{(\w+)\}/g, (_match, key) => params[key] !== undefined ? String(params[key]) : _match);
|
||
};
|
||
const hasTranslation = (key) => {
|
||
return getTranslation(currentLocale, key) !== undefined || getTranslation(fallbackLocale, key) !== undefined;
|
||
};
|
||
const getTranslations = (locale) => LOCALES[locale] || {};
|
||
const addTranslations = (locale, translations) => {
|
||
if (!LOCALES[locale])
|
||
LOCALES[locale] = {};
|
||
deepMergeI18n(LOCALES[locale], translations);
|
||
};
|
||
const loadRemote = async (url, locale) => {
|
||
try {
|
||
const res = await fetch(url);
|
||
if (!res.ok)
|
||
throw new Error(`HTTP ${res.status}`);
|
||
const data = await res.json();
|
||
addTranslations(locale, data);
|
||
return true;
|
||
}
|
||
catch (e) {
|
||
console.error(`MeEditor: failed to load locale "${locale}" from ${url}`, e);
|
||
return false;
|
||
}
|
||
};
|
||
const deepMergeI18n = (target, source) => {
|
||
for (const key in source) {
|
||
if (source[key] instanceof Object && key in target && target[key] instanceof Object && !Array.isArray(source[key])) {
|
||
deepMergeI18n(target[key], source[key]);
|
||
}
|
||
else {
|
||
target[key] = source[key];
|
||
}
|
||
}
|
||
return target;
|
||
};
|
||
const getSupportedLocales = () => Object.keys(LOCALES);
|
||
const isLocaleSupported = (locale) => locale in LOCALES;
|
||
const getLocaleName = (locale) => {
|
||
const names = {
|
||
'zh-CN': '简体中文', 'zh-TW': '繁體中文', 'en-US': 'English (US)', 'en-GB': 'English (UK)',
|
||
ja: '日本語', ko: '한국어', fr: 'Français', de: 'Deutsch', es: 'Español', pt: 'Português',
|
||
ru: 'Русский', ar: 'العربية', hi: 'हिन्दी', th: 'ไทย',
|
||
vi: 'Tiếng Việt', id: 'Bahasa Indonesia', tr: 'Türkçe',
|
||
it: 'Italiano', nl: 'Nederlands', pl: 'Polski',
|
||
};
|
||
return names[locale] || locale;
|
||
};
|
||
const getLocaleDirection = (locale) => {
|
||
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug', 'dv', 'ku', 'syr'];
|
||
const short = locale.split('-')[0].toLowerCase();
|
||
return rtlLocales.includes(short) ? 'rtl' : 'ltr';
|
||
};
|
||
const formatNumber = (number, options = {}) => {
|
||
try {
|
||
return new Intl.NumberFormat(currentLocale, options).format(number);
|
||
}
|
||
catch (_) {
|
||
return String(number);
|
||
}
|
||
};
|
||
const formatCurrency = (amount, currency = 'USD', options = {}) => {
|
||
try {
|
||
return new Intl.NumberFormat(currentLocale, { style: 'currency', currency, ...options }).format(amount);
|
||
}
|
||
catch (_) {
|
||
return String(amount);
|
||
}
|
||
};
|
||
const formatDate = (date, options = {}) => {
|
||
try {
|
||
const d = date instanceof Date ? date : new Date(date);
|
||
return new Intl.DateTimeFormat(currentLocale, options).format(d);
|
||
}
|
||
catch (_) {
|
||
return String(date);
|
||
}
|
||
};
|
||
const addLocaleListener = (fn) => {
|
||
localeListeners.add(fn);
|
||
return () => { localeListeners.delete(fn); };
|
||
};
|
||
const removeLocaleListener = (fn) => { localeListeners.delete(fn); };
|
||
const clearLocaleListeners = () => { localeListeners.clear(); };
|
||
const notifyLocaleListeners = (locale) => {
|
||
localeListeners.forEach((fn) => {
|
||
try {
|
||
fn(locale);
|
||
}
|
||
catch (e) {
|
||
console.error('Locale listener error:', e);
|
||
}
|
||
});
|
||
};
|
||
const saveLocale = (locale) => {
|
||
if (typeof localStorage !== 'undefined') {
|
||
try {
|
||
localStorage.setItem('metona-editor-locale', locale);
|
||
}
|
||
catch (_) { }
|
||
}
|
||
};
|
||
const loadLocale = () => {
|
||
if (typeof localStorage !== 'undefined') {
|
||
try {
|
||
return localStorage.getItem('metona-editor-locale') || getDefaultLocale();
|
||
}
|
||
catch (_) { }
|
||
}
|
||
return getDefaultLocale();
|
||
};
|
||
const getDefaultLocale = () => {
|
||
if (typeof navigator !== 'undefined') {
|
||
const browserLocale = navigator.language || navigator.userLanguage;
|
||
if (browserLocale && isLocaleSupported(browserLocale))
|
||
return browserLocale;
|
||
const short = browserLocale?.split('-')[0];
|
||
if (short && isLocaleSupported(short))
|
||
return short;
|
||
}
|
||
return fallbackLocale;
|
||
};
|
||
const initI18n = () => {
|
||
const saved = loadLocale();
|
||
setCurrentLocale(saved);
|
||
};
|
||
const switchLocale = (locale) => { setCurrentLocale(locale); };
|
||
const createInstanceI18n = (editor) => {
|
||
let instanceLocale = editor.config?.locale || currentLocale || 'zh-CN';
|
||
const instanceT = (key, params = {}) => t(key, params, instanceLocale);
|
||
const set = (locale) => {
|
||
if (!locale || instanceLocale === locale)
|
||
return instanceLocale;
|
||
instanceLocale = locale;
|
||
if (editor.el) {
|
||
editor.el.setAttribute('lang', locale);
|
||
editor.el.setAttribute('dir', getLocaleDirection(locale));
|
||
}
|
||
if (editor.textarea) {
|
||
editor.textarea.placeholder = instanceT('placeholder') || '';
|
||
editor.textarea.setAttribute('aria-label', instanceT('edit') || '');
|
||
editor.textarea.setAttribute('dir', getLocaleDirection(locale));
|
||
}
|
||
if (typeof editor._emit === 'function') {
|
||
editor._emit('localeChange', { locale, direction: getLocaleDirection(locale) });
|
||
}
|
||
if (editor.toolbarEl) {
|
||
editor.toolbarEl.querySelectorAll('.me-btn').forEach((btn) => {
|
||
const action = btn.dataset.action || btn.dataset.mode;
|
||
if (action) {
|
||
const label = instanceT(action);
|
||
if (label && label !== action) {
|
||
btn.title = label;
|
||
btn.setAttribute('aria-label', label);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
return instanceLocale;
|
||
};
|
||
return {
|
||
set, get: () => instanceLocale, getDirection: () => getLocaleDirection(instanceLocale), t: instanceT,
|
||
formatNumber: (n, opts) => { try {
|
||
return new Intl.NumberFormat(instanceLocale, opts).format(n);
|
||
}
|
||
catch (_) {
|
||
return String(n);
|
||
} },
|
||
formatDate: (d, opts) => { try {
|
||
return new Intl.DateTimeFormat(instanceLocale, opts).format(d instanceof Date ? d : new Date(d));
|
||
}
|
||
catch (_) {
|
||
return String(d);
|
||
} },
|
||
};
|
||
};
|
||
const createI18nManager = () => ({
|
||
t, getCurrentLocale, setCurrentLocale, switchLocale,
|
||
getFallbackLocale: () => fallbackLocale, setFallbackLocale: (locale) => { fallbackLocale = locale; },
|
||
hasTranslation, getTranslations, addTranslations, loadRemote,
|
||
getSupportedLocales, isLocaleSupported, getLocaleName, getLocaleDirection,
|
||
formatNumber, formatCurrency, formatDate, addLocaleListener, removeLocaleListener, clearLocaleListeners,
|
||
initI18n, saveLocale, loadLocale, getDefaultLocale, createInstanceI18n,
|
||
});
|
||
({
|
||
'zh-CN': { name: '简体中文', nativeName: '简体中文', direction: 'ltr', translations: LOCALES['zh-CN'] },
|
||
'en-US': { name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', translations: LOCALES['en-US'] },
|
||
ja: { name: '日本語', nativeName: '日本語', direction: 'ltr', translations: LOCALES['ja'] },
|
||
ko: { name: '한국어', nativeName: '한국어', direction: 'ltr', translations: LOCALES['ko'] },
|
||
fr: { name: 'Français', nativeName: 'Français', direction: 'ltr', translations: LOCALES['fr'] },
|
||
de: { name: 'Deutsch', nativeName: 'Deutsch', direction: 'ltr', translations: LOCALES['de'] },
|
||
});
|
||
const i18nUtils = createI18nManager();
|
||
|
||
/**
|
||
* MetonaEditor Parser — lightweight Markdown parser (TypeScript)
|
||
* @module parser
|
||
* @version 0.2.0
|
||
*/
|
||
// ============ Precompiled regex ============
|
||
const RE_EMPTY = /^\s*$/;
|
||
const RE_ATX = /^(#{1,6})\s+(.+?)(?:\s+#{1,6})?\s*$/;
|
||
const RE_SETEXT_H1 = /^={3,}\s*$/;
|
||
const RE_SETEXT_H2 = /^-{3,}\s*$/;
|
||
const RE_FENCE_START = /^(\s{0,3})(`{3,}|~{3,})\s*([\w+#.-]*)(\s+[^\n]*?)?\s*$/;
|
||
const RE_HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/;
|
||
const RE_QUOTE = /^\s{0,3}>\s?/;
|
||
const RE_UL = /^(\s*)([-*+])\s/;
|
||
const RE_OL = /^(\s*)(\d+)\.\s/;
|
||
const RE_TASK = /^\[([ xX])\]\s+(.*)$/;
|
||
const RE_TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
|
||
const RE_INDENT_CODE = /^( {4}|\t)/;
|
||
const RE_DEF_LIST = /^:\s+/;
|
||
const RE_FOOTNOTE_DEF = /^\[\^([^\]]+)\]:\s*/;
|
||
// ============ Emoji map ============
|
||
const EMOJI_MAP = {
|
||
smile: '😊', grinning: '😀', joy: '😂', rofl: '🤣', wink: '😉', blush: '😊', innocent: '😇',
|
||
heart_eyes: '😍', kissing_heart: '😘', yum: '😋', stuck_out_tongue: '😛', sunglasses: '😎',
|
||
smirk: '😏', unamused: '😒', disappointed: '😞', worried: '😟', confused: '😕',
|
||
cry: '😢', sob: '😭', scream: '😱', angry: '😠', rage: '💢', triumph: '😤', sleepy: '😪',
|
||
dizzy_face: '😵', zipper_mouth: '🤐', nerd: '🤓', thinking: '🤔', rolling_eyes: '🙄',
|
||
expressionless: '😑', thumbsup: '👍', thumbsdown: '👎', clap: '👏', pray: '🙏', muscle: '💪',
|
||
ok: '👌', point_up: '👆', point_down: '👇', point_left: '👈', point_right: '👉',
|
||
raised_hands: '🙌', wave: '👋', punch: '👊', crossed_fingers: '🤞',
|
||
heart: '❤️', broken_heart: '💔', star: '⭐', star2: '🌟', fire: '🔥', rocket: '🚀',
|
||
check: '✅', cross: '❌', warning: '⚠️', info: 'ℹ️', question: '❓', exclamation: '❗',
|
||
bangbang: '‼️', grey_exclamation: '❕', bulb: '💡', book: '📖', memo: '📝', pin: '📌',
|
||
link: '🔗', lock: '🔒', unlock: '🔓', key: '🔑', hammer: '🔨', wrench: '🔧',
|
||
gear: '⚙️', tools: '🛠️', magnet: '🧲', zap: '⚡', cloud: '☁️',
|
||
sun: '☀️', moon: '🌙', rain: '🌧️', snow: '❄️', umbrella: '☂️', rainbow: '🌈',
|
||
tornado: '🌪️', fog: '🌫️', droplet: '💧',
|
||
coffee: '☕', pizza: '🍕', cake: '🎂', beer: '🍺', wine: '🍷',
|
||
hamburger: '🍔', fries: '🍟', apple: '🍎', banana: '🍌', grapes: '🍇',
|
||
watermelon: '🍉', strawberry: '🍓', peach: '🍑', cherry: '🍒', taco: '🌮',
|
||
tada: '🎉', gift: '🎁', crown: '👑', gem: '💎', ring: '💍',
|
||
confetti: '🎊', balloon: '🎈', ribbon: '🎀', medal: '🏅', trophy: '🏆',
|
||
eye: '👁️', ear: '👂', nose: '👃', tongue: '👅', lips: '👄',
|
||
brain: '🧠', speech: '💬', thought: '💭', anger: '💢', sweat: '💦',
|
||
one: '1️⃣', two: '2️⃣', three: '3️⃣', four: '4️⃣', five: '5️⃣',
|
||
six: '6️⃣', seven: '7️⃣', eight: '8️⃣', nine: '9️⃣', zero: '0️⃣',
|
||
arrow_up: '⬆️', arrow_down: '⬇️', arrow_left: '⬅️', arrow_right: '➡️',
|
||
arrow_upper_right: '↗️', arrow_lower_right: '↘️', arrow_lower_left: '↙️', arrow_upper_left: '↖️',
|
||
computer: '💻', phone: '📱', battery: '🔋', electric_plug: '🔌',
|
||
copyright: '©️', registered: '®️', tm: '™️', hundred: '💯', boom: '💥', dash: '💨',
|
||
hole: '🕳️', bomb: '💣', email: '📧', phone2: '📞', clock: '🕐', hourglass: '⏳',
|
||
calendar: '📅', money: '💰', shopping: '🛒', package: '📦', mailbox: '📫',
|
||
art: '🎨', music: '🎵', movie: '🎬', game: '🎮', sport: '⚽',
|
||
earth: '🌍', house: '🏠', car: '🚗', airplane: '✈️', ship: '🚢',
|
||
};
|
||
// ============ Render cache ============
|
||
const renderCache = new Map();
|
||
const MAX_CACHE_SIZE = 300;
|
||
const cachedRenderInline = (text, env) => {
|
||
if (!text)
|
||
return '';
|
||
if (!env.highlight && text.length < 600) {
|
||
const cached = renderCache.get(text);
|
||
if (cached !== undefined)
|
||
return cached;
|
||
const result = renderInline(text, env);
|
||
if (renderCache.size >= MAX_CACHE_SIZE) {
|
||
const firstKey = renderCache.keys().next().value;
|
||
renderCache.delete(firstKey);
|
||
}
|
||
renderCache.set(text, result);
|
||
return result;
|
||
}
|
||
return renderInline(text, env);
|
||
};
|
||
const clearRenderCache = () => { renderCache.clear(); };
|
||
// ============ Security utils ============
|
||
const safeUrl = (url) => {
|
||
if (!url)
|
||
return '';
|
||
const u = String(url).trim();
|
||
if (/^(javascript|vbscript|file):/i.test(u))
|
||
return '';
|
||
if (/^data:/i.test(u)) {
|
||
if (!/^data:image\//i.test(u))
|
||
return '';
|
||
if (u.length > 500000)
|
||
return '';
|
||
}
|
||
return u;
|
||
};
|
||
const slugify = (text) => {
|
||
let slug = String(text).normalize('NFKC').toLowerCase()
|
||
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf\s-]/g, '')
|
||
.trim().replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||
return slug || 'heading';
|
||
};
|
||
// ============ Block handler registry ============
|
||
const blockHandlers = [];
|
||
const registerBlockHandler = (handler) => {
|
||
blockHandlers.push(handler);
|
||
blockHandlers.sort((a, b) => a.priority - b.priority);
|
||
};
|
||
const isBlockStart = (line) => {
|
||
if (RE_EMPTY.test(line) || RE_FENCE_START.test(line) || RE_ATX.test(line))
|
||
return true;
|
||
if (RE_HR.test(line) || RE_QUOTE.test(line) || RE_UL.test(line) || RE_OL.test(line))
|
||
return true;
|
||
if (RE_FOOTNOTE_DEF.test(line) || RE_DEF_LIST.test(line))
|
||
return true;
|
||
if (/^\$\$/.test(line))
|
||
return true;
|
||
return false;
|
||
};
|
||
// Register all built-in block handlers
|
||
registerBlockHandler({ name: 'refDef', priority: 0.1,
|
||
test: (line) => {
|
||
// Must not match footnote definitions: [^id]: text
|
||
if (/^\[\^/.test(line))
|
||
return null;
|
||
const m = line.match(/^\[([^\]]+)\]:\s*(?:<(\S+)>|(\S+))(?:\s+['"\(](.*?)['"\)])?\s*$/);
|
||
return m ? m : null;
|
||
},
|
||
parse: (_lines, i, match, _tokens, _footnotes, refs) => {
|
||
const refId = match[1].toLowerCase();
|
||
const url = match[2] || match[3] || '';
|
||
const title = match[4] || '';
|
||
if (url && refs) {
|
||
refs[refId] = JSON.stringify({ url, title });
|
||
}
|
||
return { token: null, newIndex: i + 1 };
|
||
},
|
||
});
|
||
registerBlockHandler({ name: 'blank', priority: 0,
|
||
test: (line) => RE_EMPTY.test(line) ? true : null,
|
||
parse: (_lines, i) => ({ token: null, newIndex: i + 1 }),
|
||
});
|
||
registerBlockHandler({ name: 'fencedCode', priority: 1,
|
||
test: (line) => {
|
||
const m = line.match(RE_FENCE_START);
|
||
if (!m)
|
||
return null;
|
||
// Reject fences where the info string contains backticks (false positives like ``` `code` ```)
|
||
const rest = (m[4] || '');
|
||
if (rest.includes('`'))
|
||
return null;
|
||
return m;
|
||
},
|
||
parse: (lines, i, match) => {
|
||
const fenceChar = match[2][0];
|
||
const fenceLen = match[2].length;
|
||
const lang = match[3] || '';
|
||
const rest = (match[4] || '').trim();
|
||
// Parse info string: first word is lang, rest are key=value or standalone flags
|
||
const infoParts = rest ? rest.split(/\s+/).filter(Boolean) : [];
|
||
const attrs = {};
|
||
for (let p = 0; p < infoParts.length; p++) {
|
||
const kv = infoParts[p].split('=');
|
||
if (kv.length === 2)
|
||
attrs[kv[0]] = kv[1];
|
||
else
|
||
attrs[infoParts[p]] = 'true';
|
||
}
|
||
const codeLines = [];
|
||
let j = i + 1;
|
||
while (j < lines.length) {
|
||
const cm = lines[j].match(/^(\s{0,3})(`{3,}|~{3,})\s*$/);
|
||
if (cm && cm[2][0] === fenceChar && cm[2].length >= fenceLen)
|
||
break;
|
||
codeLines.push(lines[j]);
|
||
j++;
|
||
}
|
||
if (j < lines.length)
|
||
j++;
|
||
return { token: { type: 'code', lang, content: codeLines.join('\n'), attrs }, newIndex: j };
|
||
},
|
||
});
|
||
registerBlockHandler({ name: 'indentedCode', priority: 2,
|
||
test: (line) => {
|
||
if (!RE_INDENT_CODE.test(line))
|
||
return null;
|
||
if (RE_ATX.test(line) || RE_QUOTE.test(line) || RE_UL.test(line) || RE_OL.test(line))
|
||
return null;
|
||
return true;
|
||
},
|
||
parse: (lines, i) => {
|
||
const codeLines = [];
|
||
while (i < lines.length && RE_INDENT_CODE.test(lines[i])) {
|
||
codeLines.push(lines[i].replace(/^( {4}|\t)/, ''));
|
||
i++;
|
||
}
|
||
if (codeLines.length)
|
||
return { token: { type: 'code', lang: '', content: codeLines.join('\n'), indent: true }, newIndex: i };
|
||
return { token: null, newIndex: i };
|
||
},
|
||
});
|
||
registerBlockHandler({ name: 'atxHeading', priority: 3,
|
||
test: (line) => line.match(RE_ATX),
|
||
parse: (_lines, i, match) => ({
|
||
token: { type: 'heading', level: match[1].length, text: match[2] }, newIndex: i + 1,
|
||
}),
|
||
});
|
||
registerBlockHandler({ name: 'setextH1', priority: 4,
|
||
test: (line, lines, i) => {
|
||
if (i + 1 >= lines.length)
|
||
return null;
|
||
if (!RE_SETEXT_H1.test(lines[i + 1]))
|
||
return null;
|
||
if (RE_EMPTY.test(line) || RE_UL.test(line) || RE_OL.test(line) || RE_QUOTE.test(line) || /^\s*`{3,}/.test(line))
|
||
return null;
|
||
return true;
|
||
},
|
||
parse: (lines, i) => ({ token: { type: 'heading', level: 1, text: lines[i] }, newIndex: i + 2 }),
|
||
});
|
||
registerBlockHandler({ name: 'setextH2', priority: 5,
|
||
test: (line, lines, i) => {
|
||
if (i + 1 >= lines.length)
|
||
return null;
|
||
if (!RE_SETEXT_H2.test(lines[i + 1]))
|
||
return null;
|
||
if (RE_EMPTY.test(line) || RE_UL.test(line) || RE_OL.test(line) || RE_QUOTE.test(line) || /^\s*`{3,}/.test(line))
|
||
return null;
|
||
return true;
|
||
},
|
||
parse: (lines, i) => ({ token: { type: 'heading', level: 2, text: lines[i] }, newIndex: i + 2 }),
|
||
});
|
||
registerBlockHandler({ name: 'hr', priority: 6,
|
||
test: (line) => RE_HR.test(line) ? true : null,
|
||
parse: (_lines, i) => ({ token: { type: 'hr' }, newIndex: i + 1 }),
|
||
});
|
||
registerBlockHandler({ name: 'blockquote', priority: 7,
|
||
test: (line) => RE_QUOTE.test(line) ? true : null,
|
||
parse: (lines, i) => {
|
||
const quoteLines = [];
|
||
while (i < lines.length && RE_QUOTE.test(lines[i])) {
|
||
quoteLines.push(lines[i].replace(RE_QUOTE, ''));
|
||
i++;
|
||
}
|
||
return { token: { type: 'quote', content: quoteLines.join('\n') }, newIndex: i };
|
||
},
|
||
});
|
||
registerBlockHandler({ name: 'table', priority: 8,
|
||
test: (line, lines, i) => {
|
||
if (!/\|/.test(line) || i + 1 >= lines.length)
|
||
return null;
|
||
if (!isTableSeparator(lines[i + 1]))
|
||
return null;
|
||
return { header: line, align: parseAlign(lines[i + 1]) };
|
||
},
|
||
parse: (lines, i, match) => {
|
||
let j = i + 2;
|
||
const rows = [];
|
||
while (j < lines.length && /\|/.test(lines[j]) && !RE_EMPTY.test(lines[j])) {
|
||
rows.push(lines[j]);
|
||
j++;
|
||
}
|
||
return { token: { type: 'table', header: match.header, rows, align: match.align }, newIndex: j };
|
||
},
|
||
});
|
||
registerBlockHandler({ name: 'ul', priority: 9,
|
||
test: (line) => line.match(RE_UL),
|
||
parse: (lines, i, match) => {
|
||
const { items, endIdx } = parseList(lines, i, match[2], match[1].length, false);
|
||
return { token: { type: 'ul', items }, newIndex: endIdx };
|
||
},
|
||
});
|
||
registerBlockHandler({ name: 'ol', priority: 10,
|
||
test: (line) => line.match(RE_OL),
|
||
parse: (lines, i, match) => {
|
||
const start = parseInt(match[2], 10);
|
||
const { items, endIdx } = parseList(lines, i, match[2], match[1].length, true);
|
||
return { token: { type: 'ol', items, start }, newIndex: endIdx };
|
||
},
|
||
});
|
||
registerBlockHandler({ name: 'footnoteDef', priority: 11,
|
||
test: (line) => line.match(RE_FOOTNOTE_DEF),
|
||
parse: (lines, i, match, _tokens, footnotes) => {
|
||
const fnId = match[1];
|
||
const fnContent = lines[i].slice(match[0].length);
|
||
const fnLines = [fnContent];
|
||
let j = i + 1;
|
||
while (j < lines.length && !RE_EMPTY.test(lines[j]) && !isBlockStart(lines[j])) {
|
||
fnLines.push(lines[j]);
|
||
j++;
|
||
}
|
||
footnotes[fnId] = fnLines.join(' ');
|
||
return { token: null, newIndex: j };
|
||
},
|
||
});
|
||
registerBlockHandler({ name: 'defList', priority: 12,
|
||
test: (line) => RE_DEF_LIST.test(line) ? true : null,
|
||
parse: (lines, i, _match, tokens) => {
|
||
const term = (tokens.length > 0 && tokens[tokens.length - 1].type === 'paragraph') ? tokens.pop().text : '';
|
||
const defs = [lines[i].replace(RE_DEF_LIST, '')];
|
||
let j = i + 1;
|
||
while (j < lines.length && RE_DEF_LIST.test(lines[j])) {
|
||
defs.push(lines[j].replace(RE_DEF_LIST, ''));
|
||
j++;
|
||
}
|
||
return { token: { type: 'defList', term, defs }, newIndex: j };
|
||
},
|
||
});
|
||
registerBlockHandler({ name: 'mathBlock', priority: 13,
|
||
test: (line) => /^\$\$/.test(line) ? true : null,
|
||
parse: (lines, i) => {
|
||
const single = lines[i].match(/^\$\$([\s\S]+?)\$\$\s*$/);
|
||
if (single)
|
||
return { token: { type: 'mathBlock', content: single[1] }, newIndex: i + 1 };
|
||
const mathLines = [lines[i].replace(/^\$\$/, '')];
|
||
let j = i + 1;
|
||
while (j < lines.length && !/\$\$/.test(lines[j])) {
|
||
mathLines.push(lines[j]);
|
||
j++;
|
||
}
|
||
if (j < lines.length) {
|
||
mathLines.push(lines[j].replace(/\$\$\s*$/, ''));
|
||
j++;
|
||
}
|
||
return { token: { type: 'mathBlock', content: mathLines.join('\n') }, newIndex: j };
|
||
},
|
||
});
|
||
// ============ Block parsing ============
|
||
const parseTokens = (md) => {
|
||
if (md == null)
|
||
return { tokens: [], footnotes: {}, refs: {} };
|
||
const text = String(md).replace(/\r\n?/g, '\n');
|
||
const lines = text.split('\n');
|
||
const tokens = [];
|
||
const footnotes = {};
|
||
const refs = {};
|
||
let i = 0;
|
||
while (i < lines.length) {
|
||
const line = lines[i];
|
||
let handled = false;
|
||
for (const handler of blockHandlers) {
|
||
const match = handler.test(line, lines, i);
|
||
if (match !== null && match !== false) {
|
||
const result = handler.parse(lines, i, match, tokens, footnotes, refs);
|
||
if (result.token)
|
||
tokens.push(result.token);
|
||
i = result.newIndex;
|
||
handled = true;
|
||
break;
|
||
}
|
||
}
|
||
if (handled)
|
||
continue;
|
||
// Paragraph fallback
|
||
const para = [];
|
||
while (i < lines.length) {
|
||
const l = lines[i];
|
||
if (RE_EMPTY.test(l))
|
||
break;
|
||
if (isBlockStart(l))
|
||
break;
|
||
if (i + 1 < lines.length && (RE_SETEXT_H1.test(lines[i + 1]) || RE_SETEXT_H2.test(lines[i + 1])))
|
||
break;
|
||
if (/\|/.test(l) && i + 1 < lines.length && isTableSeparator(lines[i + 1]))
|
||
break;
|
||
para.push(l);
|
||
i++;
|
||
}
|
||
if (para.length)
|
||
tokens.push({ type: 'paragraph', text: para.join('\n') });
|
||
}
|
||
return { tokens, footnotes, refs };
|
||
};
|
||
// ============ Token rendering ============
|
||
const renderTokens = (tokens, env = {}, footnotes = {}) => {
|
||
let html = tokens.map((tok) => renderToken(tok, env)).join('\n');
|
||
const fnIds = Object.keys(footnotes);
|
||
if (fnIds.length) {
|
||
html += '\n<div class="me-footnotes"><hr/><ol>';
|
||
fnIds.forEach((id) => {
|
||
html += `<li id="fn-${id}" class="me-footnote-item"><a href="#fnref-${id}" class="me-footnote-backref">↩</a> ${cachedRenderInline(footnotes[id], env)}</li>`;
|
||
});
|
||
html += '</ol></div>';
|
||
}
|
||
return html;
|
||
};
|
||
const parseMarkdown = (md, env = {}) => {
|
||
const { tokens, footnotes, refs } = parseTokens(md);
|
||
return renderTokens(tokens, { ...env, refs }, footnotes);
|
||
};
|
||
// ============ List parsing ============
|
||
const parseList = (lines, startIdx, marker, baseIndent, isOl, olStart) => {
|
||
const items = [];
|
||
let i = startIdx;
|
||
const itemIndent = baseIndent + (isOl ? String(marker).length + 2 : 2);
|
||
while (i < lines.length) {
|
||
const line = lines[i];
|
||
let itemMatch;
|
||
if (isOl) {
|
||
itemMatch = line.match(new RegExp(`^(\\s{${baseIndent}})\\d+\\.\\s`));
|
||
if (!itemMatch)
|
||
break;
|
||
}
|
||
else {
|
||
itemMatch = line.match(new RegExp(`^(\\s{${baseIndent}})[-*+]\\s`));
|
||
if (!itemMatch)
|
||
break;
|
||
}
|
||
let raw = line.slice(itemMatch[0].length);
|
||
const task = raw.match(RE_TASK);
|
||
let content = task ? task[2] : raw;
|
||
const checked = task ? task[1].toLowerCase() === 'x' : false;
|
||
const isTask = !!task;
|
||
i++;
|
||
const subContent = [];
|
||
const subTokens = [];
|
||
while (i < lines.length) {
|
||
const cl = lines[i];
|
||
if (RE_EMPTY.test(cl)) {
|
||
let peek = i + 1;
|
||
while (peek < lines.length && RE_EMPTY.test(lines[peek]))
|
||
peek++;
|
||
if (peek < lines.length) {
|
||
const pl = lines[peek];
|
||
const isCont = pl.length > itemIndent && pl[itemIndent] !== ' '
|
||
? false : new RegExp(`^\\s{${itemIndent},}`).test(pl);
|
||
if (isCont) {
|
||
subContent.push('');
|
||
i++;
|
||
continue;
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
if (isOl) {
|
||
if (new RegExp(`^(\\s{0,${baseIndent}})\\d+\\.\\s`).test(cl))
|
||
break;
|
||
}
|
||
else {
|
||
if (new RegExp(`^(\\s{0,${baseIndent}})[-*+]\\s`).test(cl))
|
||
break;
|
||
}
|
||
if (RE_QUOTE.test(cl) || RE_ATX.test(cl) || RE_HR.test(cl) || RE_FOOTNOTE_DEF.test(cl))
|
||
break;
|
||
const nUl = cl.match(new RegExp(`^(\\s{${itemIndent},})([-*+])\\s`));
|
||
const nOl = cl.match(new RegExp(`^(\\s{${itemIndent},})(\\d+)\\.\\s`));
|
||
if (nUl || nOl) {
|
||
const isNOl = !!nOl;
|
||
const nM = isNOl ? nOl[2] : nUl[2];
|
||
const nI = isNOl ? nOl[1].length : nUl[1].length;
|
||
const nS = isNOl ? parseInt(nOl[2], 10) : undefined;
|
||
const nested = parseList(lines, i, nM, nI, isNOl);
|
||
subTokens.push({ type: isNOl ? 'ol' : 'ul', items: nested.items, start: nS });
|
||
i = nested.endIdx;
|
||
continue;
|
||
}
|
||
if (cl.length > itemIndent && cl.slice(0, itemIndent).trim() === '') {
|
||
subContent.push(cl.slice(itemIndent));
|
||
}
|
||
else {
|
||
subContent.push(cl);
|
||
}
|
||
i++;
|
||
}
|
||
if (subContent.length) {
|
||
content = content ? content + '\n' + subContent.join('\n') : subContent.join('\n');
|
||
}
|
||
items.push({ type: 'listItem', text: content, checked, task: isTask, subTokens: subTokens.length ? subTokens : undefined });
|
||
}
|
||
return { items, endIdx: i };
|
||
};
|
||
// ============ Table utils ============
|
||
const isTableSeparator = (line) => RE_TABLE_SEP.test(line) && /-/.test(line);
|
||
const parseAlign = (line) => {
|
||
const cells = line.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
|
||
return cells.map((c) => {
|
||
if (/^:/.test(c) && /:$/.test(c))
|
||
return 'center';
|
||
if (/:$/.test(c))
|
||
return 'right';
|
||
if (/^:/.test(c))
|
||
return 'left';
|
||
return 'left';
|
||
});
|
||
};
|
||
// ============ Token renderer ============
|
||
const renderToken = (tok, env, footnotes) => {
|
||
switch (tok.type) {
|
||
case 'heading': {
|
||
const id = slugify(tok.text);
|
||
return `<h${tok.level} id="${id}">${cachedRenderInline(tok.text, env)}</h${tok.level}>`;
|
||
}
|
||
case 'paragraph': return `<p>${cachedRenderInline(tok.text, env)}</p>`;
|
||
case 'hr': return '<hr/>';
|
||
case 'quote': return `<blockquote>${parseMarkdown(tok.content, env)}</blockquote>`;
|
||
case 'code': return renderCode(tok.content, tok.lang, env, tok.attrs);
|
||
case 'ul': return `<ul>${tok.items.map((it) => renderListItem(it, env)).join('')}</ul>`;
|
||
case 'ol': {
|
||
const sn = tok.start || 1;
|
||
const sa = sn > 1 ? ` start="${sn}"` : '';
|
||
const body = tok.items.map((it, idx) => renderListItem(it, env, sn > 1 ? idx + sn : undefined)).join('');
|
||
return `<ol${sa}>${body}</ol>`;
|
||
}
|
||
case 'table': return renderTable(tok, env);
|
||
case 'defList': {
|
||
let h = '<dl>';
|
||
h += `<dt>${cachedRenderInline(tok.term, env)}</dt>`;
|
||
tok.defs.forEach((d) => { h += `<dd>${cachedRenderInline(d, env)}</dd>`; });
|
||
return h + '</dl>';
|
||
}
|
||
case 'mathBlock': return `<div class="me-math-block">${escapeHTML(tok.content)}</div>`;
|
||
default: {
|
||
// Fallback: render unknown block types as styled div
|
||
const typeClass = `me-block-${tok.type}`;
|
||
const content = tok.content ? (typeof tok.content === 'string' ? cachedRenderInline(tok.content, env) : escapeHTML(String(tok.content))) : '';
|
||
return `<div class="${typeClass}">${content}</div>`;
|
||
}
|
||
}
|
||
};
|
||
const renderListItem = (it, env, idx) => {
|
||
if (it.task) {
|
||
const checked = it.checked ? ' checked' : '';
|
||
let h = `<li class="me-task-item"><input type="checkbox" disabled${checked}/> ${cachedRenderInline(it.text, env)}`;
|
||
if (it.subTokens)
|
||
h += '\n' + it.subTokens.map((st) => renderToken(st, env)).join('\n');
|
||
return h + '</li>';
|
||
}
|
||
let h = idx != null ? `<li value="${idx}">` : '<li>';
|
||
h += cachedRenderInline(it.text, env);
|
||
if (it.subTokens)
|
||
h += '\n' + it.subTokens.map((st) => renderToken(st, env)).join('\n');
|
||
return h + '</li>';
|
||
};
|
||
const renderCode = (code, lang, env, attrs) => {
|
||
if (lang === 'mermaid')
|
||
return `<div class="me-mermaid"><pre class="mermaid">${escapeHTML(code)}</pre></div>`;
|
||
const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : '';
|
||
const titleHtml = attrs?.title ? `<div class="me-code-title">${escapeHTML(attrs.title)}</div>` : '';
|
||
if (env.highlight && typeof env.highlight === 'function' && lang) {
|
||
try {
|
||
const highlighted = env.highlight(code, lang);
|
||
if (typeof highlighted === 'string')
|
||
return `${titleHtml}<pre><code${langClass}>${highlighted}</code></pre>`;
|
||
}
|
||
catch (e) {
|
||
console.error('MeEditor highlight error:', e);
|
||
}
|
||
}
|
||
return `${titleHtml}<pre><code${langClass}>${escapeHTML(code)}</code></pre>`;
|
||
};
|
||
const renderTable = (tok, env) => {
|
||
// Split respecting backslash-escaped pipes
|
||
const splitRow = (r) => {
|
||
const cells = [];
|
||
let current = '';
|
||
let escaped = false;
|
||
for (let i = 0; i < r.length; i++) {
|
||
const ch = r[i];
|
||
if (escaped) {
|
||
current += ch;
|
||
escaped = false;
|
||
}
|
||
else if (ch === '\\') {
|
||
escaped = true;
|
||
}
|
||
else if (ch === '|') {
|
||
cells.push(current.trim());
|
||
current = '';
|
||
}
|
||
else {
|
||
current += ch;
|
||
}
|
||
}
|
||
cells.push(current.trim());
|
||
// Remove leading/trailing empty cells from outer pipes
|
||
if (cells.length > 0 && cells[0] === '')
|
||
cells.shift();
|
||
if (cells.length > 0 && cells[cells.length - 1] === '')
|
||
cells.pop();
|
||
return cells;
|
||
};
|
||
const headers = splitRow(tok.header);
|
||
const align = tok.align || [];
|
||
const as = (i) => align[i] && align[i] !== 'left' ? ` style="text-align:${align[i]}"` : '';
|
||
let h = '<div class="me-table-wrap"><table><thead><tr>';
|
||
h += headers.map((hd, i) => `<th${as(i)}>${cachedRenderInline(hd, env)}</th>`).join('');
|
||
h += '</tr></thead><tbody>';
|
||
h += tok.rows.map((r) => {
|
||
const cells = splitRow(r);
|
||
return `<tr>${cells.map((c, i) => `<td${as(i)}>${cachedRenderInline(c, env)}</td>`).join('')}</tr>`;
|
||
}).join('');
|
||
return h + '</tbody></table></div>';
|
||
};
|
||
// ============ Inline rendering ============
|
||
const renderInline = (text, env) => {
|
||
if (!text)
|
||
return '';
|
||
const codes = [];
|
||
let s = extractInlineCodes(text, codes);
|
||
const protectedItems = [];
|
||
s = s.replace(/&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});/g, (m) => {
|
||
protectedItems.push(m);
|
||
return `\u0005${protectedItems.length - 1}\u0005`;
|
||
});
|
||
s = s.replace(/<!--[\s\S]*?-->/g, (m) => {
|
||
protectedItems.push(m);
|
||
return `\u0005${protectedItems.length - 1}\u0005`;
|
||
});
|
||
s = escapeHTML(s);
|
||
s = s.replace(/\u0005(\d+)\u0005/g, (_m, idx) => protectedItems[+idx] || _m);
|
||
s = scanInline(s, codes, env);
|
||
s = s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => {
|
||
const code = codes[+idx];
|
||
if (!code)
|
||
return _m;
|
||
return `<code>${escapeHTML(code.content)}</code>`;
|
||
});
|
||
s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m);
|
||
s = s.replace(/ \n/g, '<br/>\n');
|
||
s = s.replace(/\n/g, '<br/>');
|
||
return s;
|
||
};
|
||
const extractInlineCodes = (text, codes) => {
|
||
let result = '';
|
||
let i = 0;
|
||
while (i < text.length) {
|
||
if (text[i] === '`') {
|
||
let startLen = 1;
|
||
while (i + startLen < text.length && text[i + startLen] === '`')
|
||
startLen++;
|
||
let j = i + startLen;
|
||
let found = false;
|
||
while (j < text.length) {
|
||
const nextTick = text.indexOf('`', j);
|
||
if (nextTick === -1)
|
||
break;
|
||
let endLen = 1;
|
||
while (nextTick + endLen < text.length && text[nextTick + endLen] === '`')
|
||
endLen++;
|
||
if (endLen === startLen) {
|
||
const content = text.slice(i + startLen, nextTick);
|
||
codes.push({ content, len: startLen });
|
||
result += `\u0000${codes.length - 1}\u0000`;
|
||
i = nextTick + endLen;
|
||
found = true;
|
||
break;
|
||
}
|
||
j = nextTick + endLen;
|
||
}
|
||
if (!found) {
|
||
result += text.slice(i, i + startLen);
|
||
i += startLen;
|
||
}
|
||
}
|
||
else {
|
||
result += text[i];
|
||
i++;
|
||
}
|
||
}
|
||
return result;
|
||
};
|
||
const INLINE_RE = new RegExp([
|
||
'(\\*\\*\\*[^*\\n][^*\\n]*?\\*\\*\\*)',
|
||
'|(___[^_\\n][^_\\n]*?___)',
|
||
'|(!\\[[^\\]]*\\]\\([^)]+\\))',
|
||
'|(?<!!)(\\[[^\\]]+\\]\\([^)]+\\))',
|
||
'|(!\\[[^\\]]*\\]\\[[^\\]]*\\])',
|
||
'|(?<!!)(\\[[^\\]]+\\]\\[[^\\]]*\\])',
|
||
'|(<https?:\\/\\/[^\\s&]+>)',
|
||
'|(\\*\\*[^*\\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(') {
|
||
const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
|
||
if (!m)
|
||
return match;
|
||
const u = safeUrl(m[2]);
|
||
if (!u)
|
||
return escapeHTML(match);
|
||
const t = m[3] ? ` title="${m[3]}"` : '';
|
||
return `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`;
|
||
}
|
||
if (match.startsWith('![') && match.includes('][')) {
|
||
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
|
||
if (!m)
|
||
return match;
|
||
const refKey = (m[2] || m[1]).toLowerCase();
|
||
if (env.refs && env.refs[refKey]) {
|
||
try {
|
||
const ref = JSON.parse(env.refs[refKey]);
|
||
const u = safeUrl(ref.url);
|
||
if (!u)
|
||
return escapeHTML(match);
|
||
const t = ref.title ? ` title="${ref.title}"` : '';
|
||
return `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`;
|
||
}
|
||
catch (_) {
|
||
return `<img src="" alt="${m[1]}" class="me-img-ref"/>`;
|
||
}
|
||
}
|
||
return `<img src="" alt="${m[1]}" class="me-img-ref"/>`;
|
||
}
|
||
if (match.startsWith('[') && match.includes('](')) {
|
||
const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
|
||
if (!m)
|
||
return match;
|
||
const u = safeUrl(m[2]);
|
||
if (!u)
|
||
return match;
|
||
const t = m[3] ? ` title="${m[3]}"` : '';
|
||
const linkText = renderInline(m[1], env);
|
||
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
|
||
}
|
||
if (match.startsWith('[') && match.includes('][')) {
|
||
const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/);
|
||
if (!m)
|
||
return match;
|
||
const refKey = (m[2] || m[1]).toLowerCase();
|
||
if (env.refs && env.refs[refKey]) {
|
||
try {
|
||
const ref = JSON.parse(env.refs[refKey]);
|
||
const u = safeUrl(ref.url);
|
||
if (!u)
|
||
return escapeHTML(match);
|
||
const t = ref.title ? ` title="${ref.title}"` : '';
|
||
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
|
||
}
|
||
catch (_) {
|
||
return escapeHTML(match);
|
||
}
|
||
}
|
||
return escapeHTML(match);
|
||
}
|
||
if (match.startsWith('<http')) {
|
||
const url = match.slice(4, -4);
|
||
const u = safeUrl(url);
|
||
return `<a href="${u}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||
}
|
||
if (match.startsWith('**'))
|
||
return `<strong>${match.slice(2, -2)}</strong>`;
|
||
if (match.startsWith('__'))
|
||
return `<strong>${match.slice(2, -2)}</strong>`;
|
||
if (match.startsWith('~~'))
|
||
return `<del>${match.slice(2, -2)}</del>`;
|
||
if (match.startsWith('=='))
|
||
return `<mark>${match.slice(2, -2)}</mark>`;
|
||
if (match.startsWith('*') && !match.startsWith('**') && !match.startsWith('***'))
|
||
return `<em>${match.slice(1, -1)}</em>`;
|
||
if (match.startsWith('_') && !match.startsWith('__') && !match.startsWith('___'))
|
||
return `<em>${match.slice(1, -1)}</em>`;
|
||
if (match.startsWith('^') && !match.startsWith('^^'))
|
||
return `<sup>${match.slice(1, -1)}</sup>`;
|
||
if (match.startsWith('~') && !match.startsWith('~~'))
|
||
return `<sub>${match.slice(1, -1)}</sub>`;
|
||
if (match.startsWith('$') && !match.startsWith('$$'))
|
||
return `<span class="me-math-inline">${escapeHTML(match.slice(1, -1))}</span>`;
|
||
if (/^\[\^/.test(match)) {
|
||
const fnId = match.slice(2, -1);
|
||
return `<sup class="me-footnote-ref"><a href="#fn-${fnId}" id="fnref-${fnId}">[${fnId}]</a></sup>`;
|
||
}
|
||
if (match.startsWith('\\') && match.length === 2) {
|
||
const escaped = match[1];
|
||
if (/[!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~]/.test(escaped))
|
||
return escaped;
|
||
return match;
|
||
}
|
||
return match;
|
||
});
|
||
s = s.replace(/\u0003(\d+)\u0003/g, (m) => placeholderMap.get(m) || m);
|
||
return s;
|
||
};
|
||
|
||
/**
|
||
* MetonaEditor Plugins — plugin system v2
|
||
* @module plugins
|
||
* @version 0.2.0
|
||
*/
|
||
// ============ Plugin Manager ============
|
||
class PluginManager {
|
||
constructor() {
|
||
this.plugins = new Map();
|
||
}
|
||
register(name, plugin) {
|
||
if (this.plugins.has(name)) {
|
||
console.warn(`MeEditor: plugin "${name}" already registered`);
|
||
return this;
|
||
}
|
||
if (!plugin || typeof plugin !== 'object' || (!plugin.name && !name)) {
|
||
console.error(`MeEditor: invalid plugin "${name}"`);
|
||
return this;
|
||
}
|
||
this.plugins.set(name, {
|
||
version: plugin.version || '0.0.0', description: plugin.description || '',
|
||
depends: plugin.depends || [], priority: plugin.priority || 0,
|
||
...plugin, name, enabled: true,
|
||
});
|
||
return this;
|
||
}
|
||
unregister(name) { this.plugins.delete(name); return this; }
|
||
get(name) { return this.plugins.get(name) || null; }
|
||
has(name) { return this.plugins.has(name); }
|
||
getAll() { return Array.from(this.plugins.values()); }
|
||
getNames() { return Array.from(this.plugins.keys()); }
|
||
enable(name) { const p = this.plugins.get(name); if (p)
|
||
p.enabled = true; return this; }
|
||
disable(name) { const p = this.plugins.get(name); if (p)
|
||
p.enabled = false; return this; }
|
||
isEnabled(name) { const p = this.plugins.get(name); return p ? p.enabled : false; }
|
||
destroy() { this.plugins.clear(); }
|
||
}
|
||
const defaultPluginManager = new PluginManager();
|
||
// ============ Topological sort ============
|
||
const topologicalSort = (plugins) => {
|
||
const map = new Map();
|
||
plugins.forEach((p) => map.set(p.name, p));
|
||
const inDegree = new Map();
|
||
const adj = new Map();
|
||
plugins.forEach((p) => { inDegree.set(p.name, 0); adj.set(p.name, []); });
|
||
plugins.forEach((p) => {
|
||
(p.depends || []).forEach((dep) => {
|
||
if (map.has(dep)) {
|
||
adj.get(dep).push(p.name);
|
||
inDegree.set(p.name, (inDegree.get(p.name) || 0) + 1);
|
||
}
|
||
else {
|
||
console.warn(`MeEditor: plugin "${p.name}" depends on unknown "${dep}"`);
|
||
}
|
||
});
|
||
});
|
||
const queue = [];
|
||
inDegree.forEach((deg, name) => { if (deg === 0)
|
||
queue.push(name); });
|
||
const sorted = [];
|
||
while (queue.length) {
|
||
queue.sort((a, b) => ((map.get(b).priority || 0) - (map.get(a).priority || 0)));
|
||
const name = queue.shift();
|
||
sorted.push(map.get(name));
|
||
(adj.get(name) || []).forEach((n) => { inDegree.set(n, inDegree.get(n) - 1); if (inDegree.get(n) === 0)
|
||
queue.push(n); });
|
||
}
|
||
if (sorted.length !== plugins.length) {
|
||
console.warn('MeEditor: circular dependency detected, falling back to original order');
|
||
return plugins;
|
||
}
|
||
return sorted;
|
||
};
|
||
// ============ Config validation ============
|
||
const validateConfig = (schema = {}, config = {}) => {
|
||
const errors = [];
|
||
const patched = { ...config };
|
||
for (const [key, rule] of Object.entries(schema)) {
|
||
const val = config[key];
|
||
if (rule.required && (val === undefined || val === null)) {
|
||
errors.push(`"${key}" is required`);
|
||
continue;
|
||
}
|
||
if (val === undefined && rule.default !== undefined) {
|
||
patched[key] = rule.default;
|
||
continue;
|
||
}
|
||
if (val !== undefined && rule.type) {
|
||
const actual = Array.isArray(val) ? 'array' : typeof val;
|
||
if (actual !== rule.type)
|
||
errors.push(`"${key}" expected ${rule.type}, got ${actual}`);
|
||
}
|
||
if (val !== undefined && rule.enum && !rule.enum.includes(val))
|
||
errors.push(`"${key}" must be one of [${rule.enum.join(', ')}]`);
|
||
if (val !== undefined && typeof rule.validator === 'function') {
|
||
try {
|
||
const r = rule.validator(val);
|
||
if (r !== true)
|
||
errors.push(`"${key}": ${r}`);
|
||
}
|
||
catch (e) {
|
||
errors.push(`"${key}": ${e.message}`);
|
||
}
|
||
}
|
||
}
|
||
return { valid: errors.length === 0, errors, patched };
|
||
};
|
||
// ============ Preset plugins ============
|
||
const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||
const autoSavePlugin = {
|
||
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
|
||
install(editor, options) {
|
||
if (!editor || typeof editor.getValue !== 'function')
|
||
return;
|
||
const opts = options || this;
|
||
const key = opts.key || ('me-draft-' + (editor.id || ''));
|
||
const delay = opts.delay || 1000;
|
||
const state = { _timer: null };
|
||
const save = () => {
|
||
if (state._timer) {
|
||
clearTimeout(state._timer);
|
||
state._timer = null;
|
||
}
|
||
try {
|
||
localStorage.setItem(key, editor.getValue());
|
||
if (typeof editor._emit === 'function')
|
||
editor._emit('autosave', { key, value: editor.getValue() });
|
||
}
|
||
catch (e) {
|
||
console.warn('MeEditor autoSave:', e);
|
||
}
|
||
};
|
||
const _onInput = () => { if (state._timer)
|
||
clearTimeout(state._timer); state._timer = setTimeout(save, delay); };
|
||
const _onBlur = save;
|
||
const _onSave = save;
|
||
editor.on('change', _onInput);
|
||
editor.on('blur', _onBlur);
|
||
editor.on('save', _onSave);
|
||
editor.restoreDraft = () => { try {
|
||
const v = localStorage.getItem(key);
|
||
if (v != null)
|
||
editor.setValue(v);
|
||
return v;
|
||
}
|
||
catch (_) {
|
||
return null;
|
||
} };
|
||
editor.clearDraft = () => { try {
|
||
localStorage.removeItem(key);
|
||
}
|
||
catch (_) { } return editor; };
|
||
editor.getDraftKey = () => key;
|
||
editor.__autoSaveCleanup = { state, _onInput, _onBlur, _onSave, save };
|
||
},
|
||
destroy(editor) {
|
||
const cleanup = editor.__autoSaveCleanup;
|
||
if (cleanup) {
|
||
if (cleanup.state._timer) {
|
||
clearTimeout(cleanup.state._timer);
|
||
cleanup.state._timer = null;
|
||
}
|
||
if (editor && typeof editor.off === 'function') {
|
||
if (cleanup._onInput)
|
||
editor.off('change', cleanup._onInput);
|
||
if (cleanup._onBlur)
|
||
editor.off('blur', cleanup._onBlur);
|
||
if (cleanup._onSave)
|
||
editor.off('save', cleanup._onSave);
|
||
}
|
||
delete editor.__autoSaveCleanup;
|
||
}
|
||
},
|
||
};
|
||
const exportToolPlugin = {
|
||
name: 'exportTool', version: '0.1.0', description: 'Export Markdown/HTML', priority: 50,
|
||
install(editor) {
|
||
if (!editor || typeof editor.getValue !== 'function')
|
||
return;
|
||
const download = (filename, content, mime) => {
|
||
if (typeof document === 'undefined')
|
||
return;
|
||
const blob = new Blob([content], { type: mime + ';charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||
};
|
||
const stamp = () => { const d = new Date(); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; };
|
||
editor.exportMarkdown = (filename) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; };
|
||
editor.exportHTML = (filename, opts = {}) => {
|
||
const title = opts.title || 'Document';
|
||
const css = opts.css || '';
|
||
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
|
||
// Build minimal embedded CSS for the exported HTML
|
||
const embedCSS = opts.embedCSS !== false ? `<style>
|
||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;font-size:14px;line-height:1.6;color:#1f2937;max-width:860px;margin:0 auto;padding:20px}
|
||
h1,h2,h3,h4,h5,h6{margin:1.4em 0 .6em;font-weight:650;line-height:1.3}
|
||
h1{font-size:1.9em;padding-bottom:.3em;border-bottom:1px solid #e5e7eb}
|
||
h2{font-size:1.55em;padding-bottom:.3em;border-bottom:1px solid #e5e7eb}
|
||
h3{font-size:1.3em}h4{font-size:1.12em}h5{font-size:1em}h6{font-size:.9em;color:#6b7280}
|
||
p{margin:.7em 0}a{color:#3b82f6;text-decoration:none}a:hover{text-decoration:underline}
|
||
strong{font-weight:650}em{font-style:italic}del{text-decoration:line-through;opacity:.75}
|
||
ul,ol{margin:.6em 0;padding-left:1.6em}li{margin:.25em 0}
|
||
li.me-task-item{list-style:none;margin-left:-1.4em}
|
||
li.me-task-item input{margin-right:.5em;vertical-align:middle}
|
||
blockquote{margin:.8em 0;padding:.4em 1em;border-left:3px solid #3b82f6;background:#f3f4f6;border-radius:0 6px 6px 0}
|
||
hr{border:0;height:1px;background:#e5e7eb;margin:1.6em 0}
|
||
code{font-family:"SF Mono",Consolas,monospace;font-size:.88em;padding:.15em .4em;background:#f3f4f6;border-radius:4px}
|
||
pre{margin:.9em 0;padding:14px 16px;background:#f3f4f6;border-radius:8px;overflow-x:auto;border:1px solid #e5e7eb}
|
||
pre code{padding:0;background:transparent;font-size:.9em;line-height:1.6;border-radius:0}
|
||
img{max-width:100%;height:auto;border-radius:6px}
|
||
table{border-collapse:collapse;width:100%;font-size:.93em;display:block;overflow-x:auto}
|
||
th,td{border:1px solid #e5e7eb;padding:7px 12px;text-align:left}
|
||
th{background:#f3f4f6;font-weight:600}
|
||
tr:nth-child(even) td{background:#f9fafb}
|
||
mark{background:rgba(250,204,21,.3);color:inherit;padding:.1em .2em;border-radius:3px}
|
||
sup{font-size:.75em}sub{font-size:.75em}
|
||
.me-math-block{display:block;margin:1.2em 0;padding:12px 16px;background:#f3f4f6;border-radius:8px;overflow-x:auto;font-family:monospace;text-align:center}
|
||
.me-math-inline{font-family:monospace}
|
||
dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;color:#4b5563}
|
||
.me-footnote-ref a{font-size:.75em;vertical-align:super;text-decoration:none;color:#3b82f6}
|
||
.me-footnotes{margin-top:2em;border-top:1px solid #e5e7eb;padding-top:.8em;font-size:.9em;color:#6b7280}
|
||
.me-footnotes hr{display:none}
|
||
.me-footnotes ol{padding-left:1.2em}
|
||
.me-footnote-item{margin:.3em 0}
|
||
.me-footnote-backref{text-decoration:none;color:#3b82f6;margin-right:.4em}
|
||
.me-table-wrap{overflow-x:auto;margin:.9em 0}
|
||
</style>` : '';
|
||
download(filename || `metona-${stamp()}.html`, `<!DOCTYPE html>\n<html lang="${opts.lang || 'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css ? `<style>${css}</style>` : ''}${embedCSS}\n</head>\n<body>\n${body}\n</body>\n</html>`, 'text/html');
|
||
return editor;
|
||
};
|
||
},
|
||
destroy() { },
|
||
};
|
||
const searchReplacePlugin = {
|
||
name: 'searchReplace', version: '0.2.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
|
||
install(editor) {
|
||
if (!editor || !editor.textarea || typeof document === 'undefined')
|
||
return;
|
||
// Inject style once globally
|
||
if (!document.getElementById('me-search-style')) {
|
||
const style = document.createElement('style');
|
||
style.id = 'me-search-style';
|
||
style.textContent = `.me-search{position:absolute;top:8px;right:12px;z-index:20;display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);font-size:13px;min-width:280px}.me-search-row{display:flex;gap:4px;align-items:center}.me-search input{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));border-radius:4px;background:var(--md-textarea-bg,#fff);color:var(--md-text,#1f2937);font-size:13px}.me-search input:focus{outline:none;border-color:var(--md-accent,#3b82f6)}.me-search button{padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));background:var(--md-bg,#fff);color:var(--md-text,#1f2937);border-radius:4px;cursor:pointer;font-size:12px;line-height:1}.me-search button:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-count{color:var(--md-muted,#6b7280);font-size:12px;min-width:60px;text-align:center}.me-search .me-search-close{padding:2px 6px}`;
|
||
document.head.appendChild(style);
|
||
}
|
||
const state = {
|
||
_panel: null,
|
||
_regexMode: false,
|
||
_cleanup: null,
|
||
};
|
||
const _updateReplaceVisible = () => {
|
||
if (!state._panel)
|
||
return;
|
||
const show = state._panel.dataset.replace === '1';
|
||
const row = state._panel.querySelector('.me-search-replace-row');
|
||
if (row)
|
||
row.style.display = show ? 'flex' : 'none';
|
||
};
|
||
const _close = () => {
|
||
if (state._cleanup) {
|
||
state._cleanup();
|
||
state._cleanup = null;
|
||
}
|
||
state._panel = null;
|
||
if (editor && editor.textarea)
|
||
editor.textarea.focus();
|
||
};
|
||
const _open = (showReplace) => {
|
||
if (state._panel) {
|
||
state._panel.dataset.replace = showReplace ? '1' : '0';
|
||
_updateReplaceVisible();
|
||
const inp = state._panel.querySelector('.me-search-find');
|
||
if (inp) {
|
||
inp.focus();
|
||
inp.select();
|
||
}
|
||
return;
|
||
}
|
||
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
|
||
const panel = document.createElement('div');
|
||
panel.className = 'me-search';
|
||
panel.dataset.replace = showReplace ? '1' : '0';
|
||
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder') || 'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-prev">↑</button><button class="me-search-next">↓</button><span class="me-search-count"></span><button class="me-search-regex" title="Regex">.*</button><button class="me-search-close">×</button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder') || 'Replace'}"/><button class="me-search-replace-one">${t('replace') || 'Replace'}</button><button class="me-search-replace-all">${t('replaceAll') || 'All'}</button></div>`;
|
||
editor.el.appendChild(panel);
|
||
state._panel = panel;
|
||
_updateReplaceVisible();
|
||
state._regexMode = false;
|
||
const fi = panel.querySelector('.me-search-find');
|
||
const ri = panel.querySelector('.me-search-replace');
|
||
const ce = panel.querySelector('.me-search-count');
|
||
const regexBtn = panel.querySelector('.me-search-regex');
|
||
regexBtn.addEventListener('click', () => {
|
||
state._regexMode = !state._regexMode;
|
||
regexBtn.classList.toggle('me-active', state._regexMode);
|
||
lastIdxs = findAll();
|
||
});
|
||
const findAll = () => {
|
||
const q = fi.value;
|
||
if (!q) {
|
||
ce.textContent = '';
|
||
return [];
|
||
}
|
||
const idxs = [];
|
||
let from = 0;
|
||
if (state._regexMode) {
|
||
try {
|
||
const re = new RegExp(q, 'g');
|
||
let m;
|
||
while ((m = re.exec(editor.textarea.value)) !== null) {
|
||
idxs.push(m.index);
|
||
if (m[0].length === 0)
|
||
re.lastIndex++;
|
||
}
|
||
}
|
||
catch (_) {
|
||
ce.textContent = 'err';
|
||
return [];
|
||
}
|
||
}
|
||
else {
|
||
while (true) {
|
||
const idx = editor.textarea.value.indexOf(q, from);
|
||
if (idx === -1)
|
||
break;
|
||
idxs.push(idx);
|
||
from = idx + q.length;
|
||
}
|
||
}
|
||
ce.textContent = idxs.length ? `${idxs.length}` : '0';
|
||
return idxs;
|
||
};
|
||
let lastIdxs = [];
|
||
const selectAt = (idx, len) => {
|
||
editor.textarea.focus();
|
||
editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length));
|
||
};
|
||
const findNext = () => {
|
||
lastIdxs = findAll();
|
||
if (!lastIdxs.length)
|
||
return;
|
||
const cur = editor.textarea.selectionEnd;
|
||
const qlen = state._regexMode ? (() => { try {
|
||
const m = new RegExp(fi.value).exec(editor.textarea.value.substring(cur));
|
||
return m ? m[0].length : fi.value.length;
|
||
}
|
||
catch (_) {
|
||
return fi.value.length;
|
||
} })() : fi.value.length;
|
||
let next = lastIdxs.find((i) => i >= cur);
|
||
if (next == null)
|
||
next = lastIdxs[0];
|
||
selectAt(next, qlen);
|
||
};
|
||
const findPrev = () => {
|
||
lastIdxs = findAll();
|
||
if (!lastIdxs.length)
|
||
return;
|
||
const cur = editor.textarea.selectionStart;
|
||
const qlen = state._regexMode ? (() => { try {
|
||
const m = new RegExp(fi.value).exec(editor.textarea.value.substring(Math.max(0, cur - 100), cur + 100));
|
||
return m ? m[0].length : fi.value.length;
|
||
}
|
||
catch (_) {
|
||
return fi.value.length;
|
||
} })() : fi.value.length;
|
||
let prev = -1;
|
||
for (let i = lastIdxs.length - 1; i >= 0; i--) {
|
||
if (lastIdxs[i] < cur) {
|
||
prev = lastIdxs[i];
|
||
break;
|
||
}
|
||
}
|
||
if (prev === -1)
|
||
prev = lastIdxs[lastIdxs.length - 1];
|
||
selectAt(prev, qlen);
|
||
};
|
||
const replaceOne = () => {
|
||
const q = fi.value, r = ri.value;
|
||
if (!q)
|
||
return;
|
||
const ta = editor.textarea;
|
||
const s = ta.selectionStart, e = ta.selectionEnd;
|
||
const matchText = ta.value.substring(s, e);
|
||
if (state._regexMode) {
|
||
try {
|
||
if (new RegExp(q).test(matchText)) {
|
||
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
|
||
ta.setSelectionRange(s, s + r.length);
|
||
}
|
||
}
|
||
catch (_) { }
|
||
}
|
||
else if (matchText === q) {
|
||
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
|
||
ta.setSelectionRange(s, s + r.length);
|
||
}
|
||
editor._value = ta.value;
|
||
if (typeof editor._pushHistory === 'function')
|
||
editor._pushHistory();
|
||
if (typeof editor._render === 'function')
|
||
editor._render();
|
||
if (typeof editor._emit === 'function')
|
||
editor._emit('change', editor._value);
|
||
findNext();
|
||
};
|
||
const replaceAll = () => {
|
||
const q = fi.value, r = ri.value;
|
||
if (!q)
|
||
return;
|
||
const ta = editor.textarea;
|
||
if (state._regexMode) {
|
||
try {
|
||
ta.value = ta.value.replace(new RegExp(q, 'g'), r);
|
||
}
|
||
catch (_) {
|
||
return;
|
||
}
|
||
}
|
||
else {
|
||
ta.value = ta.value.split(q).join(r);
|
||
}
|
||
ta.setSelectionRange(0, 0);
|
||
editor._value = ta.value;
|
||
if (typeof editor._pushHistory === 'function')
|
||
editor._pushHistory();
|
||
if (typeof editor._render === 'function')
|
||
editor._render();
|
||
if (typeof editor._emit === 'function')
|
||
editor._emit('change', editor._value);
|
||
findAll();
|
||
};
|
||
fi.addEventListener('input', () => { lastIdxs = findAll(); });
|
||
fi.addEventListener('keydown', (e) => { if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
e.shiftKey ? findPrev() : findNext();
|
||
} if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
_close();
|
||
} });
|
||
ri.addEventListener('keydown', (e) => { if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
replaceOne();
|
||
} if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
_close();
|
||
} });
|
||
panel.querySelector('.me-search-next').addEventListener('click', findNext);
|
||
panel.querySelector('.me-search-prev').addEventListener('click', findPrev);
|
||
panel.querySelector('.me-search-close').addEventListener('click', () => _close());
|
||
panel.querySelector('.me-search-replace-one').addEventListener('click', replaceOne);
|
||
panel.querySelector('.me-search-replace-all').addEventListener('click', replaceAll);
|
||
fi.focus();
|
||
fi.select();
|
||
state._cleanup = () => { if (panel.parentNode)
|
||
panel.parentNode.removeChild(panel); };
|
||
};
|
||
const _onKeydown = (e) => {
|
||
const mod = e.ctrlKey || e.metaKey;
|
||
if (!mod)
|
||
return;
|
||
const k = e.key.toLowerCase();
|
||
if (k === 'f') {
|
||
e.preventDefault();
|
||
_open(false);
|
||
}
|
||
else if (k === 'h') {
|
||
e.preventDefault();
|
||
_open(true);
|
||
}
|
||
else if (k === 'escape' && state._panel) {
|
||
_close();
|
||
}
|
||
};
|
||
editor.textarea.addEventListener('keydown', _onKeydown);
|
||
// Expose for tests
|
||
editor.__srState = state;
|
||
editor.__srOpen = _open;
|
||
editor.__srClose = _close;
|
||
editor.__srKeydown = _onKeydown;
|
||
},
|
||
destroy(editor) {
|
||
const state = editor.__srState;
|
||
if (state) {
|
||
if (state._cleanup) {
|
||
state._cleanup();
|
||
state._cleanup = null;
|
||
}
|
||
state._panel = null;
|
||
}
|
||
const _onKeydown = editor.__srKeydown;
|
||
if (_onKeydown && editor && editor.textarea) {
|
||
editor.textarea.removeEventListener('keydown', _onKeydown);
|
||
}
|
||
delete editor.__srState;
|
||
delete editor.__srOpen;
|
||
delete editor.__srClose;
|
||
delete editor.__srKeydown;
|
||
},
|
||
};
|
||
const imagePastePlugin = {
|
||
name: 'imagePaste', version: '0.2.0', description: 'Paste image as base64', priority: 60,
|
||
install(editor) {
|
||
if (!editor || !editor.textarea || typeof document === 'undefined')
|
||
return;
|
||
const _onPaste = (e) => {
|
||
const items = e.clipboardData?.items;
|
||
if (!items)
|
||
return;
|
||
for (const item of items) {
|
||
if (item.type?.startsWith('image/')) {
|
||
e.preventDefault();
|
||
const reader = new FileReader();
|
||
reader.onload = () => { editor.insert(`\n`); };
|
||
reader.readAsDataURL(item.getAsFile());
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
editor.textarea.addEventListener('paste', _onPaste);
|
||
editor.__ipOnPaste = _onPaste;
|
||
},
|
||
destroy(editor) {
|
||
const _onPaste = editor.__ipOnPaste;
|
||
if (_onPaste && editor?.textarea)
|
||
editor.textarea.removeEventListener('paste', _onPaste);
|
||
delete editor.__ipOnPaste;
|
||
},
|
||
};
|
||
const shortcutHelpPlugin = {
|
||
name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200,
|
||
install(editor) {
|
||
if (!editor || !editor.textarea || typeof document === 'undefined')
|
||
return;
|
||
if (!document.getElementById('me-shortcut-style')) {
|
||
const s = document.createElement('style');
|
||
s.id = 'me-shortcut-style';
|
||
s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`;
|
||
document.head.appendChild(s);
|
||
}
|
||
let _panel = null;
|
||
const _close = () => { if (_panel) {
|
||
_panel.remove();
|
||
_panel = null;
|
||
} };
|
||
const _open = () => {
|
||
if (_panel) {
|
||
_close();
|
||
return;
|
||
}
|
||
// i18n-aware shortcut labels
|
||
const builtin = [
|
||
['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'],
|
||
['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'],
|
||
['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'],
|
||
['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'],
|
||
['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'],
|
||
['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'],
|
||
['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'],
|
||
['?', t('close') || 'Shortcuts'],
|
||
];
|
||
let rows = '';
|
||
builtin.forEach(([c, d]) => { rows += `<tr><td>${c}</td><td>${d}</td></tr>`; });
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'me-shortcut-overlay';
|
||
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('close') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
|
||
overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close'))
|
||
_close(); });
|
||
document.body.appendChild(overlay);
|
||
_panel = overlay;
|
||
};
|
||
const _onKeydown = (e) => {
|
||
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||
e.preventDefault();
|
||
_open();
|
||
}
|
||
if (e.key === 'Escape' && _panel)
|
||
_close();
|
||
};
|
||
editor.textarea.addEventListener('keydown', _onKeydown);
|
||
editor.__shState = { _panel, _open, _close, _onKeydown };
|
||
},
|
||
destroy(editor) {
|
||
const state = editor.__shState;
|
||
if (state) {
|
||
if (state._panel) {
|
||
state._panel.remove();
|
||
}
|
||
}
|
||
const _onKeydown = editor.__shState?._onKeydown;
|
||
if (_onKeydown && editor?.textarea)
|
||
editor.textarea.removeEventListener('keydown', _onKeydown);
|
||
delete editor.__shState;
|
||
},
|
||
};
|
||
const fileSystemPlugin = {
|
||
name: 'fileSystem', version: '0.2.0', description: 'File System Access API', priority: 90,
|
||
install(editor) {
|
||
if (!editor || typeof editor.getValue !== 'function')
|
||
return;
|
||
const hasAPI = typeof window !== 'undefined' && typeof window.showOpenFilePicker === 'function';
|
||
let _fileHandle = null;
|
||
editor.openFile = async (opts = {}) => {
|
||
if (!hasAPI) {
|
||
editor.toast?.('File System Access API not supported', { type: 'warning' });
|
||
return null;
|
||
}
|
||
try {
|
||
const [handle] = await window.showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md', '.txt', '.markdown'] } }], ...opts });
|
||
_fileHandle = handle;
|
||
const file = await handle.getFile();
|
||
const content = await file.text();
|
||
editor.setValue(content);
|
||
editor._emit?.('fileOpened', { name: file.name, handle });
|
||
return { name: file.name, content, handle };
|
||
}
|
||
catch (e) {
|
||
if (e.name !== 'AbortError')
|
||
console.error('Open file error:', e);
|
||
return null;
|
||
}
|
||
};
|
||
editor.saveFile = async (opts = {}) => {
|
||
let handle = _fileHandle;
|
||
if (!handle || opts.saveAs) {
|
||
if (!hasAPI) {
|
||
editor.toast?.('File System Access API not supported', { type: 'warning' });
|
||
return false;
|
||
}
|
||
try {
|
||
handle = await window.showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' });
|
||
_fileHandle = handle;
|
||
}
|
||
catch (e) {
|
||
if (e.name !== 'AbortError')
|
||
console.error('Save error:', e);
|
||
return false;
|
||
}
|
||
}
|
||
try {
|
||
const w = await handle.createWritable();
|
||
await w.write(editor.getValue());
|
||
await w.close();
|
||
editor._emit?.('fileSaved', { handle });
|
||
return true;
|
||
}
|
||
catch (e) {
|
||
_fileHandle = null;
|
||
if (!opts.saveAs)
|
||
return editor.saveFile({ ...opts, saveAs: true });
|
||
console.error('Write error:', e);
|
||
return false;
|
||
}
|
||
};
|
||
editor.saveFileAs = (name) => editor.saveFile({ saveAs: true, name });
|
||
editor.getFileHandle = () => _fileHandle;
|
||
editor.__fsCleanup = { getHandle: () => _fileHandle };
|
||
},
|
||
destroy(editor) {
|
||
if (editor) {
|
||
delete editor.openFile;
|
||
delete editor.saveFile;
|
||
delete editor.saveFileAs;
|
||
delete editor.getFileHandle;
|
||
}
|
||
delete editor.__fsCleanup;
|
||
},
|
||
};
|
||
// ============ Preset plugins table ============
|
||
const presetPlugins = {
|
||
autoSave: autoSavePlugin, exportTool: exportToolPlugin, searchReplace: searchReplacePlugin,
|
||
imagePaste: imagePastePlugin, shortcutHelp: shortcutHelpPlugin, fileSystem: fileSystemPlugin,
|
||
};
|
||
// ============ Plugin utils ============
|
||
const pluginUtils = {
|
||
createManager: () => new PluginManager(),
|
||
manager: defaultPluginManager,
|
||
register: (name, plugin) => defaultPluginManager.register(name, plugin),
|
||
unregister: (name) => defaultPluginManager.unregister(name),
|
||
get: (name) => defaultPluginManager.get(name),
|
||
has: (name) => defaultPluginManager.has(name),
|
||
getAll: () => defaultPluginManager.getAll(),
|
||
getNames: () => defaultPluginManager.getNames(),
|
||
enable: (name) => defaultPluginManager.enable(name),
|
||
disable: (name) => defaultPluginManager.disable(name),
|
||
isEnabled: (name) => defaultPluginManager.isEnabled(name),
|
||
getPreset: (name) => presetPlugins[name] ? { ...presetPlugins[name] } : null,
|
||
getAllPresets: () => Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {}),
|
||
createPlugin: (config = {}) => {
|
||
const result = { name: config.name || 'custom', version: config.version || '0.0.0', description: config.description || '', depends: config.depends || [], priority: config.priority || 0, install: () => { }, destroy: () => { }, ...config };
|
||
if (typeof result.install !== 'function')
|
||
result.install = () => { };
|
||
if (typeof result.destroy !== 'function')
|
||
result.destroy = () => { };
|
||
return result;
|
||
},
|
||
validatePlugin: (plugin) => {
|
||
const errors = [];
|
||
if (!plugin || typeof plugin !== 'object')
|
||
errors.push('plugin must be an object');
|
||
if (plugin && !plugin.name)
|
||
errors.push('plugin must have a name');
|
||
if (plugin && plugin.install && typeof plugin.install !== 'function')
|
||
errors.push('install must be a function');
|
||
return { valid: errors.length === 0, errors };
|
||
},
|
||
topologicalSort, validateConfig,
|
||
};
|
||
|
||
/**
|
||
* MetonaEditor Themes — theme system
|
||
* @module themes
|
||
* @version 0.2.0
|
||
*/
|
||
// ============ State ============
|
||
let globalCurrentTheme = 'auto';
|
||
let globalThemeListeners = new Set();
|
||
let _sysWatchUnsub = null;
|
||
// ============ System theme ============
|
||
const getSystemTheme = () => {
|
||
if (typeof window === 'undefined')
|
||
return 'light';
|
||
return prefersDark() ? 'dark' : 'light';
|
||
};
|
||
const _watchSystem = (callback) => {
|
||
if (typeof window === 'undefined' || !window.matchMedia)
|
||
return () => { };
|
||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||
const handler = (e) => callback(e.matches ? 'dark' : 'light');
|
||
mql.addEventListener('change', handler);
|
||
return () => mql.removeEventListener('change', handler);
|
||
};
|
||
const watchSystemTheme = () => {
|
||
if (typeof window === 'undefined' || !window.matchMedia)
|
||
return;
|
||
if (_sysWatchUnsub) {
|
||
_sysWatchUnsub();
|
||
_sysWatchUnsub = null;
|
||
}
|
||
_sysWatchUnsub = _watchSystem((sysTheme) => {
|
||
if (globalCurrentTheme === 'auto')
|
||
applyTheme('auto');
|
||
});
|
||
};
|
||
const unwatchSystemTheme = () => {
|
||
if (_sysWatchUnsub) {
|
||
_sysWatchUnsub();
|
||
_sysWatchUnsub = null;
|
||
}
|
||
};
|
||
// ============ Theme resolution ============
|
||
const resolveTheme = (theme) => {
|
||
if (!theme || theme === 'auto')
|
||
return getSystemTheme();
|
||
return theme;
|
||
};
|
||
const getThemeConfig = (theme) => {
|
||
const resolved = resolveTheme(theme);
|
||
const config = THEMES[resolved];
|
||
return (config && typeof config === 'object' ? config : THEMES.light);
|
||
};
|
||
// ============ CSS variables ============
|
||
const setThemeVariables = (config, target) => {
|
||
if (typeof document === 'undefined' || !config || typeof config !== 'object')
|
||
return;
|
||
const root = target || document.documentElement;
|
||
const vars = {
|
||
'--md-bg': config.bg, '--md-text': config.text, '--md-border': config.border,
|
||
'--md-shadow': config.shadow, '--md-hover-shadow': config.hoverShadow,
|
||
'--md-toolbar-bg': config.toolbarBg || config.bg,
|
||
'--md-textarea-bg': config.textareaBg || config.bg,
|
||
'--md-preview-bg': config.previewBg || config.bg,
|
||
'--md-code-bg': config.codeBg || 'rgba(127,127,127,0.1)',
|
||
'--md-code-text': config.codeText || config.text,
|
||
'--md-accent': config.accent || '#3b82f6',
|
||
'--md-muted': config.muted || '#9ca3af',
|
||
};
|
||
for (const [k, v] of Object.entries(vars)) {
|
||
if (v !== undefined && v !== null)
|
||
root.style.setProperty(k, String(v));
|
||
}
|
||
if (config.progressBg)
|
||
root.style.setProperty('--md-progress-bg', config.progressBg);
|
||
if (config.closeHoverBg)
|
||
root.style.setProperty('--md-close-hover-bg', config.closeHoverBg);
|
||
};
|
||
const exportCSSVars = (el) => {
|
||
const root = el || document.documentElement;
|
||
if (typeof document === 'undefined')
|
||
return {};
|
||
const style = getComputedStyle(root);
|
||
const varNames = ['--md-bg', '--md-text', '--md-border', '--md-shadow', '--md-hover-shadow', '--md-toolbar-bg', '--md-textarea-bg', '--md-preview-bg', '--md-code-bg', '--md-code-text', '--md-accent', '--md-muted'];
|
||
const result = {};
|
||
varNames.forEach((name) => { const val = style.getPropertyValue(name).trim(); if (val)
|
||
result[name] = val; });
|
||
return result;
|
||
};
|
||
const getCSSVariable = (name, el) => {
|
||
const root = el || document.documentElement;
|
||
if (typeof document === 'undefined')
|
||
return '';
|
||
const fullName = name.startsWith('--') ? name : `--md-${name}`;
|
||
return getComputedStyle(root).getPropertyValue(fullName).trim();
|
||
};
|
||
// ============ Apply theme ============
|
||
const applyTheme = (theme) => {
|
||
globalCurrentTheme = theme;
|
||
const resolved = resolveTheme(theme);
|
||
if (typeof document !== 'undefined') {
|
||
document.documentElement.setAttribute('data-md-theme', resolved);
|
||
document.documentElement.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm');
|
||
document.documentElement.classList.add(`md-theme-${resolved}`);
|
||
setThemeVariables(getThemeConfig(theme));
|
||
}
|
||
notifyGlobalListeners(theme, resolved);
|
||
};
|
||
const applyThemeToElement = (theme, target) => {
|
||
if (!target || typeof document === 'undefined')
|
||
return;
|
||
const resolved = resolveTheme(theme);
|
||
target.setAttribute('data-md-theme', resolved);
|
||
target.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm');
|
||
if (resolved !== 'auto')
|
||
target.classList.add(`md-theme-${resolved}`);
|
||
setThemeVariables(getThemeConfig(theme), target);
|
||
};
|
||
const switchTheme = (theme) => { applyTheme(theme); saveTheme(theme); };
|
||
const toggleTheme = () => { const r = getResolvedTheme(); switchTheme(r === 'dark' ? 'light' : 'dark'); };
|
||
const resetToAuto = () => { switchTheme('auto'); };
|
||
const getCurrentTheme = () => globalCurrentTheme;
|
||
const getResolvedTheme = () => resolveTheme(globalCurrentTheme);
|
||
// ============ Persistence ============
|
||
const saveTheme = (theme) => {
|
||
if (typeof localStorage !== 'undefined') {
|
||
try {
|
||
localStorage.setItem('metona-editor-theme', theme);
|
||
}
|
||
catch (_) { }
|
||
}
|
||
};
|
||
const loadTheme = () => {
|
||
if (typeof localStorage !== 'undefined') {
|
||
try {
|
||
return localStorage.getItem('metona-editor-theme') || 'auto';
|
||
}
|
||
catch (_) { }
|
||
}
|
||
return 'auto';
|
||
};
|
||
// ============ Listeners ============
|
||
const notifyGlobalListeners = (theme, resolved) => {
|
||
globalThemeListeners.forEach((fn) => { try {
|
||
fn(theme, resolved);
|
||
}
|
||
catch (e) {
|
||
console.error('Theme listener error:', e);
|
||
} });
|
||
};
|
||
const addThemeListener = (fn) => {
|
||
globalThemeListeners.add(fn);
|
||
return () => { globalThemeListeners.delete(fn); };
|
||
};
|
||
const removeThemeListener = (fn) => { globalThemeListeners.delete(fn); };
|
||
const clearThemeListeners = () => { globalThemeListeners.clear(); };
|
||
// ============ System watch ============
|
||
const startSystemWatch = () => { watchSystemTheme(); };
|
||
// ============ Init ============
|
||
const initTheme = () => { applyTheme(loadTheme()); startSystemWatch(); };
|
||
// ============ External follow ============
|
||
const followExternalTheme = (options, onThemeChange) => {
|
||
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined')
|
||
return () => { };
|
||
const { element = document.documentElement, attr = 'data-theme', classMap = null, callback = null } = options;
|
||
const detect = () => {
|
||
if (typeof callback === 'function')
|
||
return callback(element);
|
||
const attrVal = element.getAttribute(attr);
|
||
if (attrVal)
|
||
return attrVal;
|
||
if (classMap) {
|
||
for (const [cls, theme] of Object.entries(classMap)) {
|
||
if (element.classList.contains(cls))
|
||
return theme;
|
||
}
|
||
}
|
||
const mdTheme = element.getAttribute('data-md-theme');
|
||
if (mdTheme)
|
||
return mdTheme;
|
||
return null;
|
||
};
|
||
let lastTheme = detect();
|
||
if (lastTheme)
|
||
onThemeChange(lastTheme);
|
||
const observer = new MutationObserver(() => { const current = detect(); if (current && current !== lastTheme) {
|
||
lastTheme = current;
|
||
onThemeChange(current);
|
||
} });
|
||
observer.observe(element, { attributes: true, attributeFilter: [attr, 'class', 'data-md-theme'] });
|
||
return () => observer.disconnect();
|
||
};
|
||
const adoptFromParent = (container, onThemeDetected) => {
|
||
if (typeof document === 'undefined' || !container)
|
||
return () => { };
|
||
const detect = () => {
|
||
let el = container.parentElement;
|
||
while (el) {
|
||
const t = el.getAttribute('data-md-theme');
|
||
if (t)
|
||
return t;
|
||
if (el.classList.contains('theme-dark') || el.classList.contains('dark'))
|
||
return 'dark';
|
||
if (el.classList.contains('theme-light') || el.classList.contains('light'))
|
||
return 'light';
|
||
el = el.parentElement;
|
||
}
|
||
if (container.parentElement) {
|
||
const bg = getComputedStyle(container.parentElement).getPropertyValue('--md-bg').trim() || getComputedStyle(container.parentElement).backgroundColor;
|
||
if (bg) {
|
||
const rgb = bg.match(/\d+/g);
|
||
if (rgb && rgb.length >= 3) {
|
||
const brightness = (parseInt(rgb[0]) * 299 + parseInt(rgb[1]) * 587 + parseInt(rgb[2]) * 114) / 1000;
|
||
return brightness < 128 ? 'dark' : 'light';
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
const detected = detect();
|
||
if (detected)
|
||
onThemeDetected(detected);
|
||
const observer = new MutationObserver(() => { const d = detect(); if (d)
|
||
onThemeDetected(d); });
|
||
let target = container.parentElement;
|
||
while (target) {
|
||
observer.observe(target, { attributes: true, attributeFilter: ['class', 'data-md-theme', 'data-theme'] });
|
||
target = target.parentElement;
|
||
if (target === document.documentElement)
|
||
break;
|
||
}
|
||
return () => observer.disconnect();
|
||
};
|
||
const watch = (source, onChange) => {
|
||
if (typeof source === 'function') {
|
||
const tick = () => { try {
|
||
const t = source();
|
||
if (t)
|
||
onChange(t);
|
||
}
|
||
catch (_) { } };
|
||
tick();
|
||
const id = setInterval(tick, 500);
|
||
return () => clearInterval(id);
|
||
}
|
||
if (typeof source === 'string' && typeof document !== 'undefined') {
|
||
const el = document.querySelector(source);
|
||
if (!el)
|
||
return () => { };
|
||
return followExternalTheme({ element: el, attr: 'data-theme' }, onChange);
|
||
}
|
||
return () => { };
|
||
};
|
||
// ============ Custom theme registration ============
|
||
const registerTheme = (name, config = {}) => {
|
||
const baseName = config.extends || 'light';
|
||
const base = (THEMES[baseName] || THEMES.light);
|
||
THEMES[name] = {
|
||
bg: config.bg || base.bg, text: config.text || base.text, border: config.border || base.border,
|
||
shadow: config.shadow || base.shadow, hoverShadow: config.hoverShadow || base.hoverShadow,
|
||
toolbarBg: config.toolbarBg || base.toolbarBg || base.bg,
|
||
textareaBg: config.textareaBg || base.textareaBg || base.bg,
|
||
previewBg: config.previewBg || base.previewBg || base.bg,
|
||
codeBg: config.codeBg || base.codeBg, codeText: config.codeText || base.codeText,
|
||
accent: config.accent || base.accent, muted: config.muted || base.muted,
|
||
progressBg: config.progressBg || base.progressBg,
|
||
closeHoverBg: config.closeHoverBg || base.closeHoverBg,
|
||
};
|
||
};
|
||
const unregisterTheme = (name) => {
|
||
if (name === 'light' || name === 'dark' || name === 'auto' || name === 'warm') {
|
||
console.warn('Cannot unregister built-in theme:', name);
|
||
return;
|
||
}
|
||
delete THEMES[name];
|
||
};
|
||
const getAllThemes = () => ({ ...THEMES });
|
||
const getThemeNames = () => Object.keys(THEMES);
|
||
const hasTheme = (name) => name in THEMES;
|
||
// ============ Instance theme ============
|
||
const createInstanceTheme = (editor) => {
|
||
let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto';
|
||
const apply = (theme) => {
|
||
instanceTheme = theme;
|
||
const resolved = resolveTheme(theme);
|
||
const config = getThemeConfig(theme);
|
||
if (editor.el) {
|
||
['me-theme-light', 'me-theme-dark', 'me-theme-warm'].forEach((c) => editor.el.classList.remove(c));
|
||
if (resolved !== 'auto')
|
||
editor.el.classList.add(`me-theme-${resolved}`);
|
||
editor.el.setAttribute('data-md-theme', resolved);
|
||
setThemeVariables(config, editor.el);
|
||
}
|
||
if (typeof editor._emit === 'function')
|
||
editor._emit('themeChange', { theme, resolved, config });
|
||
if (typeof editor.refresh === 'function')
|
||
editor.refresh();
|
||
return instanceTheme;
|
||
};
|
||
const get = () => instanceTheme;
|
||
const set = (theme) => apply(theme);
|
||
const toggle = () => { const r = resolveTheme(instanceTheme); return apply(r === 'dark' ? 'light' : 'dark'); };
|
||
const getResolved = () => resolveTheme(instanceTheme);
|
||
const getConfig = () => getThemeConfig(instanceTheme);
|
||
const getVars = () => editor.el ? exportCSSVars(editor.el) : {};
|
||
const syncWithElement = (element, opts = {}) => followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected));
|
||
const adopt = () => editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => { };
|
||
const watchExternal = (source) => watch(source, (theme) => apply(theme));
|
||
apply(instanceTheme);
|
||
return { apply, get, set, toggle, getResolved, getConfig, getVars, syncWithElement, adopt, watch: watchExternal };
|
||
};
|
||
const presetThemes = {
|
||
light: { name: '浅色', description: '明亮清晰', config: THEMES.light },
|
||
dark: { name: '深色', description: '护眼暗色', config: THEMES.dark },
|
||
auto: { name: '自动', description: '跟随系统', config: 'auto' },
|
||
warm: { name: '暖色', description: '温馨暖色', config: THEMES.warm },
|
||
};
|
||
// Ensure built-in themes in THEMES
|
||
for (const [name, theme] of Object.entries(presetThemes)) {
|
||
if (name !== 'auto' && theme.config !== 'auto')
|
||
THEMES[name] = theme.config;
|
||
}
|
||
const themeUtils = {
|
||
getSystemTheme, resolveTheme, getThemeConfig, watchSystemTheme, unwatchSystemTheme,
|
||
applyTheme, getCurrentTheme, getResolvedTheme, switchTheme, toggleTheme, resetToAuto, initTheme,
|
||
saveTheme, loadTheme, addThemeListener, removeThemeListener, clearThemeListeners,
|
||
registerTheme, unregisterTheme, getAllThemes, getThemeNames, hasTheme,
|
||
setThemeVariables, exportCSSVars, getCSSVariable, applyThemeToElement,
|
||
followExternalTheme, adoptFromParent, watch, createInstanceTheme,
|
||
};
|
||
|
||
/**
|
||
* MetonaEditor Core — MarkdownEditor class (TypeScript)
|
||
* @module core
|
||
* @version 0.2.0
|
||
*/
|
||
const MODES = ['edit', 'split', 'preview'];
|
||
const TOAST_ICONS = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' };
|
||
const BRACKET_PAIRS = {
|
||
'(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`', '*': '*', '_': '_',
|
||
};
|
||
const resolveThemeName = (theme) => {
|
||
if (theme && theme !== 'auto')
|
||
return theme;
|
||
return resolveTheme('auto');
|
||
};
|
||
class MarkdownEditor {
|
||
static on(name, fn) {
|
||
if (!this._hooks.has(name))
|
||
this._hooks.set(name, []);
|
||
this._hooks.get(name).push(fn);
|
||
return () => this.off(name, fn);
|
||
}
|
||
static off(name, fn) {
|
||
const list = this._hooks.get(name);
|
||
if (list)
|
||
this._hooks.set(name, list.filter((f) => f !== fn));
|
||
}
|
||
static trigger(name, instance) {
|
||
const list = this._hooks.get(name);
|
||
if (list)
|
||
list.forEach((fn) => { try {
|
||
fn(instance);
|
||
}
|
||
catch (e) {
|
||
console.error(`MeEditor hook "${name}" error:`, e);
|
||
} });
|
||
}
|
||
constructor(container, options = {}) {
|
||
if (!isBrowser()) {
|
||
this._destroyed = true;
|
||
this._value = options.value || '';
|
||
return this;
|
||
}
|
||
this.id = options.id || generateId();
|
||
this.container = typeof container === 'string' ? document.querySelector(container) : container;
|
||
if (!this.container) {
|
||
console.error('MeEditor: container not found:', container);
|
||
this._destroyed = true;
|
||
return this;
|
||
}
|
||
this.config = { ...DEFAULTS, ...options };
|
||
if (options.style && typeof options.style === 'object') {
|
||
this.config.style = { ...DEFAULTS.style, ...options.style };
|
||
}
|
||
this._value = this.config.value || '';
|
||
this._mode = MODES.includes(this.config.mode) ? this.config.mode : 'split';
|
||
this._history = [];
|
||
this._historyIndex = -1;
|
||
this._cleanups = [];
|
||
this._plugins = [];
|
||
this._listeners = {};
|
||
this._renderRaf = null;
|
||
this._historyTimer = null;
|
||
this._fullscreen = false;
|
||
this._destroyed = false;
|
||
this._lastRenderedValue = null;
|
||
this._shortcuts = [];
|
||
this._contextMenuItems = [];
|
||
this._customActions = {};
|
||
this._outlineTimer = null;
|
||
this._zenMode = false;
|
||
this._wordWrap = true;
|
||
this._syncing = false;
|
||
this._zenMouseHandler = null;
|
||
this._floatingToolbar = null;
|
||
this._floatingEnabled = this.config.floatingToolbar !== false;
|
||
this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown;
|
||
this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null;
|
||
MarkdownEditor.trigger('beforeCreate', this);
|
||
injectStyles();
|
||
this._buildDOM();
|
||
this._themeCtx = createInstanceTheme(this);
|
||
this._i18nCtx = createInstanceI18n(this);
|
||
if (options.theme && typeof document !== 'undefined') {
|
||
document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme));
|
||
}
|
||
const resolvedThemeConfig = getThemeConfig(this.config.theme);
|
||
setThemeVariables(resolvedThemeConfig, this.el);
|
||
this._buildToolbar();
|
||
this._updateModeButtons();
|
||
this._bindToolbarKeyboard();
|
||
this._initAriaLive();
|
||
this._bindEvents();
|
||
this._bindContextMenu();
|
||
this._trackOutlineScroll();
|
||
if (this.config.floatingToolbar !== false)
|
||
this._initFloatingToolbar();
|
||
this.textarea.value = this._value;
|
||
this._pushHistory();
|
||
this._render();
|
||
this._updateWordCount();
|
||
if (Array.isArray(this.config.plugins)) {
|
||
const plugins = this.config.plugins.map((p) => {
|
||
if (typeof p === 'string')
|
||
return { ...presetPlugins[p], _isStringRef: true };
|
||
return p;
|
||
}).filter(Boolean);
|
||
const sorted = topologicalSort(plugins);
|
||
sorted.forEach((p) => this.use(p));
|
||
}
|
||
if (this.config.autofocus && !this.config.readOnly)
|
||
this.focus();
|
||
if (typeof this.config.onCreate === 'function') {
|
||
try {
|
||
this.config.onCreate(this);
|
||
}
|
||
catch (e) {
|
||
console.error('onCreate error:', e);
|
||
}
|
||
}
|
||
MarkdownEditor.trigger('afterCreate', this);
|
||
}
|
||
_buildDOM() {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = `me-wrapper me-theme-${resolveThemeName(this.config.theme)}`;
|
||
if (this.config.readOnly)
|
||
wrapper.classList.add('me-readonly');
|
||
wrapper.dataset.id = this.id;
|
||
wrapper.setAttribute('role', 'application');
|
||
wrapper.setAttribute('aria-label', t('edit'));
|
||
if (this.config.className) {
|
||
this.config.className.split(/\s+/).filter(Boolean).forEach((c) => wrapper.classList.add(c));
|
||
}
|
||
const h = this.config.height;
|
||
wrapper.style.height = typeof h === 'number' ? `${h}px` : (h || '400px');
|
||
if (this.config.style && typeof this.config.style === 'object') {
|
||
Object.entries(this.config.style).forEach(([k, v]) => { if (v)
|
||
wrapper.style[k] = v; });
|
||
}
|
||
const toolbar = document.createElement('div');
|
||
toolbar.className = 'me-toolbar';
|
||
toolbar.setAttribute('role', 'toolbar');
|
||
const body = document.createElement('div');
|
||
body.className = `me-body me-mode-${this._mode}`;
|
||
const editorPane = document.createElement('div');
|
||
editorPane.className = 'me-editor-pane';
|
||
const editorInner = document.createElement('div');
|
||
editorInner.className = 'me-editor-inner';
|
||
const gutter = document.createElement('div');
|
||
gutter.className = 'me-gutter';
|
||
if (!this.config.lineNumbers)
|
||
gutter.style.display = 'none';
|
||
editorInner.appendChild(gutter);
|
||
const textarea = document.createElement('textarea');
|
||
textarea.className = 'me-textarea';
|
||
textarea.spellcheck = !!this.config.spellcheck;
|
||
textarea.readOnly = !!this.config.readOnly;
|
||
textarea.style.tabSize = String(this.config.tabSize || 2);
|
||
textarea.placeholder = this.config.placeholder || t('placeholder');
|
||
textarea.setAttribute('aria-label', t('edit'));
|
||
editorInner.appendChild(textarea);
|
||
editorPane.appendChild(editorInner);
|
||
const divider = document.createElement('div');
|
||
divider.className = 'me-divider';
|
||
divider.setAttribute('role', 'separator');
|
||
const previewPane = document.createElement('div');
|
||
previewPane.className = 'me-preview-pane';
|
||
previewPane.setAttribute('role', 'region');
|
||
previewPane.setAttribute('aria-label', t('preview'));
|
||
const preview = document.createElement('div');
|
||
preview.className = 'me-preview';
|
||
previewPane.appendChild(preview);
|
||
body.appendChild(editorPane);
|
||
body.appendChild(divider);
|
||
body.appendChild(previewPane);
|
||
wrapper.appendChild(toolbar);
|
||
wrapper.appendChild(body);
|
||
let statusbar = null;
|
||
if (this.config.wordCount) {
|
||
statusbar = document.createElement('div');
|
||
statusbar.className = 'me-statusbar';
|
||
wrapper.appendChild(statusbar);
|
||
}
|
||
this.container.appendChild(wrapper);
|
||
this.el = wrapper;
|
||
this.toolbarEl = toolbar;
|
||
this.bodyEl = body;
|
||
this.editorPane = editorPane;
|
||
this.editorInner = editorInner;
|
||
this.gutter = gutter;
|
||
this.previewPane = previewPane;
|
||
this.dividerEl = divider;
|
||
this.textarea = textarea;
|
||
this.previewEl = preview;
|
||
this.statusEl = statusbar;
|
||
// Restore divider position if saved
|
||
this._restoreDividerPosition();
|
||
}
|
||
_buildToolbar() {
|
||
const tools = this.config.toolbar;
|
||
if (!tools || !Array.isArray(tools) || tools.length === 0)
|
||
return;
|
||
const modeActions = [];
|
||
tools.forEach((item) => {
|
||
if (item === '|') {
|
||
const sep = document.createElement('span');
|
||
sep.className = 'me-toolbar-sep';
|
||
this.toolbarEl.appendChild(sep);
|
||
return;
|
||
}
|
||
if (item === 'edit' || item === 'split' || item === 'preview' || item === 'fullscreen') {
|
||
modeActions.push(item);
|
||
return;
|
||
}
|
||
this.toolbarEl.appendChild(this._createBtn(item));
|
||
});
|
||
if (modeActions.length) {
|
||
const group = document.createElement('div');
|
||
group.className = 'me-toolbar-group';
|
||
modeActions.forEach((item) => {
|
||
const btn = this._createBtn(item);
|
||
if (item === 'edit' || item === 'split' || item === 'preview') {
|
||
btn.dataset.mode = item;
|
||
if (item === this._mode)
|
||
btn.classList.add('me-active');
|
||
}
|
||
group.appendChild(btn);
|
||
});
|
||
this.toolbarEl.appendChild(group);
|
||
}
|
||
}
|
||
_createBtn(item) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = `me-btn me-btn-${item}`;
|
||
btn.dataset.action = item;
|
||
const label = t(item) || item;
|
||
btn.title = label;
|
||
btn.setAttribute('aria-label', label);
|
||
btn.innerHTML = ICONS[item] || `<span>${escapeHTML(item)}</span>`;
|
||
return btn;
|
||
}
|
||
_bindEvents() {
|
||
const ta = this.textarea;
|
||
const onInput = () => {
|
||
const oldValue = this._value;
|
||
this._value = ta.value;
|
||
MarkdownEditor.trigger('beforeChange', this);
|
||
this._emit('beforeChange', oldValue, this._value);
|
||
this._scheduleRender();
|
||
this._scheduleHistory();
|
||
this._updateWordCount();
|
||
this._renderGutter();
|
||
this._updateOutline();
|
||
this._emit('input', this._value);
|
||
this._emit('change', this._value);
|
||
if (typeof this.config.onInput === 'function') {
|
||
try {
|
||
this.config.onInput(this._value, this);
|
||
}
|
||
catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
if (typeof this.config.onChange === 'function') {
|
||
try {
|
||
this.config.onChange(this._value, this);
|
||
}
|
||
catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
this._emit('afterChange', this._value);
|
||
MarkdownEditor.trigger('afterChange', this);
|
||
};
|
||
ta.addEventListener('input', onInput);
|
||
const onKeydown = (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey)
|
||
this._handleSmartEnter(e);
|
||
this._handleKeydown(e);
|
||
};
|
||
ta.addEventListener('keydown', onKeydown);
|
||
const onKeypress = (e) => this._handleBracketAutoClose(e);
|
||
ta.addEventListener('keypress', onKeypress);
|
||
const onCursorActivity = () => { this._updateCurrentLine(); };
|
||
ta.addEventListener('click', onCursorActivity);
|
||
ta.addEventListener('keyup', onCursorActivity);
|
||
const onScroll = () => { this._syncScroll(); };
|
||
ta.addEventListener('scroll', onScroll);
|
||
const onPreviewScroll = () => {
|
||
if (!this.config.syncScroll || this._mode !== 'split')
|
||
return;
|
||
if (this._syncing)
|
||
return;
|
||
this._syncing = true;
|
||
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
|
||
if (pmax <= 0) {
|
||
this._syncing = false;
|
||
return;
|
||
}
|
||
const ratio = this.previewPane.scrollTop / pmax;
|
||
const tmax = ta.scrollHeight - ta.clientHeight;
|
||
ta.scrollTop = ratio * tmax;
|
||
if (this.gutter) {
|
||
const gmax = this.gutter.scrollHeight - this.gutter.clientHeight;
|
||
this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ratio * tmax;
|
||
}
|
||
requestAnimationFrame(() => { this._syncing = false; });
|
||
};
|
||
this.previewPane.addEventListener('scroll', onPreviewScroll);
|
||
const onFocus = () => {
|
||
this._emit('focus');
|
||
if (typeof this.config.onFocus === 'function') {
|
||
try {
|
||
this.config.onFocus(this);
|
||
}
|
||
catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
};
|
||
const onBlur = () => {
|
||
this._emit('blur');
|
||
if (typeof this.config.onBlur === 'function') {
|
||
try {
|
||
this.config.onBlur(this);
|
||
}
|
||
catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
};
|
||
ta.addEventListener('focus', onFocus);
|
||
ta.addEventListener('blur', onBlur);
|
||
const onPreviewClick = (e) => {
|
||
const a = e.target.closest('a');
|
||
if (!a)
|
||
return;
|
||
const href = a.getAttribute('href');
|
||
if (!href || href.startsWith('#'))
|
||
return;
|
||
e.preventDefault();
|
||
this._emit('linkClick', { href, text: a.textContent });
|
||
if (typeof this.config.onLinkClick === 'function') {
|
||
try {
|
||
this.config.onLinkClick(href, a.textContent, this);
|
||
}
|
||
catch (_) { }
|
||
return;
|
||
}
|
||
window.open(href, '_blank', 'noopener');
|
||
};
|
||
this.previewEl.addEventListener('click', onPreviewClick);
|
||
const onToolbarClick = (e) => {
|
||
const btn = e.target.closest('.me-btn');
|
||
if (!btn)
|
||
return;
|
||
const mode = btn.dataset.mode;
|
||
if (mode) {
|
||
this.setMode(mode);
|
||
return;
|
||
}
|
||
const action = btn.dataset.action;
|
||
if (action)
|
||
this.exec(action);
|
||
};
|
||
this.toolbarEl.addEventListener('click', onToolbarClick);
|
||
const onDividerDown = (e) => this._bindDividerDrag(e);
|
||
this.dividerEl.addEventListener('pointerdown', onDividerDown);
|
||
this._bindDragDrop();
|
||
this._cleanups.push(() => {
|
||
ta.removeEventListener('input', onInput);
|
||
ta.removeEventListener('keydown', onKeydown);
|
||
ta.removeEventListener('keypress', onKeypress);
|
||
ta.removeEventListener('scroll', onScroll);
|
||
this.previewPane.removeEventListener('scroll', onPreviewScroll);
|
||
ta.removeEventListener('click', onCursorActivity);
|
||
ta.removeEventListener('keyup', onCursorActivity);
|
||
ta.removeEventListener('focus', onFocus);
|
||
ta.removeEventListener('blur', onBlur);
|
||
this.toolbarEl.removeEventListener('click', onToolbarClick);
|
||
this.dividerEl.removeEventListener('pointerdown', onDividerDown);
|
||
});
|
||
}
|
||
_handleKeydown(e) {
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
this._handleTab(e.shiftKey);
|
||
return;
|
||
}
|
||
if (this._shortcuts.length) {
|
||
for (const sc of this._shortcuts) {
|
||
const ctrlOk = sc.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
|
||
const shiftOk = sc.shift ? e.shiftKey : !e.shiftKey;
|
||
const altOk = sc.alt ? e.altKey : !e.altKey;
|
||
if (e.key.toLowerCase() === sc.key && ctrlOk && shiftOk && altOk) {
|
||
e.preventDefault();
|
||
if (typeof sc.handler === 'string')
|
||
this.exec(sc.handler);
|
||
else if (typeof sc.handler === 'function') {
|
||
try {
|
||
sc.handler(this);
|
||
}
|
||
catch (err) {
|
||
console.error('Shortcut error:', err);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
const mod = e.ctrlKey || e.metaKey;
|
||
if (!mod)
|
||
return;
|
||
const key = e.key.toLowerCase();
|
||
const shortcuts = { b: 'bold', i: 'italic', k: 'link', u: 'underline', e: 'code', '1': 'h1', '2': 'h2', '3': 'h3', q: 'quote' };
|
||
if (key === 'z' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
this.undo();
|
||
}
|
||
else if ((key === 'z' && e.shiftKey) || key === 'y') {
|
||
e.preventDefault();
|
||
this.redo();
|
||
}
|
||
else if (key === 's') {
|
||
e.preventDefault();
|
||
this._emit('save', this._value);
|
||
if (typeof this.config.onSave === 'function') {
|
||
try {
|
||
this.config.onSave(this._value, this);
|
||
}
|
||
catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
}
|
||
else if (shortcuts[key]) {
|
||
e.preventDefault();
|
||
this.exec(shortcuts[key]);
|
||
}
|
||
}
|
||
_handleTab(shift) {
|
||
const ta = this.textarea;
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
const tabSize = this.config.tabSize || 0;
|
||
const tabStr = tabSize > 0 ? ' '.repeat(tabSize) : '\t';
|
||
if (start === end) {
|
||
ta.value = ta.value.slice(0, start) + tabStr + ta.value.slice(end);
|
||
ta.selectionStart = ta.selectionEnd = start + tabStr.length;
|
||
}
|
||
else {
|
||
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
|
||
const block = ta.value.slice(lineStart, end);
|
||
const lines = block.split('\n');
|
||
let newBlock;
|
||
if (shift) {
|
||
newBlock = lines.map((l) => l.replace(/^(\t| {1,4})/, '')).join('\n');
|
||
}
|
||
else {
|
||
newBlock = lines.map((l) => tabStr + l).join('\n');
|
||
}
|
||
ta.value = ta.value.slice(0, lineStart) + newBlock + ta.value.slice(end);
|
||
ta.selectionStart = lineStart;
|
||
ta.selectionEnd = lineStart + newBlock.length;
|
||
}
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._render();
|
||
this._emit('change', this._value);
|
||
}
|
||
_bindDividerDrag(e) {
|
||
if (this._mode !== 'split')
|
||
return;
|
||
e.preventDefault();
|
||
const startX = e.clientX;
|
||
const editorWidth = this.editorPane.getBoundingClientRect().width;
|
||
const totalWidth = this.bodyEl.getBoundingClientRect().width;
|
||
if (totalWidth <= 0)
|
||
return;
|
||
const divider = this.dividerEl;
|
||
const onMove = (ev) => {
|
||
const dx = ev.clientX - startX;
|
||
let newW = editorWidth + dx;
|
||
const min = 80;
|
||
const max = totalWidth - 80 - divider.offsetWidth;
|
||
newW = Math.max(min, Math.min(max, newW));
|
||
const pct = (newW / totalWidth) * 100;
|
||
this.editorPane.style.flex = `0 0 ${pct}%`;
|
||
this.previewPane.style.flex = `1 1 ${100 - pct}%`;
|
||
};
|
||
const onUp = () => {
|
||
window.removeEventListener('pointermove', onMove);
|
||
window.removeEventListener('pointerup', onUp);
|
||
this._saveDividerPosition();
|
||
};
|
||
window.addEventListener('pointermove', onMove);
|
||
window.addEventListener('pointerup', onUp);
|
||
}
|
||
_saveDividerPosition() {
|
||
try {
|
||
const epFlex = this.editorPane.style.flex;
|
||
if (epFlex)
|
||
localStorage.setItem('metona-editor-divider', epFlex);
|
||
}
|
||
catch (_) { }
|
||
}
|
||
_restoreDividerPosition() {
|
||
try {
|
||
const saved = localStorage.getItem('metona-editor-divider');
|
||
if (saved && this._mode === 'split') {
|
||
this.editorPane.style.flex = saved;
|
||
this.previewPane.style.flex = '1 1 auto';
|
||
}
|
||
}
|
||
catch (_) { }
|
||
}
|
||
_scheduleRender() { if (this._renderRaf)
|
||
return; this._renderRaf = requestAnimationFrame(() => { this._renderRaf = null; this._render(); }); }
|
||
_render() {
|
||
if (this._mode === 'edit')
|
||
return;
|
||
if (this._lastRenderedValue === this._value)
|
||
return;
|
||
this._lastRenderedValue = this._value;
|
||
MarkdownEditor.trigger('beforeRender', this);
|
||
this._emit('beforeRender', this);
|
||
let html;
|
||
try {
|
||
html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() });
|
||
}
|
||
catch (err) {
|
||
console.error('MeEditor render error:', err);
|
||
html = `<p style="color:#ef4444">${t('renderError')}: ${escapeHTML(err.message)}</p>`;
|
||
}
|
||
if (typeof this.config.sanitize === 'function') {
|
||
try {
|
||
html = this.config.sanitize(html);
|
||
}
|
||
catch (e) {
|
||
console.error('sanitize error:', e);
|
||
}
|
||
}
|
||
this.previewEl.innerHTML = html;
|
||
this._renderGutter();
|
||
MarkdownEditor.trigger('afterRender', this);
|
||
this._emit('afterRender', this);
|
||
}
|
||
_renderGutter() {
|
||
if (!this.config.lineNumbers || !this.gutter)
|
||
return;
|
||
const lines = this._value ? this._value.split('\n').length : 1;
|
||
const current = this.gutter.children.length;
|
||
if (current === lines)
|
||
return;
|
||
if (current < lines) {
|
||
// Add new line numbers
|
||
let h = '';
|
||
for (let i = current + 1; i <= lines; i++)
|
||
h += `<div class="me-gutter-line">${i}</div>`;
|
||
this.gutter.insertAdjacentHTML('beforeend', h);
|
||
}
|
||
else {
|
||
// Remove excess line numbers
|
||
while (this.gutter.children.length > lines) {
|
||
this.gutter.removeChild(this.gutter.lastChild);
|
||
}
|
||
}
|
||
}
|
||
_updateCurrentLine() {
|
||
if (!this.gutter)
|
||
return;
|
||
const pos = this.textarea.selectionStart;
|
||
const lineNum = this._value.substring(0, pos).split('\n').length;
|
||
const prev = this.gutter.querySelector('.me-gutter-active');
|
||
if (prev)
|
||
prev.classList.remove('me-gutter-active');
|
||
const cur = this.gutter.children[lineNum - 1];
|
||
if (cur)
|
||
cur.classList.add('me-gutter-active');
|
||
}
|
||
_handleSmartEnter(e) {
|
||
const ta = this.textarea;
|
||
const start = ta.selectionStart;
|
||
const val = ta.value;
|
||
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
|
||
const line = val.slice(lineStart, start);
|
||
const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s(.*)/);
|
||
if (listMatch) {
|
||
const indent = listMatch[1];
|
||
const marker = listMatch[2];
|
||
const content = listMatch[3];
|
||
if (!content.trim()) {
|
||
e.preventDefault();
|
||
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
|
||
ta.selectionStart = ta.selectionEnd = lineStart;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._renderGutter();
|
||
this._emit('change', this._value);
|
||
return;
|
||
}
|
||
let nextMarker = marker;
|
||
if (/^\d+\.$/.test(marker)) {
|
||
const num = parseInt(marker, 10);
|
||
if (!isNaN(num))
|
||
nextMarker = (num + 1) + '.';
|
||
}
|
||
e.preventDefault();
|
||
const insert = '\n' + indent + nextMarker + ' ';
|
||
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
|
||
ta.selectionStart = ta.selectionEnd = start + insert.length;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._renderGutter();
|
||
this._emit('change', this._value);
|
||
return;
|
||
}
|
||
const quoteMatch = line.match(/^(\s*>+\s?)(.*)/);
|
||
if (quoteMatch) {
|
||
const prefix = quoteMatch[1];
|
||
const content = quoteMatch[2];
|
||
if (!content.trim()) {
|
||
e.preventDefault();
|
||
ta.value = val.slice(0, lineStart) + '\n' + val.slice(start);
|
||
ta.selectionStart = ta.selectionEnd = lineStart;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._renderGutter();
|
||
this._emit('change', this._value);
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
const insert = '\n' + prefix;
|
||
ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd);
|
||
ta.selectionStart = ta.selectionEnd = start + insert.length;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._renderGutter();
|
||
this._emit('change', this._value);
|
||
}
|
||
}
|
||
_handleBracketAutoClose(e) {
|
||
if (!this.config.autoBrackets)
|
||
return;
|
||
const ta = this.textarea;
|
||
const key = e.key;
|
||
const close = BRACKET_PAIRS[key];
|
||
if (!close)
|
||
return;
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
if (start !== end) {
|
||
e.preventDefault();
|
||
const selected = ta.value.slice(start, end);
|
||
const wrap = key + selected + close;
|
||
ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end);
|
||
ta.selectionStart = start + 1;
|
||
ta.selectionEnd = start + 1 + selected.length;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._emit('change', this._value);
|
||
return;
|
||
}
|
||
const nextChar = ta.value[start] || '';
|
||
if (key === close) {
|
||
if (nextChar === close) {
|
||
e.preventDefault();
|
||
ta.selectionStart = ta.selectionEnd = start + 1;
|
||
return;
|
||
}
|
||
if (/\w/.test(nextChar))
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
const pair = key + close;
|
||
ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end);
|
||
ta.selectionStart = ta.selectionEnd = start + 1;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._emit('change', this._value);
|
||
}
|
||
_bindDragDrop() {
|
||
const ta = this.textarea;
|
||
if (!ta)
|
||
return;
|
||
const onDragOver = (e) => { e.preventDefault(); if (e.dataTransfer)
|
||
e.dataTransfer.dropEffect = 'copy'; };
|
||
const onDrop = (e) => {
|
||
e.preventDefault();
|
||
const files = e.dataTransfer?.files;
|
||
if (files && files.length) {
|
||
Array.from(files).forEach((file) => {
|
||
if (file.type.startsWith('image/')) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => { this.insert(`\n`); };
|
||
reader.readAsDataURL(file);
|
||
}
|
||
else if (file.type.startsWith('text/') || /\.(md|txt|js|ts|json|css|html|xml|yml|yaml)$/i.test(file.name)) {
|
||
const reader = new FileReader();
|
||
reader.onload = () => this.insert(reader.result);
|
||
reader.readAsText(file);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
const text = e.dataTransfer?.getData('text/plain');
|
||
if (text)
|
||
this.insert(text);
|
||
};
|
||
ta.addEventListener('dragover', onDragOver);
|
||
ta.addEventListener('drop', onDrop);
|
||
this._cleanups.push(() => { ta.removeEventListener('dragover', onDragOver); ta.removeEventListener('drop', onDrop); });
|
||
}
|
||
_buildOutline() {
|
||
if (!this.config.outline || !this.previewEl)
|
||
return;
|
||
const old = this.el.querySelector('.me-outline');
|
||
if (old)
|
||
old.remove();
|
||
const headings = [];
|
||
const headingRe = /<h([1-6])\s+id="([^"]+)"[^>]*>(.+?)<\/h\1>/gi;
|
||
let m;
|
||
while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) {
|
||
headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') });
|
||
}
|
||
if (!headings.length)
|
||
return;
|
||
const panel = document.createElement('div');
|
||
panel.className = 'me-outline';
|
||
panel.innerHTML = `<div class="me-outline-title">${t('outline') || '大纲'}</div>`;
|
||
const buildTree = (items, minLevel) => {
|
||
let h = '<ul>';
|
||
let i = 0;
|
||
while (i < items.length) {
|
||
const item = items[i];
|
||
if (item.level < minLevel)
|
||
break;
|
||
h += `<li class="me-outline-l${item.level}"><a href="#${item.id}" data-line="${item.id}">${escapeHTML(item.text)}</a>`;
|
||
const sub = [];
|
||
let j = i + 1;
|
||
while (j < items.length && items[j].level > item.level) {
|
||
sub.push(items[j]);
|
||
j++;
|
||
}
|
||
if (sub.length)
|
||
h += buildTree(sub, item.level + 1);
|
||
h += '</li>';
|
||
i = j > i + 1 ? j : i + 1;
|
||
}
|
||
return h + '</ul>';
|
||
};
|
||
panel.innerHTML += buildTree(headings, 1);
|
||
this.el.appendChild(panel);
|
||
panel.addEventListener('click', (e) => {
|
||
const a = e.target.closest('a');
|
||
if (!a)
|
||
return;
|
||
e.preventDefault();
|
||
const id = a.getAttribute('href').slice(1);
|
||
const target = this.previewEl.querySelector('#' + CSS.escape(id));
|
||
if (target) {
|
||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
const idx = this._value.indexOf(target.textContent || '');
|
||
if (idx !== -1) {
|
||
this.textarea.focus();
|
||
this.textarea.setSelectionRange(idx, idx);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
_updateOutline() { if (!this.config.outline)
|
||
return; if (this._outlineTimer)
|
||
clearTimeout(this._outlineTimer); this._outlineTimer = setTimeout(() => this._buildOutline(), 300); }
|
||
_trackOutlineScroll() {
|
||
if (!this.config.outline || !this.previewPane)
|
||
return;
|
||
let ticking = false;
|
||
const onScroll = () => {
|
||
if (ticking)
|
||
return;
|
||
ticking = true;
|
||
requestAnimationFrame(() => {
|
||
ticking = false;
|
||
if (!this.el)
|
||
return;
|
||
const panel = this.el.querySelector('.me-outline');
|
||
if (!panel)
|
||
return;
|
||
const headings = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||
let activeId = '';
|
||
const scrollTop = this.previewPane.scrollTop + 80; // offset for better UX
|
||
headings.forEach((h) => {
|
||
if (h.offsetTop <= scrollTop) {
|
||
activeId = h.id;
|
||
}
|
||
});
|
||
panel.querySelectorAll('a').forEach((a) => {
|
||
a.classList.toggle('me-outline-active', a.getAttribute('href') === '#' + activeId);
|
||
});
|
||
});
|
||
};
|
||
this.previewPane.addEventListener('scroll', onScroll, { passive: true });
|
||
this._cleanups.push(() => this.previewPane.removeEventListener('scroll', onScroll));
|
||
}
|
||
_scheduleHistory() { if (this._historyTimer)
|
||
clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
|
||
_pushHistory() {
|
||
const cur = this._history[this._historyIndex];
|
||
if (cur === this._value)
|
||
return;
|
||
this._history = this._history.slice(0, this._historyIndex + 1);
|
||
this._history.push(this._value);
|
||
const limit = this.config.historyLimit > 0 ? this.config.historyLimit : 100;
|
||
while (this._history.length > limit)
|
||
this._history.shift();
|
||
this._historyIndex = this._history.length - 1;
|
||
}
|
||
undo() { if (this._historyIndex <= 0)
|
||
return this; this._historyIndex--; this._applyHistory(); return this; }
|
||
redo() { if (this._historyIndex >= this._history.length - 1)
|
||
return this; this._historyIndex++; this._applyHistory(); return this; }
|
||
canUndo() { return this._historyIndex > 0; }
|
||
canRedo() { return this._historyIndex < this._history.length - 1; }
|
||
_applyHistory() {
|
||
this._value = this._history[this._historyIndex];
|
||
this.textarea.value = this._value;
|
||
this._render();
|
||
this._renderGutter();
|
||
this._updateWordCount();
|
||
if (this.gutter)
|
||
this.gutter.scrollTop = this.textarea.scrollTop;
|
||
if (this.previewPane && this.config.syncScroll)
|
||
this.previewPane.scrollTop = 0;
|
||
this._emit('change', this._value);
|
||
if (typeof this.config.onChange === 'function') {
|
||
try {
|
||
this.config.onChange(this._value, this);
|
||
}
|
||
catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
}
|
||
exec(action, ...args) {
|
||
const actions = {
|
||
bold: () => this._wrapSelection('**', '**'), italic: () => this._wrapSelection('*', '*'),
|
||
strikethrough: () => this._wrapSelection('~~', '~~'), underline: () => this._wrapSelection('<u>', '</u>'),
|
||
code: () => this._wrapSelection('`', '`'), h1: () => this._toggleLinePrefix('# '),
|
||
h2: () => this._toggleLinePrefix('## '), h3: () => this._toggleLinePrefix('### '),
|
||
quote: () => this._toggleLinePrefix('> '), ul: () => this._toggleLinePrefix('- '),
|
||
ol: () => this._toggleLinePrefix('1. '), indent: () => this._handleTab(false),
|
||
outdent: () => this._handleTab(true), hr: () => this._insertBlock('\n---\n'),
|
||
link: () => this._insertLink(), image: () => this._insertImage(), table: () => this._insertTable(),
|
||
undo: () => this.undo(), redo: () => this.redo(),
|
||
edit: () => this.setMode('edit'), split: () => this.setMode('split'),
|
||
preview: () => this.setMode('preview'), fullscreen: () => this.toggleFullscreen(),
|
||
zen: () => this.toggleZen(), wordwrap: () => this.toggleWordWrap(),
|
||
formatTable: () => this._formatTable(),
|
||
};
|
||
const fn = actions[action];
|
||
if (fn) {
|
||
fn.apply(this, args);
|
||
return this;
|
||
}
|
||
if (this._customActions && typeof this._customActions[action] === 'function') {
|
||
try {
|
||
this._customActions[action].apply(this, args);
|
||
}
|
||
catch (e) {
|
||
console.error('custom action error:', e);
|
||
}
|
||
}
|
||
return this;
|
||
}
|
||
_wrapSelection(before, after) {
|
||
const ta = this.textarea;
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
const selected = ta.value.slice(start, end);
|
||
const text = selected || 'text';
|
||
const inserted = before + text + after;
|
||
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end);
|
||
ta.focus();
|
||
if (selected) {
|
||
ta.selectionStart = start + before.length;
|
||
ta.selectionEnd = start + before.length + text.length;
|
||
}
|
||
else {
|
||
ta.selectionStart = ta.selectionEnd = start + before.length;
|
||
}
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._render();
|
||
this._updateWordCount();
|
||
this._emit('change', this._value);
|
||
}
|
||
_toggleLinePrefix(prefix) {
|
||
const ta = this.textarea;
|
||
const start = ta.selectionStart;
|
||
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
|
||
const lineEndPos = ta.value.indexOf('\n', start);
|
||
const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos;
|
||
const line = ta.value.slice(lineStart, lineEnd);
|
||
const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/);
|
||
let newLine;
|
||
if (existingMatch && existingMatch[0] === prefix)
|
||
newLine = line.slice(prefix.length);
|
||
else if (existingMatch)
|
||
newLine = prefix + line.slice(existingMatch[0].length);
|
||
else
|
||
newLine = prefix + line;
|
||
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
|
||
ta.focus();
|
||
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._render();
|
||
this._updateWordCount();
|
||
this._emit('change', this._value);
|
||
}
|
||
_insertBlock(text) {
|
||
const ta = this.textarea;
|
||
const start = ta.selectionStart;
|
||
const before = ta.value.slice(0, start);
|
||
const needNL = before && !before.endsWith('\n');
|
||
const insert = (needNL ? '\n' : '') + text;
|
||
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd);
|
||
ta.focus();
|
||
const pos = start + insert.length;
|
||
ta.selectionStart = ta.selectionEnd = pos;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._render();
|
||
this._updateWordCount();
|
||
this._emit('change', this._value);
|
||
}
|
||
_insertLink() { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || t('link') || 'link'; const url = 'https://'; const insert = `[${sel}](${url})`; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); }
|
||
_insertImage() { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || t('image') || 'image'; const url = 'https://'; const insert = ``; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); }
|
||
_insertTable(rows = 3, cols = 3) { const header = Array.from({ length: cols }, (_, i) => `${t('tableCols') || '列'}${i + 1}`).join(' | '); const sep = Array.from({ length: cols }, () => '---').join(' | '); let md = `| ${header} |\n| ${sep} |\n`; for (let r = 1; r < rows; r++)
|
||
md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`; this._insertBlock('\n' + md); }
|
||
_formatTable() {
|
||
const ta = this.textarea;
|
||
const start = ta.selectionStart;
|
||
// Find table boundaries around cursor
|
||
const before = ta.value.substring(0, start);
|
||
const after = ta.value.substring(start);
|
||
const blockStart = before.lastIndexOf('\n\n');
|
||
const blockEnd = after.indexOf('\n\n');
|
||
const tableStart = blockStart === -1 ? 0 : blockStart + 2;
|
||
const tableEnd = blockEnd === -1 ? ta.value.length : start + blockEnd;
|
||
const tableText = ta.value.substring(tableStart, tableEnd);
|
||
const lines = tableText.split('\n').filter((l) => l.includes('|'));
|
||
if (lines.length < 2)
|
||
return; // need at least header + separator
|
||
// Parse columns
|
||
const splitRow = (r) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
|
||
const allCells = lines.map(splitRow);
|
||
const colCount = Math.max(...allCells.map((c) => c.length));
|
||
// Calculate max width per column
|
||
const colWidths = Array(colCount).fill(3);
|
||
allCells.forEach((cells) => {
|
||
cells.forEach((cell, ci) => {
|
||
colWidths[ci] = Math.max(colWidths[ci], cell.trim().length);
|
||
});
|
||
});
|
||
// Rebuild table
|
||
const pad = (s, w) => { const padLen = w - s.length; return s + ' '.repeat(Math.max(0, padLen)); };
|
||
const formatted = allCells.map((cells) => {
|
||
const padded = [];
|
||
for (let ci = 0; ci < colCount; ci++) {
|
||
padded.push(pad((cells[ci] || '').trim(), colWidths[ci]));
|
||
}
|
||
return '| ' + padded.join(' | ') + ' |';
|
||
});
|
||
// Replace in value
|
||
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._render();
|
||
this._emit('change', this._value);
|
||
}
|
||
setMode(mode) { if (!MODES.includes(mode) || mode === this._mode)
|
||
return this; const taS = this.textarea.scrollTop; const pvS = this.previewPane.scrollTop; this._mode = mode; this.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit')
|
||
this._render(); this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${t(mode) || mode} mode`); if (typeof this.config.onModeChange === 'function') {
|
||
try {
|
||
this.config.onModeChange(mode, this);
|
||
}
|
||
catch (e) {
|
||
console.error(e);
|
||
}
|
||
} return this; }
|
||
getMode() { return this._mode; }
|
||
_updateModeButtons() {
|
||
const isPreview = this._mode === 'preview';
|
||
const isReadonly = this.config.readOnly;
|
||
this.toolbarEl.querySelectorAll('.me-btn').forEach((b) => {
|
||
const btn = b;
|
||
const mode = btn.dataset.mode;
|
||
const action = btn.dataset.action;
|
||
if (mode) {
|
||
btn.classList.toggle('me-active', mode === this._mode);
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
if (action === 'fullscreen') {
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
if (isReadonly) {
|
||
btn.disabled = !(action === 'undo' || action === 'redo');
|
||
return;
|
||
}
|
||
if (isPreview) {
|
||
btn.disabled = !(action === 'undo' || action === 'redo');
|
||
return;
|
||
}
|
||
btn.disabled = false;
|
||
});
|
||
}
|
||
toggleFullscreen() { this._fullscreen = !this._fullscreen; this.el.classList.toggle('me-fullscreen', this._fullscreen); const fsBtn = this.toolbarEl.querySelector('.me-btn-fullscreen'); if (fsBtn)
|
||
fsBtn.title = this._fullscreen ? (t('fullscreenExit') || '退出全屏') : (t('fullscreen') || '全屏'); this._emit('fullscreen', this._fullscreen); if (typeof this.config.onFullscreen === 'function') {
|
||
try {
|
||
this.config.onFullscreen(this._fullscreen, this);
|
||
}
|
||
catch (e) {
|
||
console.error(e);
|
||
}
|
||
} return this; }
|
||
isFullscreen() { return this._fullscreen; }
|
||
exitFullscreen() { if (this._fullscreen)
|
||
this.toggleFullscreen(); return this; }
|
||
toggleZen() {
|
||
this._zenMode = !this._zenMode;
|
||
if (this.el)
|
||
this.el.classList.toggle('me-zen', this._zenMode);
|
||
if (this._zenMode && this.toolbarEl) {
|
||
this._zenMouseHandler = (e) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; };
|
||
this.toolbarEl.style.transition = 'opacity 0.2s';
|
||
this.toolbarEl.style.opacity = '0';
|
||
this.toolbarEl.style.pointerEvents = 'none';
|
||
document.addEventListener('mousemove', this._zenMouseHandler);
|
||
if (this.statusEl)
|
||
this.statusEl.style.display = 'none';
|
||
}
|
||
else {
|
||
if (this._zenMouseHandler) {
|
||
document.removeEventListener('mousemove', this._zenMouseHandler);
|
||
this._zenMouseHandler = null;
|
||
}
|
||
if (this.toolbarEl) {
|
||
this.toolbarEl.style.opacity = '';
|
||
this.toolbarEl.style.pointerEvents = '';
|
||
this.toolbarEl.style.transition = '';
|
||
}
|
||
if (this.statusEl)
|
||
this.statusEl.style.display = '';
|
||
}
|
||
this._emit('zenChange', this._zenMode);
|
||
return this;
|
||
}
|
||
isZen() { return this._zenMode; }
|
||
toggleWordWrap() { this._wordWrap = !this._wordWrap; if (this.textarea)
|
||
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
|
||
setWordWrap(on) { this._wordWrap = !!on; if (this.textarea)
|
||
this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; }
|
||
isWordWrap() { return this._wordWrap; }
|
||
_initFloatingToolbar() {
|
||
if (typeof document === 'undefined')
|
||
return;
|
||
// Inject floating toolbar CSS once globally
|
||
if (!document.getElementById('me-float-style')) {
|
||
const style = document.createElement('style');
|
||
style.id = 'me-float-style';
|
||
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`;
|
||
document.head.appendChild(style);
|
||
}
|
||
if (!this._floatingEnabled)
|
||
return;
|
||
const ta = this.textarea;
|
||
let _lastSelStart = ta.selectionStart;
|
||
let _lastSelEnd = ta.selectionEnd;
|
||
const emitSelectionChange = () => {
|
||
const s = ta.selectionStart;
|
||
const e = ta.selectionEnd;
|
||
if (s !== _lastSelStart || e !== _lastSelEnd) {
|
||
_lastSelStart = s;
|
||
_lastSelEnd = e;
|
||
this._emit('selectionChange', { start: s, end: e, text: this._value.slice(s, e) });
|
||
}
|
||
};
|
||
const show = () => {
|
||
if (this._destroyed || this.config.readOnly || !this._floatingEnabled)
|
||
return;
|
||
emitSelectionChange();
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
if (start === end) {
|
||
this._hideFloatingToolbar();
|
||
return;
|
||
}
|
||
if (!this._floatingToolbar || !this._floatingToolbar.parentNode)
|
||
this._buildFloatingToolbar();
|
||
const taRect = ta.getBoundingClientRect();
|
||
const paneRect = this.editorPane.getBoundingClientRect();
|
||
const taStyle = getComputedStyle(ta);
|
||
const lineHeight = parseFloat(taStyle.lineHeight) || 22;
|
||
const paddingTop = parseFloat(taStyle.paddingTop) || 16;
|
||
const paddingLeft = parseFloat(taStyle.paddingLeft) || 18;
|
||
const fontSize = parseFloat(taStyle.fontSize) || 13.5;
|
||
const charWidth = fontSize * 0.6;
|
||
const textBefore = ta.value.substring(0, start);
|
||
const lines = textBefore.split('\n');
|
||
const currentLine = lines.length - 1;
|
||
const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1;
|
||
const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1));
|
||
const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop;
|
||
const lineLocalTop = lineViewportTop - paneRect.top;
|
||
const toolbarHeight = 36;
|
||
const gap = 4;
|
||
let top = lineLocalTop - toolbarHeight - gap;
|
||
if (top < gap)
|
||
top = lineLocalTop + lineHeight + gap;
|
||
const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft;
|
||
let left = colX - paneRect.left - 80;
|
||
left = Math.max(4, Math.min(left, paneRect.width - 200));
|
||
this._floatingToolbar.style.top = top + 'px';
|
||
this._floatingToolbar.style.left = left + 'px';
|
||
this._floatingToolbar.classList.add('me-visible');
|
||
};
|
||
const hide = () => { this._hideFloatingToolbar(); };
|
||
ta.addEventListener('mouseup', () => setTimeout(show, 0));
|
||
ta.addEventListener('keyup', () => {
|
||
emitSelectionChange();
|
||
if (ta.selectionStart !== ta.selectionEnd)
|
||
setTimeout(show, 0);
|
||
else
|
||
setTimeout(hide, 0);
|
||
this._emit('cursorMove', this.getCursorPosition());
|
||
});
|
||
ta.addEventListener('blur', () => setTimeout(hide, 300));
|
||
ta.addEventListener('click', () => {
|
||
emitSelectionChange();
|
||
if (ta.selectionStart === ta.selectionEnd)
|
||
hide();
|
||
});
|
||
}
|
||
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;
|
||
}
|
||
isFloatingToolbar() { return this._floatingEnabled; }
|
||
_buildFloatingToolbar() {
|
||
const bar = document.createElement('div');
|
||
bar.className = 'me-float-toolbar';
|
||
const actions = ['bold', 'italic', 'code', 'link', 'strikethrough'];
|
||
actions.forEach((action) => {
|
||
const btn = this._createBtn(action);
|
||
btn.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this.exec(action);
|
||
// Keep selection after exec
|
||
setTimeout(() => this.textarea.focus(), 0);
|
||
});
|
||
bar.appendChild(btn);
|
||
});
|
||
this.editorPane.appendChild(bar);
|
||
this._floatingToolbar = bar;
|
||
}
|
||
_hideFloatingToolbar() {
|
||
if (this._floatingToolbar) {
|
||
this._floatingToolbar.classList.remove('me-visible');
|
||
}
|
||
}
|
||
_bindToolbarKeyboard() {
|
||
if (!this.toolbarEl)
|
||
return;
|
||
const onKeydown = (e) => {
|
||
const btns = [...this.toolbarEl.querySelectorAll('.me-btn:not(:disabled)')];
|
||
if (!btns.length)
|
||
return;
|
||
const idx = btns.indexOf(document.activeElement);
|
||
if (idx === -1)
|
||
return;
|
||
if (e.key === 'ArrowRight') {
|
||
e.preventDefault();
|
||
btns[(idx + 1) % btns.length].focus();
|
||
}
|
||
else if (e.key === 'ArrowLeft') {
|
||
e.preventDefault();
|
||
btns[(idx - 1 + btns.length) % btns.length].focus();
|
||
}
|
||
else if (e.key === 'Home') {
|
||
e.preventDefault();
|
||
btns[0].focus();
|
||
}
|
||
else if (e.key === 'End') {
|
||
e.preventDefault();
|
||
btns[btns.length - 1].focus();
|
||
}
|
||
};
|
||
this.toolbarEl.addEventListener('keydown', onKeydown);
|
||
this._cleanups.push(() => this.toolbarEl.removeEventListener('keydown', onKeydown));
|
||
}
|
||
_initAriaLive() { if (!this.el)
|
||
return; const live = document.createElement('div'); live.className = 'me-sr-only'; live.setAttribute('aria-live', 'polite'); live.setAttribute('aria-atomic', 'true'); this.el.appendChild(live); this._ariaLive = live; }
|
||
_announce(msg) { if (this._ariaLive) {
|
||
this._ariaLive.textContent = '';
|
||
requestAnimationFrame(() => { this._ariaLive.textContent = msg; });
|
||
} }
|
||
_syncScroll() {
|
||
if (this._syncing)
|
||
return;
|
||
this._syncing = true;
|
||
const ta = this.textarea;
|
||
if (!ta) {
|
||
this._syncing = false;
|
||
return;
|
||
}
|
||
const tmax = ta.scrollHeight - ta.clientHeight;
|
||
if (tmax <= 0) {
|
||
this._syncing = false;
|
||
return;
|
||
}
|
||
const ratio = ta.scrollTop / tmax;
|
||
if (this.gutter) {
|
||
const gmax = this.gutter.scrollHeight - this.gutter.clientHeight;
|
||
this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ta.scrollTop;
|
||
}
|
||
if (this.config.syncScroll && this._mode === 'split' && this.previewPane) {
|
||
const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight;
|
||
if (pmax > 0)
|
||
this.previewPane.scrollTop = ratio * pmax;
|
||
}
|
||
requestAnimationFrame(() => { this._syncing = false; });
|
||
}
|
||
_updateWordCount() {
|
||
if (!this.config.wordCount || !this.statusEl)
|
||
return;
|
||
const text = this._value || '';
|
||
const chars = text.length;
|
||
const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||
const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length;
|
||
const words = cnChars + enWords;
|
||
const lines = text ? text.split('\n').length : 0;
|
||
const readingMin = Math.max(1, Math.ceil(words / 300));
|
||
this.statusEl.textContent = [`${t('characters')}: ${chars}`, `${t('words')}: ${words}`, `${t('lines')}: ${lines}`, `${t('readingTime')}: ${readingMin} ${t('minutes')}`].join(' · ');
|
||
}
|
||
getStats() { const text = this._value || ''; const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length; const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length; return { characters: text.length, words: cnChars + enWords, chineseChars: cnChars, englishWords: enWords, lines: text ? text.split('\n').length : 0, readingTime: Math.max(1, Math.ceil((cnChars + enWords) / 300)), }; }
|
||
getSelectedText() {
|
||
if (!this.textarea)
|
||
return '';
|
||
return this._value.slice(this.textarea.selectionStart, this.textarea.selectionEnd);
|
||
}
|
||
getCursorPosition() {
|
||
if (!this.textarea)
|
||
return { line: 1, column: 0 };
|
||
const pos = this.textarea.selectionStart;
|
||
const before = this._value.substring(0, pos);
|
||
const line = before.split('\n').length;
|
||
const column = pos - before.lastIndexOf('\n') - 1;
|
||
return { line, column: column < 0 ? 0 : column };
|
||
}
|
||
setCursorPosition(line, column = 0) {
|
||
if (!this.textarea)
|
||
return this;
|
||
const lines = this._value.split('\n');
|
||
const clampedLine = Math.max(1, Math.min(line, lines.length));
|
||
let pos = 0;
|
||
for (let i = 0; i < clampedLine - 1; i++)
|
||
pos += lines[i].length + 1;
|
||
pos += Math.min(column, lines[clampedLine - 1]?.length || 0);
|
||
this.textarea.focus();
|
||
this.textarea.setSelectionRange(pos, pos);
|
||
return this;
|
||
}
|
||
scrollToLine(line) {
|
||
if (!this.textarea)
|
||
return this;
|
||
const lines = this._value.split('\n');
|
||
const clampedLine = Math.max(1, Math.min(line, lines.length));
|
||
const lineHeight = 22; // approximate
|
||
this.textarea.scrollTop = (clampedLine - 1) * lineHeight;
|
||
if (this.gutter)
|
||
this.gutter.scrollTop = this.textarea.scrollTop;
|
||
return this;
|
||
}
|
||
selectLine(line) {
|
||
if (!this.textarea)
|
||
return this;
|
||
const lines = this._value.split('\n');
|
||
const clampedLine = Math.max(1, Math.min(line, lines.length));
|
||
let startPos = 0;
|
||
for (let i = 0; i < clampedLine - 1; i++)
|
||
startPos += lines[i].length + 1;
|
||
const endPos = startPos + lines[clampedLine - 1].length;
|
||
this.textarea.focus();
|
||
this.textarea.setSelectionRange(startPos, endPos);
|
||
return this;
|
||
}
|
||
selectAll() { if (this.textarea) {
|
||
this.textarea.focus();
|
||
this.textarea.select();
|
||
} return this; }
|
||
replaceAll(search, replace, caseSensitive = true) {
|
||
if (!search)
|
||
return 0;
|
||
const flags = caseSensitive ? 'g' : 'gi';
|
||
const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const re = new RegExp(escaped, flags);
|
||
const matches = this._value.match(re);
|
||
if (!matches)
|
||
return 0;
|
||
this._value = this._value.replace(re, replace);
|
||
this.textarea.value = this._value;
|
||
this._pushHistory();
|
||
this._render();
|
||
this._updateWordCount();
|
||
this._emit('change', this._value);
|
||
return matches.length;
|
||
}
|
||
replaceAllRegex(pattern, replace) {
|
||
if (!pattern)
|
||
return 0;
|
||
const re = new RegExp(pattern, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
|
||
const matches = this._value.match(re);
|
||
if (!matches)
|
||
return 0;
|
||
this._value = this._value.replace(re, replace);
|
||
this.textarea.value = this._value;
|
||
this._pushHistory();
|
||
this._render();
|
||
this._updateWordCount();
|
||
this._emit('change', this._value);
|
||
return matches.length;
|
||
}
|
||
lineCount() {
|
||
return this._value ? this._value.split('\n').length : 0;
|
||
}
|
||
getLine(line) {
|
||
const lines = this._value.split('\n');
|
||
const idx = line - 1;
|
||
if (idx < 0 || idx >= lines.length)
|
||
return '';
|
||
return lines[idx];
|
||
}
|
||
getValue() { return this._destroyed ? '' : this._value; }
|
||
setValue(md, opts = {}) {
|
||
if (this._destroyed)
|
||
return this;
|
||
MarkdownEditor.trigger('beforeChange', this);
|
||
this._emit('beforeChange', this._value, md);
|
||
this._value = md || '';
|
||
this.textarea.value = this._value;
|
||
if (!opts.silent)
|
||
this._pushHistory();
|
||
this._render();
|
||
this._renderGutter();
|
||
this._updateWordCount();
|
||
this._updateOutline();
|
||
if (this.gutter)
|
||
this.gutter.scrollTop = 0;
|
||
this.textarea.scrollTop = 0;
|
||
if (!opts.silent) {
|
||
this._emit('change', this._value);
|
||
if (typeof this.config.onChange === 'function') {
|
||
try {
|
||
this.config.onChange(this._value, this);
|
||
}
|
||
catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
}
|
||
this._emit('afterChange', this._value);
|
||
MarkdownEditor.trigger('afterChange', this);
|
||
return this;
|
||
}
|
||
getHTML() { MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this); let html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); if (typeof this.config.sanitize === 'function') {
|
||
try {
|
||
html = this.config.sanitize(html);
|
||
}
|
||
catch (e) {
|
||
console.error(e);
|
||
}
|
||
} MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this); return html; }
|
||
refresh() { this._lastRenderedValue = null; this._render(); return this; }
|
||
copyAsMarkdown() {
|
||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'markdown' })).catch(() => { });
|
||
}
|
||
return this;
|
||
}
|
||
copyAsHTML() {
|
||
const html = this.getHTML();
|
||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||
const blob = new Blob([html], { type: 'text/html' });
|
||
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
|
||
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { });
|
||
}
|
||
return this;
|
||
}
|
||
insert(text, opts = {}) {
|
||
const ta = this.textarea;
|
||
const start = ta.selectionStart;
|
||
const end = ta.selectionEnd;
|
||
ta.value = ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start);
|
||
ta.focus();
|
||
ta.selectionStart = ta.selectionEnd = start + text.length;
|
||
this._value = ta.value;
|
||
this._pushHistory();
|
||
this._render();
|
||
this._updateWordCount();
|
||
this._emit('change', this._value);
|
||
return this;
|
||
}
|
||
wrap(before, after) { this._wrapSelection(before, after || before); return this; }
|
||
focus() { if (this.textarea)
|
||
this.textarea.focus(); return this; }
|
||
blur() { if (this.textarea)
|
||
this.textarea.blur(); return this; }
|
||
enable() { if (this.textarea)
|
||
this.textarea.disabled = false; if (this.el)
|
||
this.el.classList.remove('me-disabled'); return this; }
|
||
disable() { if (this.textarea)
|
||
this.textarea.disabled = true; if (this.el)
|
||
this.el.classList.add('me-disabled'); return this; }
|
||
isDisabled() { return this.textarea ? this.textarea.disabled : false; }
|
||
setReadOnly(readOnly) { this.config.readOnly = !!readOnly; if (this.textarea)
|
||
this.textarea.readOnly = !!readOnly; if (this.el)
|
||
this.el.classList.toggle('me-readonly', !!readOnly); this._updateModeButtons(); return this; }
|
||
isReadOnly() { return !!this.config.readOnly; }
|
||
on(name, fn) { if (!this._listeners[name])
|
||
this._listeners[name] = []; this._listeners[name].push(fn); return () => this.off(name, fn); }
|
||
off(name, fn) { const list = this._listeners[name]; if (list)
|
||
this._listeners[name] = list.filter((f) => f !== fn); return this; }
|
||
_emit(name, ...args) { const list = this._listeners[name]; if (list)
|
||
list.forEach((fn) => { try {
|
||
fn(...args, this);
|
||
}
|
||
catch (e) {
|
||
console.error(`MeEditor event "${name}" error:`, e);
|
||
} }); }
|
||
use(plugin, options = {}) {
|
||
let p = plugin;
|
||
if (typeof plugin === 'string') {
|
||
p = presetPlugins[plugin];
|
||
if (!p) {
|
||
console.warn(`MeEditor: preset plugin "${plugin}" not found`);
|
||
return this;
|
||
}
|
||
}
|
||
if (!p || typeof p !== 'object') {
|
||
console.warn('MeEditor: invalid plugin');
|
||
return this;
|
||
}
|
||
const merged = { ...p, ...options };
|
||
if (typeof merged.install === 'function') {
|
||
try {
|
||
const result = merged.install(this, options);
|
||
if (result && typeof result.then === 'function') {
|
||
result.catch((e) => console.error(`MeEditor: async plugin "${merged.name}" error:`, e));
|
||
}
|
||
}
|
||
catch (e) {
|
||
console.error(`MeEditor: plugin "${merged.name}" install error:`, e);
|
||
}
|
||
}
|
||
this._plugins.push(merged);
|
||
return this;
|
||
}
|
||
unuse(name) { const idx = this._plugins.findIndex((p) => p.name === name); if (idx === -1) {
|
||
console.warn(`MeEditor: plugin "${name}" not found`);
|
||
return this;
|
||
} const plugin = this._plugins[idx]; if (typeof plugin.destroy === 'function') {
|
||
try {
|
||
plugin.destroy(this);
|
||
}
|
||
catch (e) {
|
||
console.error(`MeEditor: plugin "${name}" destroy error:`, e);
|
||
}
|
||
} this._plugins.splice(idx, 1); return this; }
|
||
getPlugins() { return [...this._plugins]; }
|
||
addToolbarButton(config) {
|
||
if (!config || !config.action)
|
||
return this;
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = `me-btn me-btn-custom-${config.action}`;
|
||
btn.title = config.title || config.action;
|
||
btn.setAttribute('aria-label', config.title || config.action);
|
||
btn.innerHTML = config.icon || `<span>${escapeHTML(config.text || config.action)}</span>`;
|
||
if (typeof config.onClick === 'function') {
|
||
btn.addEventListener('click', (e) => { e.stopPropagation(); config.onClick(this); });
|
||
}
|
||
else if (config.action) {
|
||
btn.dataset.action = config.action;
|
||
this._customActions = this._customActions || {};
|
||
this._customActions[config.action] = config.handler;
|
||
}
|
||
this.toolbarEl.insertBefore(btn, this.toolbarEl.querySelector('.me-toolbar-group') || null);
|
||
return this;
|
||
}
|
||
registerShortcut(combo, handler, description = '') {
|
||
if (!combo)
|
||
return this;
|
||
const parts = combo.toLowerCase().split('+');
|
||
const key = parts.pop();
|
||
const ctrl = parts.includes('ctrl') || parts.includes('cmd');
|
||
const shift = parts.includes('shift');
|
||
const alt = parts.includes('alt');
|
||
const meta = parts.includes('meta');
|
||
this._shortcuts.push({ combo, key, ctrl, shift, alt, meta, handler, description });
|
||
return this;
|
||
}
|
||
unregisterShortcut(combo) { this._shortcuts = this._shortcuts.filter((s) => s.combo !== combo); return this; }
|
||
getShortcuts() { return [...this._shortcuts]; }
|
||
configureToolbar(tools) { if (!this.toolbarEl)
|
||
return this; this.config.toolbar = tools; this.toolbarEl.innerHTML = ''; this._buildToolbar(); return this; }
|
||
removeToolbarButton(action) { if (!this.toolbarEl)
|
||
return this; const btn = this.toolbarEl.querySelector(`.me-btn[data-action="${action}"], .me-btn[data-mode="${action}"]`); if (btn)
|
||
btn.remove(); return this; }
|
||
registerContextMenu(items = []) { this._contextMenuItems = items; return this; }
|
||
_bindContextMenu() {
|
||
if (!this.el)
|
||
return;
|
||
const onContextMenu = (e) => {
|
||
const existing = document.querySelector('.me-context-menu');
|
||
if (existing)
|
||
existing.remove();
|
||
this._showContextMenu(e);
|
||
};
|
||
this.el.addEventListener('contextmenu', onContextMenu);
|
||
this._cleanups.push(() => this.el.removeEventListener('contextmenu', onContextMenu));
|
||
}
|
||
_showContextMenu(e) {
|
||
e.preventDefault();
|
||
const ta = this.textarea;
|
||
const hasSelection = ta && ta.selectionStart !== ta.selectionEnd;
|
||
const defaultItems = [
|
||
{ label: t('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() },
|
||
{ label: t('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() },
|
||
{ sep: true },
|
||
{ label: t('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
|
||
{ label: t('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
|
||
{ label: t('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
|
||
{ label: t('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' },
|
||
];
|
||
const items = [...defaultItems];
|
||
if (this._contextMenuItems.length) {
|
||
items.push({ sep: true });
|
||
this._contextMenuItems.forEach((item) => items.push(item));
|
||
}
|
||
const menu = document.createElement('div');
|
||
menu.className = 'me-context-menu';
|
||
menu.style.left = e.clientX + 'px';
|
||
menu.style.top = e.clientY + 'px';
|
||
// Adjust if off-screen
|
||
requestAnimationFrame(() => {
|
||
const rect = menu.getBoundingClientRect();
|
||
if (rect.right > window.innerWidth)
|
||
menu.style.left = (e.clientX - rect.width) + 'px';
|
||
if (rect.bottom > window.innerHeight)
|
||
menu.style.top = (e.clientY - rect.height) + 'px';
|
||
});
|
||
items.forEach((item) => {
|
||
if (item.sep) {
|
||
const sep = document.createElement('div');
|
||
sep.className = 'me-context-menu-sep';
|
||
menu.appendChild(sep);
|
||
return;
|
||
}
|
||
const el = document.createElement('div');
|
||
el.className = 'me-context-menu-item';
|
||
if (item.disabled)
|
||
el.classList.add('me-disabled');
|
||
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`;
|
||
el.addEventListener('click', (ev) => {
|
||
ev.stopPropagation();
|
||
if (item.disabled)
|
||
return;
|
||
if (item.onClick) {
|
||
item.onClick();
|
||
}
|
||
else if (item.action)
|
||
this._execContextAction(item.action);
|
||
this._hideContextMenu();
|
||
});
|
||
menu.appendChild(el);
|
||
});
|
||
document.body.appendChild(menu);
|
||
const close = (ev) => {
|
||
if (!menu.contains(ev.target)) {
|
||
this._hideContextMenu();
|
||
}
|
||
};
|
||
document.addEventListener('click', close, { once: true });
|
||
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape')
|
||
this._hideContextMenu(); }, { once: true });
|
||
}
|
||
_hideContextMenu() {
|
||
const menu = document.querySelector('.me-context-menu');
|
||
if (menu)
|
||
menu.remove();
|
||
}
|
||
_execContextAction(action) {
|
||
const ta = this.textarea;
|
||
if (!ta)
|
||
return;
|
||
switch (action) {
|
||
case 'undo':
|
||
this.undo();
|
||
break;
|
||
case 'redo':
|
||
this.redo();
|
||
break;
|
||
case 'cut':
|
||
document.execCommand('cut');
|
||
break;
|
||
case 'copy':
|
||
document.execCommand('copy');
|
||
break;
|
||
case 'paste':
|
||
document.execCommand('paste');
|
||
break;
|
||
case 'selectAll':
|
||
ta.focus();
|
||
ta.select();
|
||
break;
|
||
default:
|
||
this.exec(action);
|
||
break;
|
||
}
|
||
}
|
||
toast(message, opts = {}) {
|
||
if (!this.el || typeof document === 'undefined')
|
||
return this;
|
||
const { type = 'info', duration = 3000, animation = 'fade' } = opts;
|
||
const toast = document.createElement('div');
|
||
toast.className = `me-toast me-toast-${type} me-toast-${animation}`;
|
||
toast.innerHTML = `<span class="me-toast-icon">${TOAST_ICONS[type] || ''}</span><span class="me-toast-msg">${escapeHTML(String(message))}</span>`;
|
||
this.el.appendChild(toast);
|
||
requestAnimationFrame(() => { toast.classList.add('me-toast-enter'); });
|
||
const remove = () => { toast.classList.remove('me-toast-enter'); toast.classList.add('me-toast-leave'); setTimeout(() => { if (toast.parentNode)
|
||
toast.parentNode.removeChild(toast); }, 300); };
|
||
if (duration > 0)
|
||
setTimeout(remove, duration);
|
||
toast.addEventListener('click', remove);
|
||
return this;
|
||
}
|
||
setLocale(locale) { if (this._i18nCtx)
|
||
this._i18nCtx.set(locale); return this; }
|
||
getLocale() { if (this._i18nCtx)
|
||
return this._i18nCtx.get(); return getCurrentLocale(); }
|
||
t(key, params) { if (this._i18nCtx)
|
||
return this._i18nCtx.t(key, params); return t(key, params); }
|
||
setTheme(theme) { if (!theme)
|
||
return this; this.config.theme = theme; if (this._themeCtx)
|
||
this._themeCtx.set(theme); return this; }
|
||
getTheme() { if (this._themeCtx)
|
||
return this._themeCtx.get(); return this.config.theme || 'auto'; }
|
||
getThemeContext() { return this._themeCtx || null; }
|
||
getStatus() { return { id: this.id, mode: this._mode, theme: this.getTheme(), locale: getCurrentLocale(), fullscreen: this._fullscreen, readOnly: this.config.readOnly || false, disabled: this.textarea ? this.textarea.disabled : false, destroyed: this._destroyed, plugins: this._plugins.map((p) => p.name || 'custom'), }; }
|
||
destroy() {
|
||
if (this._destroyed)
|
||
return;
|
||
MarkdownEditor.trigger('beforeDestroy', this);
|
||
if (this._renderRaf)
|
||
cancelAnimationFrame(this._renderRaf);
|
||
if (this._historyTimer)
|
||
clearTimeout(this._historyTimer);
|
||
if (this._outlineTimer)
|
||
clearTimeout(this._outlineTimer);
|
||
this._cleanups.forEach((fn) => { try {
|
||
fn();
|
||
}
|
||
catch (_) { } });
|
||
this._cleanups = [];
|
||
this._shortcuts = [];
|
||
this._contextMenuItems = [];
|
||
this._customActions = {};
|
||
if (this._floatingToolbar && this._floatingToolbar.parentNode)
|
||
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
|
||
this._floatingToolbar = null;
|
||
if (this.el)
|
||
this.el.querySelectorAll('.me-toast').forEach((t) => t.remove());
|
||
this._plugins.forEach((p) => { if (typeof p.destroy === 'function') {
|
||
try {
|
||
p.destroy(this);
|
||
}
|
||
catch (_) { }
|
||
} });
|
||
this._plugins = [];
|
||
if (this.el && this.el.parentNode)
|
||
this.el.parentNode.removeChild(this.el);
|
||
this.el = null;
|
||
this.textarea = null;
|
||
this.previewEl = null;
|
||
this._destroyed = true;
|
||
if (typeof this.config.onDestroy === 'function') {
|
||
try {
|
||
this.config.onDestroy(this);
|
||
}
|
||
catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
this._emit('destroy');
|
||
this._listeners = {};
|
||
MarkdownEditor.trigger('afterDestroy', this);
|
||
}
|
||
isDestroyed() { return this._destroyed; }
|
||
}
|
||
MarkdownEditor._hooks = new Map();
|
||
|
||
/**
|
||
* MetonaEditor Animations — animation metadata
|
||
* @module animations
|
||
* @version 0.2.0
|
||
*/
|
||
const animationMap = new Map();
|
||
// Register built-in animations
|
||
for (const [name, config] of Object.entries(ANIMATIONS)) {
|
||
animationMap.set(name, config);
|
||
}
|
||
const animationUtils = {
|
||
register(name, config) {
|
||
animationMap.set(name, {
|
||
name,
|
||
enter: config.enter || {},
|
||
leave: config.leave || {},
|
||
duration: config.duration || 300,
|
||
easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||
});
|
||
},
|
||
unregister(name) {
|
||
animationMap.delete(name);
|
||
},
|
||
get(name) {
|
||
return animationMap.get(name) || ANIMATIONS[name] || null;
|
||
},
|
||
getAnimationNames() {
|
||
return Array.from(animationMap.keys());
|
||
},
|
||
getActiveCount() {
|
||
return 0;
|
||
},
|
||
cancelAll() {
|
||
// CSS animations managed by browser natively
|
||
},
|
||
reset() {
|
||
animationMap.clear();
|
||
for (const [name, config] of Object.entries(ANIMATIONS)) {
|
||
animationMap.set(name, config);
|
||
}
|
||
},
|
||
destroy() {
|
||
animationMap.clear();
|
||
},
|
||
};
|
||
|
||
/**
|
||
* MetonaEditor — Type-safe, lightweight Markdown Editor
|
||
* @module metona-editor
|
||
* @version 0.2.3
|
||
*/
|
||
const VERSION = '0.2.3';
|
||
const globalPlugins = [];
|
||
MarkdownEditor.on('beforeCreate', (editor) => {
|
||
if (!editor || editor.isDestroyed())
|
||
return;
|
||
globalPlugins.forEach((p) => { try {
|
||
editor.use(p);
|
||
}
|
||
catch (e) {
|
||
console.error('MeEditor global plugin install error:', e);
|
||
} });
|
||
});
|
||
function create(container, options = {}) {
|
||
return new MarkdownEditor(container, options);
|
||
}
|
||
function use(plugin, options = {}) {
|
||
let p = plugin;
|
||
if (typeof plugin === 'string') {
|
||
p = presetPlugins[plugin];
|
||
if (!p) {
|
||
console.warn(`MeEditor: preset plugin "${plugin}" not found`);
|
||
return api;
|
||
}
|
||
}
|
||
if (!p || typeof p !== 'object') {
|
||
console.warn('MeEditor: invalid plugin');
|
||
return api;
|
||
}
|
||
const merged = (options && typeof options === 'object' && Object.keys(options).length > 0) ? { ...p, ...options } : p;
|
||
globalPlugins.push(merged);
|
||
return api;
|
||
}
|
||
function on(name, fn) { return MarkdownEditor.on(name, fn); }
|
||
function off(name, fn) { MarkdownEditor.off(name, fn); return api; }
|
||
function setTheme(theme, _options) { if (themeUtils && typeof themeUtils.switchTheme === 'function')
|
||
themeUtils.switchTheme(theme); return api; }
|
||
function setLocale(locale) { if (i18nUtils && typeof i18nUtils.setCurrentLocale === 'function')
|
||
i18nUtils.setCurrentLocale(locale); return api; }
|
||
function destroy() {
|
||
if (themeUtils) {
|
||
try {
|
||
themeUtils.clearThemeListeners();
|
||
}
|
||
catch (_) { }
|
||
try {
|
||
themeUtils.unwatchSystemTheme();
|
||
}
|
||
catch (_) { }
|
||
}
|
||
if (i18nUtils) {
|
||
try {
|
||
i18nUtils.clearLocaleListeners();
|
||
}
|
||
catch (_) { }
|
||
}
|
||
if (animationUtils) {
|
||
try {
|
||
animationUtils.cancelAll();
|
||
}
|
||
catch (_) { }
|
||
}
|
||
globalPlugins.length = 0;
|
||
}
|
||
function getStatus() {
|
||
return { version: VERSION, theme: (themeUtils && typeof themeUtils.getCurrentTheme === 'function') ? themeUtils.getCurrentTheme() : 'auto', locale: (i18nUtils && typeof i18nUtils.getCurrentLocale === 'function') ? i18nUtils.getCurrentLocale() : 'zh-CN', globalPlugins: globalPlugins.map((p) => p.name || 'custom'), presetPlugins: Object.keys(presetPlugins || {}), };
|
||
}
|
||
try {
|
||
if (typeof themeUtils === 'object' && typeof themeUtils.initTheme === 'function')
|
||
themeUtils.initTheme();
|
||
if (typeof i18nUtils === 'object' && typeof i18nUtils.initI18n === 'function')
|
||
i18nUtils.initI18n();
|
||
}
|
||
catch (_) { }
|
||
const api = {
|
||
VERSION, version: VERSION,
|
||
MarkdownEditor, Editor: MarkdownEditor,
|
||
create, use, on, off, setTheme, setLocale, destroy, getStatus,
|
||
parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler,
|
||
themes: themeUtils, i18n: i18nUtils, animations: animationUtils, plugins: pluginUtils, presetPlugins,
|
||
topologicalSort, validateConfig, createInstanceI18n, loadRemote,
|
||
exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme,
|
||
DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS,
|
||
};
|
||
if (typeof window !== 'undefined') {
|
||
window.MeEditor = api;
|
||
}
|
||
|
||
exports.DEFAULTS = DEFAULTS;
|
||
exports.DEFAULT_TOOLBAR = DEFAULT_TOOLBAR;
|
||
exports.EDIT_MODES = EDIT_MODES;
|
||
exports.Editor = MarkdownEditor;
|
||
exports.ICONS = ICONS;
|
||
exports.MarkdownEditor = MarkdownEditor;
|
||
exports.MeEditor = api;
|
||
exports.THEMES = THEMES;
|
||
exports.TOOLBAR_ACTIONS = TOOLBAR_ACTIONS;
|
||
exports.VERSION = VERSION;
|
||
exports.adoptFromParent = adoptFromParent;
|
||
exports.animationUtils = animationUtils;
|
||
exports.api = api;
|
||
exports.clearRenderCache = clearRenderCache;
|
||
exports.create = create;
|
||
exports.createInstanceI18n = createInstanceI18n;
|
||
exports.createInstanceTheme = createInstanceTheme;
|
||
exports.default = api;
|
||
exports.destroy = destroy;
|
||
exports.exportCSSVars = exportCSSVars;
|
||
exports.followExternalTheme = followExternalTheme;
|
||
exports.getCSSVariable = getCSSVariable;
|
||
exports.getStatus = getStatus;
|
||
exports.i18nUtils = i18nUtils;
|
||
exports.loadRemote = loadRemote;
|
||
exports.meEditor = api;
|
||
exports.off = off;
|
||
exports.on = on;
|
||
exports.parseMarkdown = parseMarkdown;
|
||
exports.parseTokens = parseTokens;
|
||
exports.pluginUtils = pluginUtils;
|
||
exports.presetPlugins = presetPlugins;
|
||
exports.registerBlockHandler = registerBlockHandler;
|
||
exports.renderTokens = renderTokens;
|
||
exports.safeUrl = safeUrl;
|
||
exports.setLocale = setLocale;
|
||
exports.setTheme = setTheme;
|
||
exports.slugify = slugify;
|
||
exports.themeUtils = themeUtils;
|
||
exports.topologicalSort = topologicalSort;
|
||
exports.use = use;
|
||
exports.validateConfig = validateConfig;
|
||
//# sourceMappingURL=metona-editor.cjs.js.map
|