/**
* MetonaEditor Utils — utility functions
* @module utils
* @version 0.2.0
*/
/** Generate a unique ID */
const generateId = () => {
return 'me-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
};
/** HTML-escape a string */
const escapeHTML = (s) => {
const str = String(s == null ? '' : s);
if (typeof document === 'undefined') {
return str
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
};
/** HTML-escape a string for use inside a double-quoted attribute value */
const escapeAttr$1 = (s) => {
return String(s == null ? '' : s)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(//g, '>');
};
/** Detect dark mode preference */
const prefersDark = () => {
if (typeof window === 'undefined' || !window.matchMedia)
return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
};
/** Check if running in browser */
const isBrowser = () => {
return typeof window !== 'undefined' && typeof document !== 'undefined';
};
/**
* MetonaEditor Icons — SVG toolbar icons
* @module icons
* @version 0.2.0
*/
const ICONS = {
bold: ` `,
italic: ` `,
underline: ` `,
strikethrough: ` `,
h1: ` `,
h2: ` `,
h3: ` `,
quote: ` `,
code: ` `,
link: ` `,
image: ` `,
table: ` `,
ul: ` `,
ol: ` `,
indent: ` `,
outdent: ` `,
hr: ` `,
undo: ` `,
redo: ` `,
preview: ` `,
split: ` `,
edit: ` `,
fullscreen: ` `,
theme: ` `,
};
/**
* MetonaEditor Locales — translation data
* @module locales
* @version 0.2.3
*/
const LOCALES = {
'zh-CN': {
bold: '粗体', italic: '斜体', underline: '下划线', strikethrough: '删除线',
h1: '标题1', h2: '标题2', h3: '标题3', quote: '引用', code: '代码',
link: '链接', image: '图片', table: '表格', ul: '无序列表', ol: '有序列表',
indent: '增加缩进', outdent: '减少缩进', hr: '分隔线', undo: '撤销', redo: '重做',
edit: '编辑', split: '分屏', preview: '预览', fullscreen: '全屏',
fullscreenExit: '退出全屏', theme: '主题', light: '亮色', dark: '暗色',
auto: '自动', warm: '暖色', wordCount: '字数统计', characters: '字符',
words: '词数', lines: '行数', readingTime: '阅读', minutes: '分钟',
placeholder: '开始输入 Markdown...', empty: '暂无内容', copied: '已复制',
copyContent: '复制内容', copyHTML: '复制 HTML', copySuccess: '复制成功',
copyFailed: '复制失败', clearContent: '清空内容', clearConfirm: '确定要清空所有内容吗?',
linkPlaceholder: '请输入链接地址', imagePlaceholder: '请输入图片地址',
altPlaceholder: '请输入替代文本', tableRows: '行数', tableCols: '列数',
confirm: '确认', cancel: '取消', exportMarkdown: '导出 Markdown',
exportHTML: '导出 HTML', search: '搜索', replace: '替换', replaceAll: '全部替换',
searchPlaceholder: '查找内容', replacePlaceholder: '替换为',
findNext: '查找下一个', findPrev: '查找上一个', matchCase: '区分大小写',
wholeWord: '全字匹配', close: '关闭', open: '打开', save: '保存',
saved: '已保存', saving: '保存中...', delete: '删除', confirmDelete: '确定要删除吗?',
unsavedChanges: '有未保存的更改', error: '错误', success: '成功',
warning: '警告', info: '信息', loading: '加载中...', retry: '重试',
renderError: '渲染失败',
outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
regex: '正则表达式', shortcuts: '快捷键', imageTooLarge: '图片过大({size}KB > {max}KB)',
},
'en-US': {
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
h1: 'Heading 1', h2: 'Heading 2', h3: 'Heading 3', quote: 'Quote', code: 'Code',
link: 'Link', image: 'Image', table: 'Table', ul: 'Bullet List', ol: 'Numbered List',
indent: 'Indent', outdent: 'Outdent', hr: 'Horizontal Rule', undo: 'Undo', redo: 'Redo',
edit: 'Edit', split: 'Split', preview: 'Preview', fullscreen: 'Fullscreen',
fullscreenExit: 'Exit Fullscreen', theme: 'Theme', light: 'Light', dark: 'Dark',
auto: 'Auto', warm: 'Warm', wordCount: 'Word Count', characters: 'Characters',
words: 'Words', lines: 'Lines', readingTime: 'Reading', minutes: 'min',
placeholder: 'Start typing Markdown...', empty: 'No content', copied: 'Copied',
copyContent: 'Copy Content', copyHTML: 'Copy HTML', copySuccess: 'Copied successfully',
copyFailed: 'Copy failed', clearContent: 'Clear Content', clearConfirm: 'Clear all content?',
linkPlaceholder: 'Enter link URL', imagePlaceholder: 'Enter image URL',
altPlaceholder: 'Enter alt text', tableRows: 'Rows', tableCols: 'Columns',
confirm: 'Confirm', cancel: 'Cancel', exportMarkdown: 'Export Markdown',
exportHTML: 'Export HTML', search: 'Search', replace: 'Replace', replaceAll: 'Replace All',
searchPlaceholder: 'Find', replacePlaceholder: 'Replace with',
findNext: 'Find Next', findPrev: 'Find Previous', matchCase: 'Match Case',
wholeWord: 'Whole Word', close: 'Close', open: 'Open', save: 'Save',
saved: 'Saved', saving: 'Saving...', delete: 'Delete', confirmDelete: 'Are you sure you want to delete?',
unsavedChanges: 'You have unsaved changes', error: 'Error', success: 'Success',
warning: 'Warning', info: 'Info', loading: 'Loading...', retry: 'Retry',
renderError: 'Render failed',
outline: 'Outline',
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
regex: 'Regex', shortcuts: 'Shortcuts', imageTooLarge: 'Image too large ({size}KB > {max}KB)',
},
ja: {
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
h1: '見出し1', h2: '見出し2', h3: '見出し3', quote: '引用', code: 'コード',
link: 'リンク', image: '画像', table: '表', ul: '箇条書き', ol: '番号付きリスト',
indent: 'インデント', outdent: 'インデント解除', hr: '水平線', undo: '元に戻す', redo: 'やり直し',
edit: '編集', split: '分割', preview: 'プレビュー', fullscreen: '全画面',
fullscreenExit: '全画面解除', theme: 'テーマ', light: 'ライト', dark: 'ダーク',
auto: '自動', warm: 'ウォーム', wordCount: '文字数', characters: '文字',
words: '単語', lines: '行', readingTime: '読了時間', minutes: '分',
placeholder: 'Markdownを入力...', empty: '内容なし', copied: 'コピー済み',
copyContent: '内容をコピー', copyHTML: 'HTMLをコピー', copySuccess: 'コピー成功',
copyFailed: 'コピー失敗', clearContent: '内容をクリア', clearConfirm: 'すべての内容をクリアしますか?',
linkPlaceholder: 'リンクURLを入力', imagePlaceholder: '画像URLを入力',
altPlaceholder: '代替テキストを入力', tableRows: '行', tableCols: '列',
confirm: '確認', cancel: 'キャンセル', exportMarkdown: 'Markdownエクスポート',
exportHTML: 'HTMLエクスポート', search: '検索', replace: '置換', replaceAll: 'すべて置換',
searchPlaceholder: '検索', replacePlaceholder: '置換後',
findNext: '次を検索', findPrev: '前を検索', matchCase: '大文字小文字',
wholeWord: '単語単位', close: '閉じる', open: '開く', save: '保存',
saved: '保存済み', saving: '保存中...', delete: '削除', confirmDelete: '削除してもよろしいですか?',
unsavedChanges: '未保存の変更があります', error: 'エラー', success: '成功',
warning: '警告', info: '情報', loading: '読み込み中...', retry: '再試行',
renderError: 'レンダリング失敗',
outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
regex: '正規表現', shortcuts: 'ショートカット', imageTooLarge: '画像が大きすぎます({size}KB > {max}KB)',
},
ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
h1: '제목1', h2: '제목2', h3: '제목3', quote: '인용', code: '코드',
link: '링크', image: '이미지', table: '표', ul: '순서 없는 목록', ol: '순서 있는 목록',
indent: '들여쓰기', outdent: '내어쓰기', hr: '수평선', undo: '실행 취소', redo: '다시 실행',
edit: '편집', split: '분할', preview: '미리보기', fullscreen: '전체 화면',
fullscreenExit: '전체 화면 종료', theme: '테마', light: '라이트', dark: '다크',
auto: '자동', warm: '웜', wordCount: '글자 수', characters: '글자',
words: '단어', lines: '줄', readingTime: '읽기 시간', minutes: '분',
placeholder: 'Markdown 입력...', empty: '내용 없음', copied: '복사됨',
copyContent: '내용 복사', copyHTML: 'HTML 복사', copySuccess: '복사 성공',
copyFailed: '복사 실패', clearContent: '내용 지우기', clearConfirm: '모든 내용을 지우시겠습니까?',
linkPlaceholder: '링크 URL 입력', imagePlaceholder: '이미지 URL 입력',
altPlaceholder: '대체 텍스트 입력', tableRows: '행', tableCols: '열',
confirm: '확인', cancel: '취소', exportMarkdown: 'Markdown 내보내기',
exportHTML: 'HTML 내보내기', search: '검색', replace: '바꾸기', replaceAll: '모두 바꾸기',
searchPlaceholder: '찾기', replacePlaceholder: '바꿀 내용',
findNext: '다음 찾기', findPrev: '이전 찾기', matchCase: '대소문자 구분',
wholeWord: '단어 단위', close: '닫기', open: '열기', save: '저장',
saved: '저장됨', saving: '저장 중...', delete: '삭제', confirmDelete: '삭제하시겠습니까?',
unsavedChanges: '저장되지 않은 변경 사항이 있습니다', error: '오류', success: '성공',
warning: '경고', info: '정보', loading: '로딩 중...', retry: '재시도',
renderError: '렌더링 실패',
outline: '개요',
cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택',
regex: '정규식', shortcuts: '단축키', imageTooLarge: '이미지가 너무 큽니다({size}KB > {max}KB)',
},
fr: {
bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré',
h1: 'Titre 1', h2: 'Titre 2', h3: 'Titre 3', quote: 'Citation', code: 'Code',
link: 'Lien', image: 'Image', table: 'Tableau', ul: 'Liste à puces', ol: 'Liste numérotée',
indent: 'Indenter', outdent: 'Désindenter', hr: 'Ligne horizontale', undo: 'Annuler', redo: 'Rétablir',
edit: 'Éditer', split: 'Fractionné', preview: 'Aperçu', fullscreen: 'Plein écran',
fullscreenExit: 'Quitter plein écran', theme: 'Thème', light: 'Clair', dark: 'Sombre',
auto: 'Auto', warm: 'Chaud', wordCount: 'Nombre de mots', characters: 'Caractères',
words: 'Mots', lines: 'Lignes', readingTime: 'Lecture', minutes: 'min',
placeholder: 'Commencez à taper Markdown...', empty: 'Aucun contenu', copied: 'Copié',
copyContent: 'Copier le contenu', copyHTML: 'Copier HTML', copySuccess: 'Copié avec succès',
copyFailed: 'Échec de la copie', clearContent: 'Effacer le contenu', clearConfirm: 'Effacer tout le contenu ?',
linkPlaceholder: 'Entrez l\'URL du lien', imagePlaceholder: 'Entrez l\'URL de l\'image',
altPlaceholder: 'Entrez le texte alternatif', tableRows: 'Lignes', tableCols: 'Colonnes',
confirm: 'Confirmer', cancel: 'Annuler', exportMarkdown: 'Exporter Markdown',
exportHTML: 'Exporter HTML', search: 'Rechercher', replace: 'Remplacer', replaceAll: 'Tout remplacer',
searchPlaceholder: 'Rechercher', replacePlaceholder: 'Remplacer par',
findNext: 'Suivant', findPrev: 'Précédent', matchCase: 'Respecter la casse',
wholeWord: 'Mot entier', close: 'Fermer', open: 'Ouvrir', save: 'Enregistrer',
saved: 'Enregistré', saving: 'Enregistrement...', delete: 'Supprimer', confirmDelete: 'Confirmer la suppression ?',
unsavedChanges: 'Modifications non enregistrées', error: 'Erreur', success: 'Succès',
warning: 'Avertissement', info: 'Info', loading: 'Chargement...', retry: 'Réessayer',
renderError: 'Échec du rendu',
outline: 'Plan',
cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner',
regex: 'Regex', shortcuts: 'Raccourcis', imageTooLarge: 'Image trop grande ({size}Ko > {max}Ko)',
},
de: {
bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen',
h1: 'Überschrift 1', h2: 'Überschrift 2', h3: 'Überschrift 3', quote: 'Zitat', code: 'Code',
link: 'Link', image: 'Bild', table: 'Tabelle', ul: 'Aufzählung', ol: 'Nummerierte Liste',
indent: 'Einrücken', outdent: 'Ausrücken', hr: 'Trennlinie', undo: 'Rückgängig', redo: 'Wiederholen',
edit: 'Bearbeiten', split: 'Geteilt', preview: 'Vorschau', fullscreen: 'Vollbild',
fullscreenExit: 'Vollbild beenden', theme: 'Design', light: 'Hell', dark: 'Dunkel',
auto: 'Auto', warm: 'Warm', wordCount: 'Wörter', characters: 'Zeichen',
words: 'Wörter', lines: 'Zeilen', readingTime: 'Lesezeit', minutes: 'min',
placeholder: 'Markdown eingeben...', empty: 'Kein Inhalt', copied: 'Kopiert',
copyContent: 'Inhalt kopieren', copyHTML: 'HTML kopieren', copySuccess: 'Erfolgreich kopiert',
copyFailed: 'Kopieren fehlgeschlagen', clearContent: 'Inhalt löschen', clearConfirm: 'Gesamten Inhalt löschen?',
linkPlaceholder: 'Link-URL eingeben', imagePlaceholder: 'Bild-URL eingeben',
altPlaceholder: 'Alternativtext eingeben', tableRows: 'Zeilen', tableCols: 'Spalten',
confirm: 'Bestätigen', cancel: 'Abbrechen', exportMarkdown: 'Markdown exportieren',
exportHTML: 'HTML exportieren', search: 'Suchen', replace: 'Ersetzen', replaceAll: 'Alle ersetzen',
searchPlaceholder: 'Suchen', replacePlaceholder: 'Ersetzen mit',
findNext: 'Nächstes', findPrev: 'Vorheriges', matchCase: 'Groß-/Kleinschreibung',
wholeWord: 'Ganzes Wort', close: 'Schließen', open: 'Öffnen', save: 'Speichern',
saved: 'Gespeichert', saving: 'Speichern...', delete: 'Löschen', confirmDelete: 'Wirklich löschen?',
unsavedChanges: 'Ungespeicherte Änderungen', error: 'Fehler', success: 'Erfolg',
warning: 'Warnung', info: 'Info', loading: 'Laden...', retry: 'Wiederholen',
renderError: 'Rendern fehlgeschlagen',
outline: 'Gliederung',
cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen',
regex: 'Regex', shortcuts: 'Tastenkürzel', imageTooLarge: 'Bild zu groß ({size}KB > {max}KB)',
},
};
/**
* MetonaEditor Constants — default configs, themes, animations
* @module constants
* @version 0.2.0
*/
/** Default toolbar button sequence */
const DEFAULT_TOOLBAR = [
'bold', 'italic', 'strikethrough', 'code', '|',
'h1', 'h2', 'h3', '|',
'quote', 'ul', 'ol', '|',
'link', 'image', 'table', 'hr', '|',
'undo', 'redo', '|',
'edit', 'split', 'preview', 'fullscreen',
];
/** Default options */
const DEFAULTS = Object.freeze({
value: '',
placeholder: '',
mode: 'split',
height: 400,
toolbar: DEFAULT_TOOLBAR,
wordCount: true,
autofocus: false,
spellcheck: false,
historyLimit: 100,
historyDebounce: 400,
syncScroll: true,
tabSize: 2,
readOnly: false,
lineNumbers: true,
outline: false,
autoBrackets: true,
zenMode: false,
zenMaxWidth: 960,
wordWrap: true,
maxLength: 0,
theme: 'auto',
locale: 'zh-CN',
render: null,
highlight: null,
sanitize: null,
className: '',
style: {},
plugins: [],
onChange: null,
onInput: null,
onFocus: null,
onBlur: null,
onSave: null,
onModeChange: null,
onFullscreen: null,
onCreate: null,
onDestroy: null,
onLinkClick: null,
floatingToolbar: true,
});
const THEMES = {
light: {
bg: 'rgba(255, 255, 255, 0.96)',
text: '#1f2937',
border: 'rgba(0, 0, 0, 0.08)',
shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.18), 0 4px 14px -4px rgba(0, 0, 0, 0.08)',
hoverShadow: '0 14px 48px -10px rgba(0, 0, 0, 0.22), 0 6px 18px -4px rgba(0, 0, 0, 0.10)',
toolbarBg: 'rgba(248, 249, 250, 0.92)',
textareaBg: '#ffffff',
previewBg: '#ffffff',
codeBg: 'rgba(243, 244, 246, 1)',
codeText: '#1f2937',
accent: '#3b82f6',
muted: '#6b7280',
},
dark: {
bg: 'rgba(28, 32, 40, 0.94)',
text: '#e6e8eb',
border: 'rgba(255, 255, 255, 0.1)',
shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.6), 0 4px 14px -4px rgba(0, 0, 0, 0.4)',
hoverShadow: '0 14px 48px -8px rgba(0, 0, 0, 0.6), 0 6px 18px -4px rgba(0, 0, 0, 0.4)',
toolbarBg: 'rgba(22, 26, 33, 0.92)',
textareaBg: '#1c2028',
previewBg: '#1c2028',
codeBg: 'rgba(15, 18, 24, 1)',
codeText: '#e6e8eb',
accent: '#60a5fa',
muted: '#9ca3af',
},
auto: 'auto',
warm: {
bg: 'rgba(255, 251, 235, 0.96)',
text: '#78350f',
border: 'rgba(245, 158, 11, 0.2)',
shadow: '0 10px 36px -10px rgba(245, 158, 11, 0.18), 0 4px 14px -4px rgba(245, 158, 11, 0.08)',
hoverShadow: '0 14px 48px -10px rgba(245, 158, 11, 0.22), 0 6px 18px -4px rgba(245, 158, 11, 0.10)',
toolbarBg: 'rgba(254, 243, 199, 0.92)',
textareaBg: '#fffbeb',
previewBg: '#fffbeb',
codeBg: 'rgba(254, 215, 170, 0.6)',
codeText: '#78350f',
accent: '#d97706',
muted: '#a16207',
progressBg: 'rgba(217, 119, 6, 0.15)',
closeHoverBg: 'rgba(217, 119, 6, 0.2)',
},
};
const ANIMATIONS = {
slide: { enter: { transform: 'translateX(80px)', opacity: '0' }, leave: { transform: 'translateX(120%)', opacity: '0' }, duration: 400, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
fade: { enter: { opacity: '0', filter: 'blur(3px)' }, leave: { opacity: '0', filter: 'blur(3px)' }, duration: 500, easing: 'ease' },
scale: { enter: { transform: 'scale(0.55)', opacity: '0' }, leave: { transform: 'scale(0.55)', opacity: '0' }, duration: 450, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
bounce: { enter: { transform: 'translateY(-80px)', opacity: '0' }, leave: { transform: 'translateY(20px)', opacity: '0' }, duration: 650, easing: 'ease' },
flip: { enter: { transform: 'perspective(500px) rotateX(-90deg)', opacity: '0' }, leave: { transform: 'perspective(500px) rotateX(90deg)', opacity: '0' }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
rotate: { enter: { transform: 'rotate(-25deg) scale(0.6)', opacity: '0' }, leave: { transform: 'rotate(25deg) scale(0.6)', opacity: '0' }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' },
zoom: { enter: { transform: 'scale(0.1)', opacity: '0' }, leave: { transform: 'scale(0.1)', opacity: '0' }, duration: 500, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' },
};
const EDIT_MODES = ['edit', 'split', 'preview'];
const TOOLBAR_ACTIONS = [
'bold', 'italic', 'strikethrough', 'underline', 'code',
'h1', 'h2', 'h3', 'quote', 'ul', 'ol', 'indent', 'outdent', 'hr',
'link', 'image', 'table', 'undo', 'redo',
'edit', 'split', 'preview', 'fullscreen',
];
/**
* MetonaEditor Styles — CSS-in-JS injection
* @module styles
* @version 0.2.0
*/
let styleElement = null;
const generateCSS = () => {
return `:root{--md-bg:#ffffff;--md-text:#1f2937;--md-border:rgba(0,0,0,0.08);--md-shadow:0 10px 36px -10px rgba(0,0,0,0.18),0 4px 14px -4px rgba(0,0,0,0.08);--md-hover-shadow:0 14px 48px -10px rgba(0,0,0,0.22),0 6px 18px -4px rgba(0,0,0,0.10);--md-toolbar-bg:rgba(248,249,250,0.92);--md-textarea-bg:#ffffff;--md-preview-bg:#ffffff;--md-code-bg:rgba(243,244,246,1);--md-code-text:#1f2937;--md-accent:#3b82f6;--md-muted:#6b7280;--md-radius:10px;--md-font:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei","Hiragino Sans GB",sans-serif;--md-mono:"SF Mono","Cascadia Code","Consolas","Liberation Mono","Courier New",monospace}
.me-wrapper{display:flex;flex-direction:column;box-sizing:border-box;border:1px solid var(--md-border);border-radius:var(--md-radius);overflow:hidden;background:var(--md-bg);color:var(--md-text);box-shadow:var(--md-shadow);font-family:var(--md-font);font-size:14px;line-height:1.6;position:relative;transition:box-shadow .25s,border-color .25s;width:100%}
.me-wrapper:hover{box-shadow:var(--md-hover-shadow)}
.me-wrapper.me-disabled{opacity:.6;pointer-events:none}
.me-wrapper *{box-sizing:border-box}
.me-wrapper.me-fullscreen{position:fixed;top:0;left:0;right:0;bottom:0;width:100vw;height:100vh;z-index:9999;border-radius:0;border:0}
.me-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:6px 8px;background:var(--md-toolbar-bg);border-bottom:1px solid var(--md-border);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);min-height:40px}
.me-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--md-text);cursor:pointer;transition:background .15s,color .15s,transform .1s;flex-shrink:0}
.me-btn:hover{background:var(--md-code-bg);color:var(--md-accent)}
.me-btn:active{transform:scale(.92)}
.me-btn.me-active{background:var(--md-accent);color:#fff}
.me-btn:disabled{opacity:.35;cursor:not-allowed;pointer-events:none}
.me-btn:focus-visible{outline:2px solid var(--md-accent);outline-offset:2px}
.me-btn:focus:not(:focus-visible){outline:none}
.me-btn svg{width:17px;height:17px;display:block}
.me-btn span{font-size:12px;font-weight:500}
.me-toolbar-sep{display:inline-block;width:1px;height:20px;background:var(--md-border);margin:0 4px;flex-shrink:0}
.me-toolbar-group{display:inline-flex;gap:2px;margin-left:auto;padding-left:6px;border-left:1px solid var(--md-border)}
.me-toolbar-group .me-btn{width:auto;padding:0 8px}
.me-body{display:flex;flex:1;min-height:0;position:relative}
.me-editor-pane,.me-preview-pane{flex:1 1 50%;min-width:0;overflow:hidden;position:relative}
.me-editor-inner{display:flex;height:100%;overflow:hidden;position:relative}
.me-gutter{flex:0 0 auto;min-width:36px;padding:16px 8px 16px 6px;overflow:hidden;background:var(--md-code-bg);border-right:1px solid var(--md-border);color:var(--md-muted);font-family:var(--md-mono);font-size:13.5px;line-height:1.7;text-align:right;user-select:none;white-space:pre}
.me-gutter-line{padding-right:4px}
.me-gutter-active{color:var(--md-accent);font-weight:600;background:rgba(59,130,246,.08);border-radius:3px}
.me-divider{flex:0 0 1px;background:var(--md-border);cursor:col-resize;position:relative}
.me-divider::after{content:'';position:absolute;top:0;bottom:0;left:-3px;right:-3px}
.me-divider:hover{background:var(--md-accent)}
.me-textarea{width:100%;height:100%;padding:16px 18px;border:0;outline:0;resize:none;background:var(--md-textarea-bg);color:var(--md-text);font-family:var(--md-mono);font-size:13.5px;line-height:1.7;display:block;white-space:pre-wrap;word-wrap:break-word;position:relative;z-index:1}
.me-textarea::placeholder{color:var(--md-muted);opacity:.7}
.me-textarea:focus{outline:0}
.me-preview-pane{overflow:auto;background:var(--md-preview-bg)}
.me-preview{padding:18px 22px;max-width:100%;word-wrap:break-word;overflow-wrap:break-word}
.me-body.me-mode-edit .me-preview-pane,.me-body.me-mode-edit .me-divider{display:none}
.me-body.me-mode-edit .me-editor-pane{flex:1 1 100%}
.me-body.me-mode-preview .me-editor-pane,.me-body.me-mode-preview .me-divider{display:none}
.me-body.me-mode-preview .me-preview-pane{flex:1 1 100%}
.me-preview>:first-child{margin-top:0}
.me-preview>:last-child{margin-bottom:0}
.me-preview h1,.me-preview h2,.me-preview h3,.me-preview h4,.me-preview h5,.me-preview h6{margin:1.4em 0 .6em;font-weight:650;line-height:1.3}
.me-preview h1{font-size:1.9em;padding-bottom:.3em;border-bottom:1px solid var(--md-border)}
.me-preview h2{font-size:1.55em;padding-bottom:.3em;border-bottom:1px solid var(--md-border)}
.me-preview h3{font-size:1.3em}
.me-preview h4{font-size:1.12em}
.me-preview h5{font-size:1em}
.me-preview h6{font-size:.9em;color:var(--md-muted)}
.me-preview p{margin:.7em 0}
.me-preview a{color:var(--md-accent);text-decoration:none}
.me-preview a:hover{text-decoration:underline}
.me-preview strong{font-weight:650}
.me-preview em{font-style:italic}
.me-preview del{text-decoration:line-through;opacity:.75}
.me-preview ul,.me-preview ol{margin:.6em 0;padding-left:1.6em}
.me-preview li{margin:.25em 0}
.me-preview li.me-task-item{list-style:none;margin-left:-1.4em}
.me-preview li.me-task-item input{margin-right:.5em;vertical-align:middle}
.me-preview blockquote{margin:.8em 0;padding:.4em 1em;border-left:3px solid var(--md-accent);background:var(--md-code-bg);border-radius:0 6px 6px 0;color:var(--md-text)}
.me-preview blockquote>:first-child{margin-top:0}
.me-preview blockquote>:last-child{margin-bottom:0}
.me-preview hr{border:0;height:1px;background:var(--md-border);margin:1.6em 0}
.me-preview code{font-family:var(--md-mono);font-size:.88em;padding:.15em .4em;background:var(--md-code-bg);color:var(--md-code-text);border-radius:4px}
.me-preview pre{margin:.9em 0;padding:14px 16px;background:var(--md-code-bg);border-radius:8px;overflow-x:auto;border:1px solid var(--md-border)}
.me-code-title{font-family:var(--md-mono);font-size:.85em;padding:8px 14px;background:var(--md-border);border-radius:8px 8px 0 0;color:var(--md-text);margin:.9em 0 -0.9em;font-weight:600}
.me-code-title+pre{margin-top:0;border-radius:0 0 8px 8px;border-top:none}
.me-preview pre code{padding:0;background:transparent;color:var(--md-code-text);font-size:.9em;line-height:1.6;border-radius:0}
.me-hl-keyword{color:#c678dd}
.me-hl-string{color:#98c379}
.me-hl-comment{color:#7f848e;font-style:italic}
.me-hl-number{color:#d19a66}
.me-hl-builtin{color:#e5c07b}
.me-hl-function{color:#61afef}
.me-theme-light .me-hl-keyword{color:#9c4cc0}
.me-theme-light .me-hl-string{color:#4f9d4f}
.me-theme-light .me-hl-comment{color:#8b8b8b}
.me-theme-light .me-hl-number{color:#b06a22}
.me-theme-light .me-hl-builtin{color:#9a7d0f}
.me-theme-light .me-hl-function{color:#2a7fd4}
.me-preview img{max-width:100%;height:auto;border-radius:6px;vertical-align:middle}
.me-table-wrap{overflow-x:auto;margin:.9em 0}
.me-preview table{border-collapse:collapse;width:100%;font-size:.93em;display:block}
.me-preview th,.me-preview td{border:1px solid var(--md-border);padding:7px 12px;text-align:left}
.me-preview th{background:var(--md-code-bg);font-weight:600}
.me-preview tr:nth-child(even) td{background:var(--md-code-bg)}
.me-statusbar{display:flex;align-items:center;justify-content:flex-end;gap:14px;padding:4px 12px;border-top:1px solid var(--md-border);background:var(--md-toolbar-bg);color:var(--md-muted);font-size:12px;min-height:26px}
.me-statusbar:empty{display:none}
.me-wrapper:focus-within{border-color:var(--md-accent)}
.me-textarea:focus-visible{outline:0}
@media(prefers-reduced-motion:reduce){.me-wrapper,.me-btn,.me-textarea{transition:none!important}}
.me-wrapper.me-fullscreen .me-preview,.me-wrapper.me-fullscreen .me-textarea{font-size:15px}
.me-wrapper.me-readonly .me-textarea{background:var(--md-code-bg);cursor:default;opacity:.88}
.me-wrapper.me-readonly .me-toolbar{opacity:.75}
.me-wrapper.me-readonly .me-btn:disabled{opacity:.3;cursor:not-allowed;pointer-events:none}
.me-preview mark{background:rgba(250,204,21,.3);color:inherit;padding:.1em .2em;border-radius:3px}
.me-preview sup{font-size:.75em;vertical-align:super;line-height:1}
.me-preview sub{font-size:.75em;vertical-align:sub;line-height:1}
.me-preview .me-math-block{display:block;margin:1.2em 0;padding:12px 16px;background:var(--md-code-bg);border-radius:8px;overflow-x:auto;font-family:var(--md-mono);font-size:.95em;text-align:center}
.me-preview .me-math-inline{font-family:var(--md-mono);font-size:.95em;padding:.05em .2em}
.me-preview dl{margin:.8em 0}
.me-preview dt{font-weight:650;margin:.6em 0 .2em}
.me-preview dd{margin:0 0 .3em 1.6em;color:var(--md-text)}
.me-preview .me-footnote-ref a{font-size:.75em;vertical-align:super;text-decoration:none;color:var(--md-accent)}
.me-preview .me-footnotes{margin-top:2em;border-top:1px solid var(--md-border);padding-top:.8em;font-size:.9em;color:var(--md-muted)}
.me-preview .me-footnotes hr{display:none}
.me-preview .me-footnotes ol{padding-left:1.2em}
.me-preview .me-footnote-item{margin:.3em 0}
.me-preview .me-footnote-backref{text-decoration:none;color:var(--md-accent);margin-right:.4em}
.me-toast{position:absolute;bottom:16px;right:16px;z-index:30;display:flex;align-items:center;gap:8px;padding:10px 16px;border-radius:8px;font-size:13px;pointer-events:auto;cursor:pointer;opacity:0;transform:translateY(12px);transition:opacity .3s,transform .3s;box-shadow:0 6px 20px rgba(0,0,0,.15);max-width:360px}
.me-toast.me-toast-enter{opacity:1;transform:translateY(0)}
.me-toast.me-toast-leave{opacity:0;transform:translateY(12px)}
.me-toast-success{background:#10b981;color:#fff}
.me-toast-error{background:#ef4444;color:#fff}
.me-toast-warning{background:#f59e0b;color:#fff}
.me-toast-info{background:var(--md-accent,#3b82f6);color:#fff}
.me-toast-icon{font-weight:700;font-size:16px;flex-shrink:0}
.me-toast-msg{line-height:1.4}
.me-context-menu{position:fixed;z-index:40;min-width:180px;padding:4px 0;background:var(--md-bg);border:1px solid var(--md-border);border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.18);font-size:13px}
.me-context-menu-item{display:flex;align-items:center;justify-content:space-between;padding:6px 14px;cursor:pointer;color:var(--md-text);transition:background .1s}
.me-context-menu-item:hover{background:var(--md-code-bg);color:var(--md-accent)}
.me-context-menu-sep{height:1px;background:var(--md-border);margin:4px 0}
.me-context-menu-shortcut{color:var(--md-muted);font-size:11px;margin-left:24px}
.me-context-menu-item.me-disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
.me-outline{position:absolute;top:0;right:0;width:220px;height:100%;overflow-y:auto;background:var(--md-preview-bg);border-left:1px solid var(--md-border);padding:12px 14px;font-size:13px;z-index:15}
.me-outline-title{font-weight:650;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--md-border);color:var(--md-text)}
.me-outline ul{list-style:none;padding:0;margin:0}
.me-outline li{margin:2px 0}
.me-outline a{color:var(--md-muted);text-decoration:none;display:block;padding:2px 6px;border-radius:4px;transition:background .1s,color .1s}
.me-outline a:hover{background:var(--md-code-bg);color:var(--md-accent)}
.me-outline a.me-outline-active{background:var(--md-accent);color:#fff;font-weight:600}
.me-outline-l1 a{font-weight:600;color:var(--md-text)}
.me-outline-l2 a{padding-left:12px}
.me-outline-l3 a{padding-left:20px;font-size:12px}
.me-outline-l4 a{padding-left:28px;font-size:12px}
.me-wrapper.me-zen .me-body{max-width:var(--md-zen-max-width,960px);margin:0 auto}
.me-wrapper.me-zen .me-textarea{font-size:15px;line-height:1.8}
.me-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media print{.me-wrapper{border:0!important;box-shadow:none!important}
.me-toolbar,.me-statusbar,.me-divider{display:none!important}
.me-body.me-mode-split .me-editor-pane{display:none}
.me-body.me-mode-split .me-preview-pane{flex:1 1 100%!important}
.me-preview{padding:0!important;font-size:11pt}
.me-wrapper.me-fullscreen{position:static!important;width:auto!important;height:auto!important}}
[dir=rtl] .me-gutter{border-right:none;border-left:1px solid var(--md-border);text-align:left}
[dir=rtl] .me-gutter-line{padding-right:0;padding-left:4px}
[dir=rtl] .me-toolbar-group{margin-left:0;margin-right:auto;padding-left:0;padding-right:6px;border-left:none;border-right:1px solid var(--md-border)}
[dir=rtl] .me-outline{right:auto;left:0;border-left:none;border-right:1px solid var(--md-border)}
[dir=rtl] .me-outline-l2 a{padding-left:0;padding-right:12px}
[dir=rtl] .me-outline-l3 a{padding-left:0;padding-right:20px}
[dir=rtl] .me-outline-l4 a{padding-left:0;padding-right:28px}
[dir=rtl] .me-preview blockquote{border-left:none;border-right:3px solid var(--md-accent);border-radius:6px 0 0 6px}
[dir=rtl] .me-preview ul,[dir=rtl] .me-preview ol{padding-left:0;padding-right:1.6em}
[dir=rtl] .me-preview li.me-task-item{margin-left:0;margin-right:-1.4em}
[dir=rtl] .me-preview dd{margin-left:0;margin-right:1.6em}
[dir=rtl] .me-preview .me-footnotes ol{padding-left:0;padding-right:1.2em}
[dir=rtl] .me-preview .me-footnote-backref{margin-right:0;margin-left:.4em}
[dir=rtl] .me-search{right:auto;left:12px}
[dir=rtl] .me-toast{right:auto;left:16px}
[dir=rtl] .me-context-menu-shortcut{margin-left:0;margin-right:24px}
[dir=rtl] .me-statusbar{justify-content:flex-start}`;
};
const injectStyles = () => {
if (typeof document === 'undefined')
return;
if (styleElement && document.getElementById('metona-editor-styles'))
return;
const css = generateCSS();
styleElement = document.createElement('style');
styleElement.id = 'metona-editor-styles';
styleElement.textContent = css;
document.head.appendChild(styleElement);
};
/**
* MetonaEditor i18n — internationalization
* @module i18n
* @version 0.2.0
*/
let currentLocale = 'zh-CN';
let localeListeners = new Set();
let fallbackLocale = 'zh-CN';
const pluralRules = {
zh: () => 'other',
en: (n) => n === 1 ? 'one' : 'other',
ru: (n) => {
const m = n % 10, h = n % 100;
if (m === 1 && h !== 11)
return 'one';
if (m >= 2 && m <= 4 && !(h >= 12 && h <= 14))
return 'few';
if (m === 0 || (m >= 5 && m <= 9) || (h >= 11 && h <= 14))
return 'many';
return 'other';
},
};
const getPluralForm = (locale, count) => {
const lang = locale.split('-')[0].toLowerCase();
const rule = pluralRules[lang] || pluralRules.en;
return rule(Math.abs(count));
};
const getCurrentLocale = () => currentLocale;
const setCurrentLocale = (locale) => {
let target = locale;
if (!LOCALES[target]) {
console.warn(`Locale "${target}" not found, falling back to "${fallbackLocale}"`);
target = fallbackLocale;
}
currentLocale = target;
notifyLocaleListeners(target);
saveLocale(target);
};
const t = (key, params = {}, locale) => {
const loc = locale || currentLocale;
let translation = getTranslation(loc, key);
if (translation && typeof translation === 'object' && params.count !== undefined) {
const form = getPluralForm(loc, params.count);
translation = translation[form] || translation.other || key;
}
if (typeof translation === 'string')
return interpolate(translation, params);
if (loc !== fallbackLocale) {
let fb = getTranslation(fallbackLocale, key);
if (fb && typeof fb === 'object' && params.count !== undefined) {
const form = getPluralForm(fallbackLocale, params.count);
fb = fb[form] || fb.other;
}
if (typeof fb === 'string')
return interpolate(fb, params);
}
console.warn(`Translation missing for key "${key}" in locale "${loc}"`);
return key;
};
const getTranslation = (locale, key) => {
const localeData = LOCALES[locale];
if (!localeData)
return undefined;
const keys = key.split('.');
let result = localeData;
for (const k of keys) {
if (result && typeof result === 'object' && k in result) {
result = result[k];
}
else
return undefined;
}
return result;
};
const interpolate = (str, params) => {
return str.replace(/\{(\w+)\}/g, (_match, key) => params[key] !== undefined ? String(params[key]) : _match);
};
const hasTranslation = (key) => {
return getTranslation(currentLocale, key) !== undefined || getTranslation(fallbackLocale, key) !== undefined;
};
const getTranslations = (locale) => LOCALES[locale] || {};
const addTranslations = (locale, translations) => {
if (!LOCALES[locale])
LOCALES[locale] = {};
deepMergeI18n(LOCALES[locale], translations);
};
const loadRemote = async (url, locale) => {
try {
const res = await fetch(url);
if (!res.ok)
throw new Error(`HTTP ${res.status}`);
const data = await res.json();
addTranslations(locale, data);
return true;
}
catch (e) {
console.error(`MeEditor: failed to load locale "${locale}" from ${url}`, e);
return false;
}
};
const deepMergeI18n = (target, source) => {
for (const key in source) {
if (source[key] instanceof Object && key in target && target[key] instanceof Object && !Array.isArray(source[key])) {
deepMergeI18n(target[key], source[key]);
}
else {
target[key] = source[key];
}
}
return target;
};
const getSupportedLocales = () => Object.keys(LOCALES);
const isLocaleSupported = (locale) => locale in LOCALES;
const getLocaleName = (locale) => {
const names = {
'zh-CN': '简体中文', 'zh-TW': '繁體中文', 'en-US': 'English (US)', 'en-GB': 'English (UK)',
ja: '日本語', ko: '한국어', fr: 'Français', de: 'Deutsch', es: 'Español', pt: 'Português',
ru: 'Русский', ar: 'العربية', hi: 'हिन्दी', th: 'ไทย',
vi: 'Tiếng Việt', id: 'Bahasa Indonesia', tr: 'Türkçe',
it: 'Italiano', nl: 'Nederlands', pl: 'Polski',
};
return names[locale] || locale;
};
const getLocaleDirection = (locale) => {
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug', 'dv', 'ku', 'syr'];
const short = locale.split('-')[0].toLowerCase();
return rtlLocales.includes(short) ? 'rtl' : 'ltr';
};
const formatNumber = (number, options = {}) => {
try {
return new Intl.NumberFormat(currentLocale, options).format(number);
}
catch (_) {
return String(number);
}
};
const formatCurrency = (amount, currency = 'USD', options = {}) => {
try {
return new Intl.NumberFormat(currentLocale, { style: 'currency', currency, ...options }).format(amount);
}
catch (_) {
return String(amount);
}
};
const formatDate = (date, options = {}) => {
try {
const d = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(currentLocale, options).format(d);
}
catch (_) {
return String(date);
}
};
const addLocaleListener = (fn) => {
localeListeners.add(fn);
return () => { localeListeners.delete(fn); };
};
const removeLocaleListener = (fn) => { localeListeners.delete(fn); };
const clearLocaleListeners = () => { localeListeners.clear(); };
const notifyLocaleListeners = (locale) => {
localeListeners.forEach((fn) => {
try {
fn(locale);
}
catch (e) {
console.error('Locale listener error:', e);
}
});
};
const saveLocale = (locale) => {
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem('metona-editor-locale', locale);
}
catch (_) { }
}
};
const loadLocale = () => {
if (typeof localStorage !== 'undefined') {
try {
return localStorage.getItem('metona-editor-locale') || getDefaultLocale();
}
catch (_) { }
}
return getDefaultLocale();
};
const getDefaultLocale = () => {
if (typeof navigator !== 'undefined') {
const browserLocale = navigator.language || navigator.userLanguage;
if (browserLocale && isLocaleSupported(browserLocale))
return browserLocale;
const short = browserLocale?.split('-')[0];
if (short && isLocaleSupported(short))
return short;
}
return fallbackLocale;
};
const initI18n = () => {
const saved = loadLocale();
setCurrentLocale(saved);
};
const switchLocale = (locale) => { setCurrentLocale(locale); };
const createInstanceI18n = (editor) => {
let instanceLocale = editor.config?.locale || currentLocale || 'zh-CN';
const instanceT = (key, params = {}) => t(key, params, instanceLocale);
const set = (locale) => {
if (!locale || instanceLocale === locale)
return instanceLocale;
instanceLocale = locale;
if (editor.el) {
editor.el.setAttribute('lang', locale);
editor.el.setAttribute('dir', getLocaleDirection(locale));
}
if (editor.textarea) {
editor.textarea.placeholder = instanceT('placeholder') || '';
editor.textarea.setAttribute('aria-label', instanceT('edit') || '');
editor.textarea.setAttribute('dir', getLocaleDirection(locale));
}
if (typeof editor._emit === 'function') {
editor._emit('localeChange', { locale, direction: getLocaleDirection(locale) });
}
if (editor.toolbarEl) {
editor.toolbarEl.querySelectorAll('.me-btn').forEach((btn) => {
const action = btn.dataset.action || btn.dataset.mode;
if (action) {
const label = instanceT(action);
if (label && label !== action) {
btn.title = label;
btn.setAttribute('aria-label', label);
}
}
});
}
return instanceLocale;
};
return {
set, get: () => instanceLocale, getDirection: () => getLocaleDirection(instanceLocale), t: instanceT,
formatNumber: (n, opts) => { try {
return new Intl.NumberFormat(instanceLocale, opts).format(n);
}
catch (_) {
return String(n);
} },
formatDate: (d, opts) => { try {
return new Intl.DateTimeFormat(instanceLocale, opts).format(d instanceof Date ? d : new Date(d));
}
catch (_) {
return String(d);
} },
};
};
const createI18nManager = () => ({
t, getCurrentLocale, setCurrentLocale, switchLocale,
getFallbackLocale: () => fallbackLocale, setFallbackLocale: (locale) => { fallbackLocale = locale; },
hasTranslation, getTranslations, addTranslations, loadRemote,
getSupportedLocales, isLocaleSupported, getLocaleName, getLocaleDirection,
formatNumber, formatCurrency, formatDate, addLocaleListener, removeLocaleListener, clearLocaleListeners,
initI18n, saveLocale, loadLocale, getDefaultLocale, createInstanceI18n,
});
({
'zh-CN': { name: '简体中文', nativeName: '简体中文', direction: 'ltr', translations: LOCALES['zh-CN'] },
'en-US': { name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', translations: LOCALES['en-US'] },
ja: { name: '日本語', nativeName: '日本語', direction: 'ltr', translations: LOCALES['ja'] },
ko: { name: '한국어', nativeName: '한국어', direction: 'ltr', translations: LOCALES['ko'] },
fr: { name: 'Français', nativeName: 'Français', direction: 'ltr', translations: LOCALES['fr'] },
de: { name: 'Deutsch', nativeName: 'Deutsch', direction: 'ltr', translations: LOCALES['de'] },
});
const i18nUtils = createI18nManager();
/**
* MetonaEditor Parser — lightweight Markdown parser (TypeScript)
* @module parser
* @version 0.2.0
*/
// ============ Precompiled regex ============
const RE_EMPTY = /^\s*$/;
const RE_ATX = /^(#{1,6})\s+(.+?)(?:\s+#{1,6})?\s*$/;
const RE_SETEXT_H1 = /^={3,}\s*$/;
const RE_SETEXT_H2 = /^-{3,}\s*$/;
const RE_FENCE_START = /^(\s{0,3})(`{3,}|~{3,})\s*([\w+#.-]*)(\s+[^\n]*?)?\s*$/;
const RE_HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/;
const RE_QUOTE = /^\s{0,3}>\s?/;
const RE_UL = /^(\s*)([-*+])\s/;
const RE_OL = /^(\s*)(\d+)\.\s/;
const RE_TASK = /^\[([ xX])\]\s+(.*)$/;
const RE_TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
const RE_INDENT_CODE = /^( {4}|\t)/;
const RE_DEF_LIST = /^:\s+/;
const RE_FOOTNOTE_DEF = /^\[\^([^\]]+)\]:\s*/;
// ============ Emoji map ============
const EMOJI_MAP = {
smile: '😊', grinning: '😀', joy: '😂', rofl: '🤣', wink: '😉', blush: '😊', innocent: '😇',
heart_eyes: '😍', kissing_heart: '😘', yum: '😋', stuck_out_tongue: '😛', sunglasses: '😎',
smirk: '😏', unamused: '😒', disappointed: '😞', worried: '😟', confused: '😕',
cry: '😢', sob: '😭', scream: '😱', angry: '😠', rage: '💢', triumph: '😤', sleepy: '😪',
dizzy_face: '😵', zipper_mouth: '🤐', nerd: '🤓', thinking: '🤔', rolling_eyes: '🙄',
expressionless: '😑', thumbsup: '👍', thumbsdown: '👎', clap: '👏', pray: '🙏', muscle: '💪',
ok: '👌', point_up: '👆', point_down: '👇', point_left: '👈', point_right: '👉',
raised_hands: '🙌', wave: '👋', punch: '👊', crossed_fingers: '🤞',
heart: '❤️', broken_heart: '💔', star: '⭐', star2: '🌟', fire: '🔥', rocket: '🚀',
check: '✅', cross: '❌', warning: '⚠️', info: 'ℹ️', question: '❓', exclamation: '❗',
bangbang: '‼️', grey_exclamation: '❕', bulb: '💡', book: '📖', memo: '📝', pin: '📌',
link: '🔗', lock: '🔒', unlock: '🔓', key: '🔑', hammer: '🔨', wrench: '🔧',
gear: '⚙️', tools: '🛠️', magnet: '🧲', zap: '⚡', cloud: '☁️',
sun: '☀️', moon: '🌙', rain: '🌧️', snow: '❄️', umbrella: '☂️', rainbow: '🌈',
tornado: '🌪️', fog: '🌫️', droplet: '💧',
coffee: '☕', pizza: '🍕', cake: '🎂', beer: '🍺', wine: '🍷',
hamburger: '🍔', fries: '🍟', apple: '🍎', banana: '🍌', grapes: '🍇',
watermelon: '🍉', strawberry: '🍓', peach: '🍑', cherry: '🍒', taco: '🌮',
tada: '🎉', gift: '🎁', crown: '👑', gem: '💎', ring: '💍',
confetti: '🎊', balloon: '🎈', ribbon: '🎀', medal: '🏅', trophy: '🏆',
eye: '👁️', ear: '👂', nose: '👃', tongue: '👅', lips: '👄',
brain: '🧠', speech: '💬', thought: '💭', anger: '💢', sweat: '💦',
one: '1️⃣', two: '2️⃣', three: '3️⃣', four: '4️⃣', five: '5️⃣',
six: '6️⃣', seven: '7️⃣', eight: '8️⃣', nine: '9️⃣', zero: '0️⃣',
arrow_up: '⬆️', arrow_down: '⬇️', arrow_left: '⬅️', arrow_right: '➡️',
arrow_upper_right: '↗️', arrow_lower_right: '↘️', arrow_lower_left: '↙️', arrow_upper_left: '↖️',
computer: '💻', phone: '📱', battery: '🔋', electric_plug: '🔌',
copyright: '©️', registered: '®️', tm: '™️', hundred: '💯', boom: '💥', dash: '💨',
hole: '🕳️', bomb: '💣', email: '📧', phone2: '📞', clock: '🕐', hourglass: '⏳',
calendar: '📅', money: '💰', shopping: '🛒', package: '📦', mailbox: '📫',
art: '🎨', music: '🎵', movie: '🎬', game: '🎮', sport: '⚽',
earth: '🌍', house: '🏠', car: '🚗', airplane: '✈️', ship: '🚢',
};
// ============ Render cache ============
const renderCache = new Map();
const MAX_CACHE_SIZE = 300;
/**
* 引用链接 `[x][r]` 的解析依赖 env.refs,若仅以文本为缓存 key,
* 不同文档中相同文本会命中彼此的缓存导致链接串数据。
* 故缓存 key 附加 refs 指纹(parseMarkdown 预计算 `_refsFp`,
* 直接调用 renderTokens 时兜底现场计算)。
*/
const refsFingerprint = (env) => {
let fp = env._refsFp;
if (fp === undefined) {
const keys = env.refs ? Object.keys(env.refs) : [];
fp = keys.length > 0 ? JSON.stringify(env.refs) : '';
}
return fp || '';
};
const cachedRenderInline = (text, env) => {
if (!text)
return '';
if (!env.highlight && text.length < 600) {
const fp = refsFingerprint(env);
const key = fp ? text + '\u0001' + fp : text;
const cached = renderCache.get(key);
if (cached !== undefined)
return cached;
const result = renderInline(text, env);
if (renderCache.size >= MAX_CACHE_SIZE) {
const firstKey = renderCache.keys().next().value;
renderCache.delete(firstKey);
}
renderCache.set(key, result);
return result;
}
return renderInline(text, env);
};
const clearRenderCache = () => { renderCache.clear(); };
// ============ Security utils ============
const safeUrl = (url) => {
if (!url)
return '';
const u = String(url).trim();
if (/^(javascript|vbscript|file):/i.test(u))
return '';
if (/^data:/i.test(u)) {
if (!/^data:image\//i.test(u))
return '';
if (u.length > 500000)
return '';
}
return u;
};
const slugify = (text) => {
let slug = String(text).normalize('NFKC').toLowerCase()
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf\s-]/g, '')
.trim().replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
return slug || 'heading';
};
/** Restore backslash-escaped punctuation in title text: \" -> " */
const unescapePunct = (text) => text.replace(/\\([!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])/g, '$1');
/**
* Safe double-quoted attribute value. Inline-sourced text is already HTML-escaped
* (entities are safe inside quoted attributes and decode correctly), so only the
* quote character that would terminate the attribute needs escaping.
*/
const attrSafe = (v) => v.replace(/"/g, '"').replace(/\r?\n/g, ' ');
/**
* Same as `attrSafe` but also restores backslash-escaped punctuation first
* (used for title attributes where \" is a markdown escape).
*/
const titleAttr = (title) => {
if (!title)
return '';
return ` title="${unescapePunct(title).replace(/"/g, '"').replace(/\r?\n/g, ' ')}"`;
};
// ============ Block handler registry ============
const blockHandlers = [];
const registerBlockHandler = (handler) => {
blockHandlers.push(handler);
blockHandlers.sort((a, b) => a.priority - b.priority);
};
const isBlockStart = (line) => {
if (RE_EMPTY.test(line) || RE_FENCE_START.test(line) || RE_ATX.test(line))
return true;
if (RE_HR.test(line) || RE_QUOTE.test(line) || RE_UL.test(line) || RE_OL.test(line))
return true;
if (RE_FOOTNOTE_DEF.test(line) || RE_DEF_LIST.test(line))
return true;
if (/^\$\$/.test(line))
return true;
return false;
};
// Register all built-in block handlers
registerBlockHandler({ name: 'refDef', priority: 0.1,
test: (line) => {
// Must not match footnote definitions: [^id]: text
if (/^\[\^/.test(line))
return null;
const m = line.match(/^\[([^\]]+)\]:\s*(?:<(\S+)>|(\S+))(?:\s+['"\(](.*?)['"\)])?\s*$/);
return m ? m : null;
},
parse: (_lines, i, match, _tokens, _footnotes, refs) => {
const refId = match[1].toLowerCase();
const url = match[2] || match[3] || '';
const title = match[4] || '';
if (url && refs) {
refs[refId] = JSON.stringify({ url, title });
}
return { token: null, newIndex: i + 1 };
},
});
registerBlockHandler({ name: 'blank', priority: 0,
test: (line) => RE_EMPTY.test(line) ? true : null,
parse: (_lines, i) => ({ token: null, newIndex: i + 1 }),
});
registerBlockHandler({ name: 'fencedCode', priority: 1,
test: (line) => {
const m = line.match(RE_FENCE_START);
if (!m)
return null;
// Reject fences where the info string contains backticks (false positives like ``` `code` ```)
const rest = (m[4] || '');
if (rest.includes('`'))
return null;
return m;
},
parse: (lines, i, match) => {
const fenceChar = match[2][0];
const fenceLen = match[2].length;
const lang = match[3] || '';
const rest = (match[4] || '').trim();
// Parse info string: first word is lang, rest are key=value or standalone flags
const infoParts = rest ? rest.split(/\s+/).filter(Boolean) : [];
const attrs = {};
for (let p = 0; p < infoParts.length; p++) {
const kv = infoParts[p].split('=');
if (kv.length === 2)
attrs[kv[0]] = kv[1];
else
attrs[infoParts[p]] = 'true';
}
const codeLines = [];
let j = i + 1;
while (j < lines.length) {
const cm = lines[j].match(/^(\s{0,3})(`{3,}|~{3,})\s*$/);
if (cm && cm[2][0] === fenceChar && cm[2].length >= fenceLen)
break;
codeLines.push(lines[j]);
j++;
}
if (j < lines.length)
j++;
return { token: { type: 'code', lang, content: codeLines.join('\n'), attrs }, newIndex: j };
},
});
registerBlockHandler({ name: 'indentedCode', priority: 2,
test: (line) => {
if (!RE_INDENT_CODE.test(line))
return null;
if (RE_ATX.test(line) || RE_QUOTE.test(line) || RE_UL.test(line) || RE_OL.test(line))
return null;
return true;
},
parse: (lines, i) => {
const codeLines = [];
while (i < lines.length && RE_INDENT_CODE.test(lines[i])) {
codeLines.push(lines[i].replace(/^( {4}|\t)/, ''));
i++;
}
if (codeLines.length)
return { token: { type: 'code', lang: '', content: codeLines.join('\n'), indent: true }, newIndex: i };
return { token: null, newIndex: i };
},
});
registerBlockHandler({ name: 'atxHeading', priority: 3,
test: (line) => line.match(RE_ATX),
parse: (_lines, i, match) => ({
token: { type: 'heading', level: match[1].length, text: match[2] }, newIndex: i + 1,
}),
});
registerBlockHandler({ name: 'setextH1', priority: 4,
test: (line, lines, i) => {
if (i + 1 >= lines.length)
return null;
if (!RE_SETEXT_H1.test(lines[i + 1]))
return null;
if (RE_EMPTY.test(line) || RE_UL.test(line) || RE_OL.test(line) || RE_QUOTE.test(line) || /^\s*`{3,}/.test(line))
return null;
return true;
},
parse: (lines, i) => ({ token: { type: 'heading', level: 1, text: lines[i] }, newIndex: i + 2 }),
});
registerBlockHandler({ name: 'setextH2', priority: 5,
test: (line, lines, i) => {
if (i + 1 >= lines.length)
return null;
if (!RE_SETEXT_H2.test(lines[i + 1]))
return null;
if (RE_EMPTY.test(line) || RE_UL.test(line) || RE_OL.test(line) || RE_QUOTE.test(line) || /^\s*`{3,}/.test(line))
return null;
return true;
},
parse: (lines, i) => ({ token: { type: 'heading', level: 2, text: lines[i] }, newIndex: i + 2 }),
});
registerBlockHandler({ name: 'hr', priority: 6,
test: (line) => RE_HR.test(line) ? true : null,
parse: (_lines, i) => ({ token: { type: 'hr' }, newIndex: i + 1 }),
});
registerBlockHandler({ name: 'blockquote', priority: 7,
test: (line) => RE_QUOTE.test(line) ? true : null,
parse: (lines, i) => {
const quoteLines = [];
while (i < lines.length && RE_QUOTE.test(lines[i])) {
quoteLines.push(lines[i].replace(RE_QUOTE, ''));
i++;
}
return { token: { type: 'quote', content: quoteLines.join('\n') }, newIndex: i };
},
});
registerBlockHandler({ name: 'table', priority: 8,
test: (line, lines, i) => {
if (!/\|/.test(line) || i + 1 >= lines.length)
return null;
if (!isTableSeparator(lines[i + 1]))
return null;
return { header: line, align: parseAlign(lines[i + 1]) };
},
parse: (lines, i, match) => {
let j = i + 2;
const rows = [];
while (j < lines.length && /\|/.test(lines[j]) && !RE_EMPTY.test(lines[j])) {
rows.push(lines[j]);
j++;
}
return { token: { type: 'table', header: match.header, rows, align: match.align }, newIndex: j };
},
});
registerBlockHandler({ name: 'ul', priority: 9,
test: (line) => line.match(RE_UL),
parse: (lines, i, match) => {
const { items, endIdx } = parseList(lines, i, match[2], match[1].length, false);
return { token: { type: 'ul', items }, newIndex: endIdx };
},
});
registerBlockHandler({ name: 'ol', priority: 10,
test: (line) => line.match(RE_OL),
parse: (lines, i, match) => {
const start = parseInt(match[2], 10);
const { items, endIdx } = parseList(lines, i, match[2], match[1].length, true);
return { token: { type: 'ol', items, start }, newIndex: endIdx };
},
});
registerBlockHandler({ name: 'footnoteDef', priority: 11,
test: (line) => line.match(RE_FOOTNOTE_DEF),
parse: (lines, i, match, _tokens, footnotes) => {
const fnId = match[1];
const fnContent = lines[i].slice(match[0].length);
const fnLines = [fnContent];
let j = i + 1;
while (j < lines.length && !RE_EMPTY.test(lines[j]) && !isBlockStart(lines[j])) {
fnLines.push(lines[j]);
j++;
}
footnotes[fnId] = fnLines.join(' ');
return { token: null, newIndex: j };
},
});
registerBlockHandler({ name: 'defList', priority: 12,
test: (line) => RE_DEF_LIST.test(line) ? true : null,
parse: (lines, i, _match, tokens) => {
const term = (tokens.length > 0 && tokens[tokens.length - 1].type === 'paragraph') ? tokens.pop().text : '';
const defs = [lines[i].replace(RE_DEF_LIST, '')];
let j = i + 1;
while (j < lines.length && RE_DEF_LIST.test(lines[j])) {
defs.push(lines[j].replace(RE_DEF_LIST, ''));
j++;
}
return { token: { type: 'defList', term, defs }, newIndex: j };
},
});
registerBlockHandler({ name: 'mathBlock', priority: 13,
test: (line) => /^\$\$/.test(line) ? true : null,
parse: (lines, i) => {
const single = lines[i].match(/^\$\$([\s\S]+?)\$\$\s*$/);
if (single)
return { token: { type: 'mathBlock', content: single[1] }, newIndex: i + 1 };
const mathLines = [lines[i].replace(/^\$\$/, '')];
let j = i + 1;
while (j < lines.length && !/\$\$/.test(lines[j])) {
mathLines.push(lines[j]);
j++;
}
if (j < lines.length) {
mathLines.push(lines[j].replace(/\$\$\s*$/, ''));
j++;
}
return { token: { type: 'mathBlock', content: mathLines.join('\n') }, newIndex: j };
},
});
// ============ Block parsing ============
const parseTokens = (md) => {
if (md == null)
return { tokens: [], footnotes: {}, refs: {} };
const text = String(md).replace(/\r\n?/g, '\n');
const lines = text.split('\n');
const tokens = [];
const footnotes = {};
const refs = {};
let i = 0;
while (i < lines.length) {
const line = lines[i];
let handled = false;
for (const handler of blockHandlers) {
const match = handler.test(line, lines, i);
if (match !== null && match !== false) {
const result = handler.parse(lines, i, match, tokens, footnotes, refs);
if (result.token)
tokens.push(result.token);
i = result.newIndex;
handled = true;
break;
}
}
if (handled)
continue;
// Paragraph fallback
const para = [];
while (i < lines.length) {
const l = lines[i];
if (RE_EMPTY.test(l))
break;
if (isBlockStart(l)) {
// 防止死循环:若该行匹配 RE_FENCE_START 但被 fencedCode handler 拒绝(info string 含反引号),
// 且 para 为空,则强制当作段落首行收集,否则主循环将无限重复处理此行
if (para.length === 0 && RE_FENCE_START.test(l) && l.includes('`')) {
para.push(l);
i++;
continue;
}
break;
}
if (i + 1 < lines.length && (RE_SETEXT_H1.test(lines[i + 1]) || RE_SETEXT_H2.test(lines[i + 1])))
break;
if (/\|/.test(l) && i + 1 < lines.length && isTableSeparator(lines[i + 1]))
break;
para.push(l);
i++;
}
if (para.length)
tokens.push({ type: 'paragraph', text: para.join('\n') });
else if (i < lines.length && !RE_EMPTY.test(lines[i]))
i++; // 兜底:跳过无法处理的非空行
}
return { tokens, footnotes, refs };
};
// ============ Token rendering ============
const renderTokens = (tokens, env = {}, footnotes = {}) => {
let html = tokens.map((tok) => renderToken(tok, env)).join('\n');
const fnIds = Object.keys(footnotes);
if (fnIds.length) {
html += '\n
';
}
return html;
};
const parseMarkdown = (md, env = {}) => {
const { tokens, footnotes, refs } = parseTokens(md);
const resolvedEnv = { ...env, refs };
if (refs && Object.keys(refs).length > 0)
resolvedEnv._refsFp = JSON.stringify(refs);
return renderTokens(tokens, resolvedEnv, footnotes);
};
// ============ List parsing ============
const parseList = (lines, startIdx, marker, baseIndent, isOl, olStart) => {
const items = [];
let i = startIdx;
const itemIndent = baseIndent + (isOl ? String(marker).length + 2 : 2);
while (i < lines.length) {
const line = lines[i];
let itemMatch;
if (isOl) {
itemMatch = line.match(new RegExp(`^(\\s{${baseIndent}})\\d+\\.\\s`));
if (!itemMatch)
break;
}
else {
itemMatch = line.match(new RegExp(`^(\\s{${baseIndent}})[-*+]\\s`));
if (!itemMatch)
break;
}
let raw = line.slice(itemMatch[0].length);
const task = raw.match(RE_TASK);
let content = task ? task[2] : raw;
const checked = task ? task[1].toLowerCase() === 'x' : false;
const isTask = !!task;
i++;
const subContent = [];
const subTokens = [];
while (i < lines.length) {
const cl = lines[i];
if (RE_EMPTY.test(cl)) {
let peek = i + 1;
while (peek < lines.length && RE_EMPTY.test(lines[peek]))
peek++;
if (peek < lines.length) {
const pl = lines[peek];
const isCont = pl.length > itemIndent && pl[itemIndent] !== ' '
? false : new RegExp(`^\\s{${itemIndent},}`).test(pl);
if (isCont) {
subContent.push('');
i++;
continue;
}
}
break;
}
if (isOl) {
if (new RegExp(`^(\\s{0,${baseIndent}})\\d+\\.\\s`).test(cl))
break;
}
else {
if (new RegExp(`^(\\s{0,${baseIndent}})[-*+]\\s`).test(cl))
break;
}
if (RE_QUOTE.test(cl) || RE_ATX.test(cl) || RE_HR.test(cl) || RE_FOOTNOTE_DEF.test(cl))
break;
const nUl = cl.match(new RegExp(`^(\\s{${itemIndent},})([-*+])\\s`));
const nOl = cl.match(new RegExp(`^(\\s{${itemIndent},})(\\d+)\\.\\s`));
if (nUl || nOl) {
const isNOl = !!nOl;
const nM = isNOl ? nOl[2] : nUl[2];
const nI = isNOl ? nOl[1].length : nUl[1].length;
const nS = isNOl ? parseInt(nOl[2], 10) : undefined;
const nested = parseList(lines, i, nM, nI, isNOl);
subTokens.push({ type: isNOl ? 'ol' : 'ul', items: nested.items, start: nS });
i = nested.endIdx;
continue;
}
if (cl.length > itemIndent && cl.slice(0, itemIndent).trim() === '') {
subContent.push(cl.slice(itemIndent));
}
else {
subContent.push(cl);
}
i++;
}
if (subContent.length) {
content = content ? content + '\n' + subContent.join('\n') : subContent.join('\n');
}
items.push({ type: 'listItem', text: content, checked, task: isTask, subTokens: subTokens.length ? subTokens : undefined });
}
return { items, endIdx: i };
};
// ============ Table utils ============
const isTableSeparator = (line) => RE_TABLE_SEP.test(line) && /-/.test(line);
const parseAlign = (line) => {
const cells = line.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
return cells.map((c) => {
if (/^:/.test(c) && /:$/.test(c))
return 'center';
if (/:$/.test(c))
return 'right';
if (/^:/.test(c))
return 'left';
return 'left';
});
};
// ============ Token renderer ============
const renderToken = (tok, env, footnotes) => {
switch (tok.type) {
case 'heading': {
const id = slugify(tok.text);
return `${cachedRenderInline(tok.text, env)} `;
}
case 'paragraph': return `${cachedRenderInline(tok.text, env)}
`;
case 'hr': return ' ';
case 'quote': return `${parseMarkdown(tok.content, env)} `;
case 'code': return renderCode(tok.content, tok.lang, env, tok.attrs);
case 'ul': return `${tok.items.map((it) => renderListItem(it, env)).join('')} `;
case 'ol': {
const sn = tok.start || 1;
const sa = sn > 1 ? ` start="${sn}"` : '';
const body = tok.items.map((it, idx) => renderListItem(it, env, sn > 1 ? idx + sn : undefined)).join('');
return `${body} `;
}
case 'table': return renderTable(tok, env);
case 'defList': {
let h = '';
h += `${cachedRenderInline(tok.term, env)} `;
tok.defs.forEach((d) => { h += `${cachedRenderInline(d, env)} `; });
return h + ' ';
}
case 'mathBlock': return `${escapeHTML(tok.content)}
`;
default: {
// Fallback: render unknown block types as styled div
const typeClass = `me-block-${tok.type}`;
const content = tok.content ? (typeof tok.content === 'string' ? cachedRenderInline(tok.content, env) : escapeHTML(String(tok.content))) : '';
return `${content}
`;
}
}
};
const renderListItem = (it, env, idx) => {
if (it.task) {
const checked = it.checked ? ' checked' : '';
let h = ` ${cachedRenderInline(it.text, env)}`;
if (it.subTokens)
h += '\n' + it.subTokens.map((st) => renderToken(st, env)).join('\n');
return h + ' ';
}
let h = idx != null ? `` : ' ';
h += cachedRenderInline(it.text, env);
if (it.subTokens)
h += '\n' + it.subTokens.map((st) => renderToken(st, env)).join('\n');
return h + ' ';
};
const renderCode = (code, lang, env, attrs) => {
if (lang === 'mermaid')
return ``;
const langClass = lang ? ` class="language-${escapeAttr$1(lang)}"` : '';
const titleHtml = attrs?.title ? `${escapeAttr$1(attrs.title)}
` : '';
if (env.highlight && typeof env.highlight === 'function' && lang) {
try {
const highlighted = env.highlight(code, lang);
if (typeof highlighted === 'string')
return `${titleHtml}${highlighted} `;
}
catch (e) {
console.error('MeEditor highlight error:', e);
}
}
return `${titleHtml}${escapeHTML(code)} `;
};
const renderTable = (tok, env) => {
// Split respecting backslash-escaped pipes
const splitRow = (r) => {
const cells = [];
let current = '';
let escaped = false;
for (let i = 0; i < r.length; i++) {
const ch = r[i];
if (escaped) {
current += ch;
escaped = false;
}
else if (ch === '\\') {
escaped = true;
}
else if (ch === '|') {
cells.push(current.trim());
current = '';
}
else {
current += ch;
}
}
cells.push(current.trim());
// Remove leading/trailing empty cells from outer pipes
if (cells.length > 0 && cells[0] === '')
cells.shift();
if (cells.length > 0 && cells[cells.length - 1] === '')
cells.pop();
return cells;
};
const headers = splitRow(tok.header);
const align = tok.align || [];
const as = (i) => align[i] && align[i] !== 'left' ? ` style="text-align:${align[i]}"` : '';
let h = '';
h += headers.map((hd, i) => `${cachedRenderInline(hd, env)} `).join('');
h += ' ';
h += tok.rows.map((r) => {
const cells = splitRow(r);
return `${cells.map((c, i) => `${cachedRenderInline(c, env)} `).join('')} `;
}).join('');
return h + '
';
};
// ============ Inline rendering ============
const renderInline = (text, env) => {
if (!text)
return '';
const codes = [];
let s = extractInlineCodes(text, codes);
const protectedItems = [];
s = s.replace(/&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});/g, (m) => {
protectedItems.push(m);
return `\u0005${protectedItems.length - 1}\u0005`;
});
s = s.replace(//g, (m) => {
protectedItems.push(m);
return `\u0005${protectedItems.length - 1}\u0005`;
});
// 白名单裸标签透传:仅 `` / ` `(无任何属性),供 Ctrl+U 下划线命令使用。
// 带属性的 `` 不匹配,仍会被 escapeHTML 转义,不引入注入面。
s = s.replace(/<\/?u\s*>/gi, (m) => {
protectedItems.push(m);
return `\u0005${protectedItems.length - 1}\u0005`;
});
s = escapeHTML(s);
s = s.replace(/\u0005(\d+)\u0005/g, (_m, idx) => protectedItems[+idx] || _m);
s = scanInline(s, codes, env);
s = s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => {
const code = codes[+idx];
if (!code)
return _m;
return `${escapeHTML(code.content)}`;
});
s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m);
s = s.replace(/ \n/g, ' \n');
s = s.replace(/\n/g, ' ');
return s;
};
const extractInlineCodes = (text, codes) => {
let result = '';
let i = 0;
while (i < text.length) {
if (text[i] === '`') {
let startLen = 1;
while (i + startLen < text.length && text[i + startLen] === '`')
startLen++;
let j = i + startLen;
let found = false;
while (j < text.length) {
const nextTick = text.indexOf('`', j);
if (nextTick === -1)
break;
let endLen = 1;
while (nextTick + endLen < text.length && text[nextTick + endLen] === '`')
endLen++;
if (endLen === startLen) {
const content = text.slice(i + startLen, nextTick);
codes.push({ content, len: startLen });
result += `\u0000${codes.length - 1}\u0000`;
i = nextTick + endLen;
found = true;
break;
}
j = nextTick + endLen;
}
if (!found) {
result += text.slice(i, i + startLen);
i += startLen;
}
}
else {
result += text[i];
i++;
}
}
return result;
};
const INLINE_RE = new RegExp([
'(\\*\\*\\*[^*\\n][^*\\n]*?\\*\\*\\*)',
'|(___[^_\\n][^_\\n]*?___)',
'|(!\\[[^\\]]*\\]\\([^)]+\\))',
'|(? {
const placeholderMap = new Map();
s = s.replace(/\u0000\d+\u0000/g, (m) => {
const key = `\u0003${placeholderMap.size}\u0003`;
placeholderMap.set(key, m);
return key;
});
s = s.replace(INLINE_RE, (fullMatch) => {
const match = fullMatch;
if (match.startsWith('***'))
return `${match.slice(3, -3)} `;
if (match.startsWith('___'))
return `${match.slice(3, -3)} `;
if (match.startsWith(') {
const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
if (!m)
return match;
const u = safeUrl(m[2]);
if (!u)
return escapeHTML(match);
return ` `;
}
if (match.startsWith('![') && match.includes('][')) {
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
if (!m)
return match;
const refKey = (m[2] || m[1]).toLowerCase();
if (env.refs && env.refs[refKey]) {
try {
const ref = JSON.parse(env.refs[refKey]);
const u = safeUrl(ref.url);
if (!u)
return escapeHTML(match);
return ` `;
}
catch (_) {
return ` `;
}
}
return ` `;
}
if (match.startsWith('[') && match.includes('](')) {
const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/);
if (!m)
return match;
const u = safeUrl(m[2]);
if (!u)
return match;
const linkText = renderInline(m[1], env);
return `${linkText} `;
}
if (match.startsWith('[') && match.includes('][')) {
const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/);
if (!m)
return match;
const refKey = (m[2] || m[1]).toLowerCase();
if (env.refs && env.refs[refKey]) {
try {
const ref = JSON.parse(env.refs[refKey]);
const u = safeUrl(ref.url);
if (!u)
return escapeHTML(match);
return `${m[1]} `;
}
catch (_) {
return escapeHTML(match);
}
}
return escapeHTML(match);
}
if (match.startsWith('<http')) {
const url = match.slice(4, -4);
const u = safeUrl(url);
return `${url} `;
}
if (match.startsWith('**'))
return `${match.slice(2, -2)} `;
if (match.startsWith('__'))
return `${match.slice(2, -2)} `;
if (match.startsWith('~~'))
return `${match.slice(2, -2)}`;
if (match.startsWith('=='))
return `${match.slice(2, -2)} `;
if (match.startsWith('*') && !match.startsWith('**') && !match.startsWith('***'))
return `${match.slice(1, -1)} `;
if (match.startsWith('_') && !match.startsWith('__') && !match.startsWith('___'))
return `${match.slice(1, -1)} `;
if (match.startsWith('^') && !match.startsWith('^^'))
return `${match.slice(1, -1)} `;
if (match.startsWith('~') && !match.startsWith('~~'))
return `${match.slice(1, -1)} `;
if (match.startsWith('$') && !match.startsWith('$$'))
return `${escapeHTML(match.slice(1, -1))} `;
if (/^\[\^/.test(match)) {
const fnId = match.slice(2, -1);
const safeId = attrSafe(fnId);
return ``;
}
if (match.startsWith('\\') && match.length === 2) {
const escaped = match[1];
if (/[!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~]/.test(escaped))
return escaped;
return match;
}
return match;
});
s = s.replace(/\u0003(\d+)\u0003/g, (m) => placeholderMap.get(m) || m);
return s;
};
/**
* MetonaEditor Plugins — plugin system v2
* @module plugins
* @version 0.2.0
*/
// ============ Plugin state keys (Symbols avoid property-name collisions) ============
const K_AUTOSAVE = Symbol('me-plugin:autoSave');
const K_SEARCH = Symbol('me-plugin:searchReplace');
const K_IMAGE_PASTE = Symbol('me-plugin:imagePaste');
const K_SHORTCUT = Symbol('me-plugin:shortcutHelp');
const K_FILESYSTEM = Symbol('me-plugin:fileSystem');
const pluginState = (editor, key) => editor[key];
const setPluginState = (editor, key, state) => { editor[key] = state; };
const deletePluginState = (editor, key) => { delete editor[key]; };
// ============ Plugin Manager ============
class PluginManager {
constructor() {
this.plugins = new Map();
}
register(name, plugin) {
if (this.plugins.has(name)) {
console.warn(`MeEditor: plugin "${name}" already registered`);
return this;
}
if (!plugin || typeof plugin !== 'object' || (!plugin.name && !name)) {
console.error(`MeEditor: invalid plugin "${name}"`);
return this;
}
this.plugins.set(name, {
version: plugin.version || '0.0.0', description: plugin.description || '',
depends: plugin.depends || [], priority: plugin.priority || 0,
...plugin, name, enabled: true,
});
return this;
}
unregister(name) { this.plugins.delete(name); return this; }
get(name) { return this.plugins.get(name) || null; }
has(name) { return this.plugins.has(name); }
getAll() { return Array.from(this.plugins.values()); }
getNames() { return Array.from(this.plugins.keys()); }
enable(name) { const p = this.plugins.get(name); if (p)
p.enabled = true; return this; }
disable(name) { const p = this.plugins.get(name); if (p)
p.enabled = false; return this; }
isEnabled(name) { const p = this.plugins.get(name); return p ? p.enabled : false; }
destroy() { this.plugins.clear(); }
}
const defaultPluginManager = new PluginManager();
// ============ Topological sort ============
const topologicalSort = (plugins) => {
const map = new Map();
plugins.forEach((p) => map.set(p.name, p));
const inDegree = new Map();
const adj = new Map();
plugins.forEach((p) => { inDegree.set(p.name, 0); adj.set(p.name, []); });
plugins.forEach((p) => {
(p.depends || []).forEach((dep) => {
if (map.has(dep)) {
adj.get(dep).push(p.name);
inDegree.set(p.name, (inDegree.get(p.name) || 0) + 1);
}
else {
console.warn(`MeEditor: plugin "${p.name}" depends on unknown "${dep}"`);
}
});
});
const queue = [];
inDegree.forEach((deg, name) => { if (deg === 0)
queue.push(name); });
const sorted = [];
while (queue.length) {
queue.sort((a, b) => ((map.get(b).priority || 0) - (map.get(a).priority || 0)));
const name = queue.shift();
sorted.push(map.get(name));
(adj.get(name) || []).forEach((n) => { inDegree.set(n, inDegree.get(n) - 1); if (inDegree.get(n) === 0)
queue.push(n); });
}
if (sorted.length !== plugins.length) {
console.warn('MeEditor: circular dependency detected, falling back to original order');
return plugins;
}
return sorted;
};
// ============ Config validation ============
const validateConfig = (schema = {}, config = {}) => {
const errors = [];
const patched = { ...config };
for (const [key, rule] of Object.entries(schema)) {
const val = config[key];
if (rule.required && (val === undefined || val === null)) {
errors.push(`"${key}" is required`);
continue;
}
if (val === undefined && rule.default !== undefined) {
patched[key] = rule.default;
continue;
}
if (val !== undefined && rule.type) {
const actual = Array.isArray(val) ? 'array' : typeof val;
if (actual !== rule.type)
errors.push(`"${key}" expected ${rule.type}, got ${actual}`);
}
if (val !== undefined && rule.enum && !rule.enum.includes(val))
errors.push(`"${key}" must be one of [${rule.enum.join(', ')}]`);
if (val !== undefined && typeof rule.validator === 'function') {
try {
const r = rule.validator(val);
if (r !== true)
errors.push(`"${key}": ${r}`);
}
catch (e) {
errors.push(`"${key}": ${e.message}`);
}
}
}
return { valid: errors.length === 0, errors, patched };
};
// ============ Preset plugins ============
const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').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;
if (typeof editor.on === 'function') {
editor.on('change', _onInput);
editor.on('blur', _onBlur);
editor.on('save', _onSave);
}
editor.restoreDraft = () => { try {
const v = localStorage.getItem(key);
if (v != null && typeof editor.setValue === 'function')
editor.setValue(v);
return v;
}
catch (_) {
return null;
} };
editor.clearDraft = () => { try {
localStorage.removeItem(key);
}
catch (_) { } return editor; };
editor.getDraftKey = () => key;
setPluginState(editor, K_AUTOSAVE, { state, _onInput, _onBlur, _onSave, save });
},
destroy(editor) {
const cleanup = pluginState(editor, K_AUTOSAVE);
if (cleanup) {
if (cleanup.state._timer) {
clearTimeout(cleanup.state._timer);
cleanup.state._timer = null;
}
if (editor && typeof editor.off === 'function') {
if (cleanup._onInput)
editor.off('change', cleanup._onInput);
if (cleanup._onBlur)
editor.off('blur', cleanup._onBlur);
if (cleanup._onSave)
editor.off('save', cleanup._onSave);
}
deletePluginState(editor, K_AUTOSAVE);
}
},
};
const exportToolPlugin = {
name: 'exportTool', version: '0.1.0', description: 'Export Markdown/HTML', priority: 50,
install(editor) {
if (!editor || typeof editor.getValue !== 'function')
return;
const download = (filename, content, mime) => {
if (typeof document === 'undefined')
return;
const blob = new Blob([content], { type: mime + ';charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 0);
};
const stamp = () => { const d = new Date(); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; };
const e = editor;
const buildHTML = (opts = {}) => {
const title = opts.title || 'Document';
const css = opts.css || '';
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
// Build minimal embedded CSS for the exported HTML
const embedCSS = opts.embedCSS !== false ? `` : '';
return `\n\n\n \n \n${title} \n${css ? `` : ''}${embedCSS}\n\n\n${body}\n\n`;
};
e.exportMarkdown = (filename) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; };
e.exportHTML = (filename, opts = {}) => {
download(filename || `metona-${stamp()}.html`, buildHTML(opts), 'text/html');
return editor;
};
e.exportPDF = (opts = {}) => {
if (typeof document === 'undefined' || !editor.el)
return editor;
const iframe = document.createElement('iframe');
iframe.style.position = 'fixed';
iframe.style.right = '0';
iframe.style.bottom = '0';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = '0';
document.body.appendChild(iframe);
const doc = iframe.contentDocument;
if (!doc) {
iframe.remove();
return editor;
}
doc.open();
doc.write(buildHTML(opts));
doc.close();
setTimeout(() => {
try {
iframe.contentWindow?.print();
}
catch (_) { }
setTimeout(() => { if (iframe.parentNode)
iframe.parentNode.removeChild(iframe); }, 1000);
}, 50);
return editor;
};
},
destroy(editor) {
if (editor) {
delete editor.exportMarkdown;
delete editor.exportHTML;
delete editor.exportPDF;
}
},
};
const searchReplacePlugin = {
name: 'searchReplace', version: '0.2.1', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
// Inject style once globally
if (!document.getElementById('me-search-style')) {
const style = document.createElement('style');
style.id = 'me-search-style';
style.textContent = `.me-search{position:absolute;top:10px;right:16px;z-index:20;display:flex;flex-direction:column;gap:8px;padding:12px;background:var(--md-bg,#1c2029);border:1px solid var(--md-border,rgba(255,255,255,0.12));border-radius:10px;box-shadow:0 12px 40px -8px rgba(0,0,0,0.4),0 0 0 1px rgba(255,255,255,0.05);font-size:13px;min-width:300px;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);animation:meSearchIn .18s ease-out}@keyframes meSearchIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}.me-search-row{display:flex;gap:6px;align-items:center}.me-search-row+.me-search-row{margin-top:2px}.me-search input{flex:1;min-width:0;padding:7px 10px;border:1px solid var(--md-border,rgba(255,255,255,0.12));border-radius:6px;background:var(--md-textarea-bg,rgba(255,255,255,0.04));color:var(--md-text,#e6e8eb);font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s}.me-search input::placeholder{color:var(--md-muted,#6b7280)}.me-search input:focus{border-color:var(--md-accent,#3b82f6);box-shadow:0 0 0 3px rgba(59,130,246,0.15)}.me-search .me-search-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid rgba(255,255,255,0.08);border-radius:5px;background:rgba(255,255,255,0.04);color:var(--md-muted,#9ca3af);cursor:pointer;transition:all .12s;flex-shrink:0}.me-search .me-search-btn:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-btn:active{transform:scale(.92)}.me-search .me-search-btn.me-active{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-btn svg{width:14px;height:14px}.me-search .me-search-count{color:var(--md-muted,#9ca3af);font-size:11.5px;min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.me-search .me-search-close{margin-left:2px;font-size:16px;line-height:1}.me-search .me-search-close:hover{background:#ef4444;border-color:#ef4444}.me-search .me-search-replace-one,.me-search .me-search-replace-all{width:auto;padding:5px 10px;font-size:12px;font-weight:500;letter-spacing:.01em}.me-search .me-search-replace-all{background:rgba(59,130,246,0.12);border-color:rgba(59,130,246,0.3);color:var(--md-accent,#60a5fa)}.me-search .me-search-replace-all:hover{background:var(--md-accent,#3b82f6);color:#fff}`;
document.head.appendChild(style);
}
const state = {
_panel: null,
_regexMode: false,
_matchCase: true,
_wholeWord: false,
_cleanup: null,
};
const WORD_CLASS = '[\\w\\u4e00-\\u9fff]';
const escapeRegExpText = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const buildPattern = (q) => {
if (!q)
return null;
let src = state._regexMode ? q : escapeRegExpText(q);
if (state._wholeWord)
src = `(? {
const isWord = (c) => !!c && /[\w\u4e00-\u9fff]/.test(c);
return !isWord(text[idx - 1]) && !isWord(text[idx + len]);
};
const _updateReplaceVisible = () => {
if (!state._panel)
return;
const show = state._panel.dataset.replace === '1';
const row = state._panel.querySelector('.me-search-replace-row');
if (row)
row.style.display = show ? 'flex' : 'none';
};
const _close = () => {
if (state._cleanup) {
state._cleanup();
state._cleanup = null;
}
state._panel = null;
if (editor && editor.textarea)
editor.textarea.focus();
};
const _open = (showReplace) => {
if (state._panel) {
state._panel.dataset.replace = showReplace ? '1' : '0';
_updateReplaceVisible();
const inp = state._panel.querySelector('.me-search-find');
if (inp) {
inp.focus();
inp.select();
}
return;
}
const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd);
const panel = document.createElement('div');
panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = ` ${t('replace') || 'Replace'} ${t('replaceAll') || 'All'}
`;
editor.el.appendChild(panel);
state._panel = panel;
_updateReplaceVisible();
state._regexMode = false;
state._matchCase = true;
state._wholeWord = false;
const fi = panel.querySelector('.me-search-find');
const ri = panel.querySelector('.me-search-replace');
const ce = panel.querySelector('.me-search-count');
const regexBtn = panel.querySelector('.me-search-regex');
const caseBtn = panel.querySelector('.me-search-case');
const wordBtn = panel.querySelector('.me-search-word');
regexBtn.addEventListener('click', () => {
state._regexMode = !state._regexMode;
regexBtn.classList.toggle('me-active', state._regexMode);
lastIdxs = findAll();
});
caseBtn.addEventListener('click', () => {
state._matchCase = !state._matchCase;
caseBtn.classList.toggle('me-active', state._matchCase);
lastIdxs = findAll();
});
wordBtn.addEventListener('click', () => {
state._wholeWord = !state._wholeWord;
wordBtn.classList.toggle('me-active', state._wholeWord);
lastIdxs = findAll();
});
const findAll = () => {
const q = fi.value;
if (!q) {
ce.textContent = '';
return [];
}
const idxs = [];
const re = buildPattern(q);
if (!re) {
ce.textContent = 'err';
return [];
}
let m;
while ((m = re.exec(editor.textarea.value)) !== null) {
idxs.push(m.index);
if (m[0].length === 0)
re.lastIndex++;
}
ce.textContent = idxs.length ? `${idxs.length}` : '0';
return idxs;
};
let lastIdxs = [];
const selectAt = (idx, len) => {
editor.textarea.focus();
editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length));
};
const matchLenAt = (idx) => {
const text = editor.textarea.value;
const q = fi.value;
if (!q)
return 0;
if (!state._regexMode) {
if (state._matchCase && text.slice(idx, idx + q.length) !== q)
return 0;
if (!state._matchCase && text.slice(idx, idx + q.length).toLowerCase() !== q.toLowerCase())
return 0;
if (state._wholeWord && !hasWordBoundary(text, idx, q.length))
return 0;
return q.length;
}
const re = buildPattern(q);
if (!re)
return 0;
re.lastIndex = idx;
const m = re.exec(text);
return m && m.index === idx ? m[0].length : 0;
};
const findNext = () => {
lastIdxs = findAll();
if (!lastIdxs.length)
return;
const cur = editor.textarea.selectionEnd;
let next = lastIdxs.find((i) => i >= cur);
if (next == null)
next = lastIdxs[0];
selectAt(next, matchLenAt(next) || fi.value.length);
};
const findPrev = () => {
lastIdxs = findAll();
if (!lastIdxs.length)
return;
const cur = editor.textarea.selectionStart;
let prev = -1;
for (let i = lastIdxs.length - 1; i >= 0; i--) {
if (lastIdxs[i] < cur) {
prev = lastIdxs[i];
break;
}
}
if (prev === -1)
prev = lastIdxs[lastIdxs.length - 1];
selectAt(prev, matchLenAt(prev) || fi.value.length);
};
const replaceOne = () => {
const q = fi.value, r = ri.value;
if (!q)
return;
const ta = editor.textarea;
const s = ta.selectionStart, e = ta.selectionEnd;
const matchText = ta.value.substring(s, e);
let matched = false;
if (state._regexMode) {
const re = buildPattern(q);
if (re) {
re.lastIndex = s;
const m = re.exec(ta.value);
matched = !!(m && m.index === s);
}
}
else {
const sameCase = state._matchCase ? matchText === q : matchText.toLowerCase() === q.toLowerCase();
matched = sameCase && (!state._wholeWord || hasWordBoundary(ta.value, s, q.length));
}
if (matched) {
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
ta.setSelectionRange(s, s + r.length);
}
editor._value = ta.value;
if (typeof editor._pushHistory === 'function')
editor._pushHistory();
if (typeof editor._render === 'function')
editor._render();
if (typeof editor._updateWordCount === 'function')
editor._updateWordCount();
if (typeof editor._emit === 'function')
editor._emit('change', editor._value);
findNext();
};
const replaceAll = () => {
const q = fi.value, r = ri.value;
if (!q)
return;
const ta = editor.textarea;
const re = buildPattern(q);
if (!re)
return;
ta.value = ta.value.replace(re, () => 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._updateWordCount === 'function')
editor._updateWordCount();
if (typeof editor._emit === 'function')
editor._emit('change', editor._value);
findAll();
};
fi.addEventListener('input', () => { lastIdxs = findAll(); });
fi.addEventListener('keydown', (e) => { if (e.key === 'Enter') {
e.preventDefault();
e.shiftKey ? findPrev() : findNext();
} if (e.key === 'Escape') {
e.preventDefault();
_close();
} });
ri.addEventListener('keydown', (e) => { if (e.key === 'Enter') {
e.preventDefault();
replaceOne();
} if (e.key === 'Escape') {
e.preventDefault();
_close();
} });
panel.querySelector('.me-search-next').addEventListener('click', findNext);
panel.querySelector('.me-search-prev').addEventListener('click', findPrev);
panel.querySelector('.me-search-close').addEventListener('click', () => _close());
panel.querySelector('.me-search-replace-one').addEventListener('click', replaceOne);
panel.querySelector('.me-search-replace-all').addEventListener('click', replaceAll);
fi.focus();
fi.select();
state._cleanup = () => { if (panel.parentNode)
panel.parentNode.removeChild(panel); };
};
const _onKeydown = (e) => {
const mod = e.ctrlKey || e.metaKey;
if (!mod)
return;
const k = e.key.toLowerCase();
if (k === 'f') {
e.preventDefault();
_open(false);
}
else if (k === 'h') {
e.preventDefault();
_open(true);
}
else if (k === 'escape' && state._panel) {
_close();
}
};
editor.textarea.addEventListener('keydown', _onKeydown);
// Expose for tests via Symbol-keyed state (no public-property pollution)
setPluginState(editor, K_SEARCH, { state, _open, _close, _onKeydown });
},
destroy(editor) {
const exposed = pluginState(editor, K_SEARCH);
if (exposed) {
const state = exposed.state;
if (state) {
if (state._cleanup) {
state._cleanup();
state._cleanup = null;
}
state._panel = null;
}
if (exposed._onKeydown && editor && editor.textarea) {
editor.textarea.removeEventListener('keydown', exposed._onKeydown);
}
deletePluginState(editor, K_SEARCH);
}
},
};
const imagePastePlugin = {
name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60,
install(editor, options = {}) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
const maxSizeKB = options.maxSizeKB || 500;
const _onPaste = (e) => {
const items = e.clipboardData?.items;
if (!items)
return;
for (const item of items) {
if (item.type?.startsWith('image/')) {
const file = item.getAsFile();
if (!file)
continue;
const sizeKB = file.size / 1024;
if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
e.preventDefault();
if (typeof editor.toast === 'function')
editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
break;
}
e.preventDefault();
const reader = new FileReader();
reader.onload = () => { if (typeof editor.insert === 'function')
editor.insert(`\n`); };
reader.readAsDataURL(file);
break;
}
}
};
editor.textarea.addEventListener('paste', _onPaste);
setPluginState(editor, K_IMAGE_PASTE, _onPaste);
},
destroy(editor) {
const _onPaste = pluginState(editor, K_IMAGE_PASTE);
if (_onPaste && editor?.textarea)
editor.textarea.removeEventListener('paste', _onPaste);
deletePluginState(editor, K_IMAGE_PASTE);
},
};
const shortcutHelpPlugin = {
name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200,
install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined')
return;
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('shortcuts') || 'Shortcuts'],
];
let rows = '';
builtin.forEach(([c, d]) => { rows += `${c} ${d} `; });
const overlay = document.createElement('div');
overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `⌨️ ${t('shortcuts') || 'Shortcuts'} × `;
overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close'))
_close(); });
document.body.appendChild(overlay);
_panel = overlay;
};
const _onKeydown = (e) => {
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
_open();
}
if (e.key === 'Escape' && _panel)
_close();
};
editor.textarea.addEventListener('keydown', _onKeydown);
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown });
},
destroy(editor) {
const state = pluginState(editor, K_SHORTCUT);
if (state) {
if (state._panel) {
state._panel.remove();
}
}
const _onKeydown = state?._onKeydown;
if (_onKeydown && editor?.textarea)
editor.textarea.removeEventListener('keydown', _onKeydown);
deletePluginState(editor, K_SHORTCUT);
},
};
const fileSystemPlugin = {
name: 'fileSystem', version: '0.2.0', description: 'File System Access API', priority: 90,
install(editor) {
if (!editor || typeof editor.getValue !== 'function')
return;
const hasAPI = typeof window !== 'undefined' && typeof window.showOpenFilePicker === 'function';
let _fileHandle = null;
editor.openFile = async (opts = {}) => {
if (!hasAPI) {
if (typeof editor.toast === 'function')
editor.toast?.('File System Access API not supported', { type: 'warning' });
return null;
}
try {
const [handle] = await window.showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md', '.txt', '.markdown'] } }], ...opts });
_fileHandle = handle;
const file = await handle.getFile();
const content = await file.text();
if (typeof editor.setValue === 'function')
editor.setValue(content);
editor._emit?.('fileOpened', { name: file.name, handle });
return { name: file.name, content, handle };
}
catch (e) {
if (e.name !== 'AbortError')
console.error('Open file error:', e);
return null;
}
};
editor.saveFile = async (opts = {}) => {
let handle = _fileHandle;
if (!handle || opts.saveAs) {
if (!hasAPI) {
if (typeof editor.toast === 'function')
editor.toast?.('File System Access API not supported', { type: 'warning' });
return false;
}
try {
handle = await window.showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' });
_fileHandle = handle;
}
catch (e) {
if (e.name !== 'AbortError')
console.error('Save error:', e);
return false;
}
}
try {
const w = await handle.createWritable();
await w.write(editor.getValue());
await w.close();
editor._emit?.('fileSaved', { handle });
return true;
}
catch (e) {
_fileHandle = null;
if (!opts.saveAs)
return editor.saveFile({ ...opts, saveAs: true });
console.error('Write error:', e);
return false;
}
};
editor.saveFileAs = (name) => editor.saveFile({ saveAs: true, name });
editor.getFileHandle = () => _fileHandle;
setPluginState(editor, K_FILESYSTEM, { getHandle: () => _fileHandle });
},
destroy(editor) {
if (editor) {
delete editor.openFile;
delete editor.saveFile;
delete editor.saveFileAs;
delete editor.getFileHandle;
}
deletePluginState(editor, K_FILESYSTEM);
},
};
// ============ Preset plugins table ============
const presetPlugins = {
autoSave: autoSavePlugin, exportTool: exportToolPlugin, searchReplace: searchReplacePlugin,
imagePaste: imagePastePlugin, shortcutHelp: shortcutHelpPlugin, fileSystem: fileSystemPlugin,
};
// ============ Plugin utils ============
const pluginUtils = {
createManager: () => new PluginManager(),
manager: defaultPluginManager,
register: (name, plugin) => defaultPluginManager.register(name, plugin),
unregister: (name) => defaultPluginManager.unregister(name),
get: (name) => defaultPluginManager.get(name),
has: (name) => defaultPluginManager.has(name),
getAll: () => defaultPluginManager.getAll(),
getNames: () => defaultPluginManager.getNames(),
enable: (name) => defaultPluginManager.enable(name),
disable: (name) => defaultPluginManager.disable(name),
isEnabled: (name) => defaultPluginManager.isEnabled(name),
getPreset: (name) => presetPlugins[name] ? { ...presetPlugins[name] } : null,
getAllPresets: () => Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {}),
createPlugin: (config = {}) => {
const result = { name: config.name || 'custom', version: config.version || '0.0.0', description: config.description || '', depends: config.depends || [], priority: config.priority || 0, install: () => { }, destroy: () => { }, ...config };
if (typeof result.install !== 'function')
result.install = () => { };
if (typeof result.destroy !== 'function')
result.destroy = () => { };
return result;
},
validatePlugin: (plugin) => {
const errors = [];
if (!plugin || typeof plugin !== 'object')
errors.push('plugin must be an object');
if (plugin && !plugin.name)
errors.push('plugin must have a name');
if (plugin && plugin.install && typeof plugin.install !== 'function')
errors.push('install must be a function');
return { valid: errors.length === 0, errors };
},
topologicalSort, validateConfig,
};
/**
* MetonaEditor Themes — theme system
* @module themes
* @version 0.2.0
*/
// ============ State ============
let globalCurrentTheme = 'auto';
let globalThemeListeners = new Set();
let _sysWatchUnsub = null;
// ============ System theme ============
const getSystemTheme = () => {
if (typeof window === 'undefined')
return 'light';
return prefersDark() ? 'dark' : 'light';
};
const _watchSystem = (callback) => {
if (typeof window === 'undefined' || !window.matchMedia)
return () => { };
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e) => callback(e.matches ? 'dark' : 'light');
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
};
const watchSystemTheme = () => {
if (typeof window === 'undefined' || !window.matchMedia)
return;
if (_sysWatchUnsub) {
_sysWatchUnsub();
_sysWatchUnsub = null;
}
_sysWatchUnsub = _watchSystem((sysTheme) => {
if (globalCurrentTheme === 'auto')
applyTheme('auto');
});
};
const unwatchSystemTheme = () => {
if (_sysWatchUnsub) {
_sysWatchUnsub();
_sysWatchUnsub = null;
}
};
// ============ Theme resolution ============
const resolveTheme = (theme) => {
if (!theme || theme === 'auto')
return getSystemTheme();
return theme;
};
const getThemeConfig = (theme) => {
const resolved = resolveTheme(theme);
const config = THEMES[resolved];
return (config && typeof config === 'object' ? config : THEMES.light);
};
// ============ CSS variables ============
const setThemeVariables = (config, target) => {
if (typeof document === 'undefined' || !config || typeof config !== 'object')
return;
const root = target || document.documentElement;
const vars = {
'--md-bg': config.bg, '--md-text': config.text, '--md-border': config.border,
'--md-shadow': config.shadow, '--md-hover-shadow': config.hoverShadow,
'--md-toolbar-bg': config.toolbarBg || config.bg,
'--md-textarea-bg': config.textareaBg || config.bg,
'--md-preview-bg': config.previewBg || config.bg,
'--md-code-bg': config.codeBg || 'rgba(127,127,127,0.1)',
'--md-code-text': config.codeText || config.text,
'--md-accent': config.accent || '#3b82f6',
'--md-muted': config.muted || '#9ca3af',
};
for (const [k, v] of Object.entries(vars)) {
if (v !== undefined && v !== null)
root.style.setProperty(k, String(v));
}
if (config.progressBg)
root.style.setProperty('--md-progress-bg', config.progressBg);
if (config.closeHoverBg)
root.style.setProperty('--md-close-hover-bg', config.closeHoverBg);
};
const exportCSSVars = (el) => {
const root = el || document.documentElement;
if (typeof document === 'undefined')
return {};
const style = getComputedStyle(root);
const varNames = ['--md-bg', '--md-text', '--md-border', '--md-shadow', '--md-hover-shadow', '--md-toolbar-bg', '--md-textarea-bg', '--md-preview-bg', '--md-code-bg', '--md-code-text', '--md-accent', '--md-muted'];
const result = {};
varNames.forEach((name) => { const val = style.getPropertyValue(name).trim(); if (val)
result[name] = val; });
return result;
};
const getCSSVariable = (name, el) => {
const root = el || document.documentElement;
if (typeof document === 'undefined')
return '';
const fullName = name.startsWith('--') ? name : `--md-${name}`;
return getComputedStyle(root).getPropertyValue(fullName).trim();
};
// ============ Apply theme ============
const applyTheme = (theme) => {
globalCurrentTheme = theme;
const resolved = resolveTheme(theme);
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-md-theme', resolved);
document.documentElement.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm');
document.documentElement.classList.add(`md-theme-${resolved}`);
setThemeVariables(getThemeConfig(theme));
}
notifyGlobalListeners(theme, resolved);
};
const applyThemeToElement = (theme, target) => {
if (!target || typeof document === 'undefined')
return;
const resolved = resolveTheme(theme);
target.setAttribute('data-md-theme', resolved);
target.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm');
if (resolved !== 'auto')
target.classList.add(`md-theme-${resolved}`);
setThemeVariables(getThemeConfig(theme), target);
};
const switchTheme = (theme) => { applyTheme(theme); saveTheme(theme); };
const toggleTheme = () => { const r = getResolvedTheme(); switchTheme(r === 'dark' ? 'light' : 'dark'); };
const resetToAuto = () => { switchTheme('auto'); };
const getCurrentTheme = () => globalCurrentTheme;
const getResolvedTheme = () => resolveTheme(globalCurrentTheme);
// ============ Persistence ============
const saveTheme = (theme) => {
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem('metona-editor-theme', theme);
}
catch (_) { }
}
};
const loadTheme = () => {
if (typeof localStorage !== 'undefined') {
try {
return localStorage.getItem('metona-editor-theme') || 'auto';
}
catch (_) { }
}
return 'auto';
};
// ============ Listeners ============
const notifyGlobalListeners = (theme, resolved) => {
globalThemeListeners.forEach((fn) => { try {
fn(theme, resolved);
}
catch (e) {
console.error('Theme listener error:', e);
} });
};
const addThemeListener = (fn) => {
globalThemeListeners.add(fn);
return () => { globalThemeListeners.delete(fn); };
};
const removeThemeListener = (fn) => { globalThemeListeners.delete(fn); };
const clearThemeListeners = () => { globalThemeListeners.clear(); };
// ============ System watch ============
const startSystemWatch = () => { watchSystemTheme(); };
// ============ Init ============
const initTheme = () => { applyTheme(loadTheme()); startSystemWatch(); };
// ============ External follow ============
const followExternalTheme = (options, onThemeChange) => {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined')
return () => { };
const { element = document.documentElement, attr = 'data-theme', classMap = null, callback = null } = options;
const detect = () => {
if (typeof callback === 'function')
return callback(element);
const attrVal = element.getAttribute(attr);
if (attrVal)
return attrVal;
if (classMap) {
for (const [cls, theme] of Object.entries(classMap)) {
if (element.classList.contains(cls))
return theme;
}
}
const mdTheme = element.getAttribute('data-md-theme');
if (mdTheme)
return mdTheme;
return null;
};
let lastTheme = detect();
if (lastTheme)
onThemeChange(lastTheme);
const observer = new MutationObserver(() => { const current = detect(); if (current && current !== lastTheme) {
lastTheme = current;
onThemeChange(current);
} });
observer.observe(element, { attributes: true, attributeFilter: [attr, 'class', 'data-md-theme'] });
return () => observer.disconnect();
};
const adoptFromParent = (container, onThemeDetected) => {
if (typeof document === 'undefined' || !container)
return () => { };
const detect = () => {
let el = container.parentElement;
while (el) {
const t = el.getAttribute('data-md-theme');
if (t)
return t;
if (el.classList.contains('theme-dark') || el.classList.contains('dark'))
return 'dark';
if (el.classList.contains('theme-light') || el.classList.contains('light'))
return 'light';
el = el.parentElement;
}
if (container.parentElement) {
const bg = getComputedStyle(container.parentElement).getPropertyValue('--md-bg').trim() || getComputedStyle(container.parentElement).backgroundColor;
if (bg) {
const rgb = bg.match(/\d+/g);
if (rgb && rgb.length >= 3) {
const brightness = (parseInt(rgb[0]) * 299 + parseInt(rgb[1]) * 587 + parseInt(rgb[2]) * 114) / 1000;
return brightness < 128 ? 'dark' : 'light';
}
}
}
return null;
};
const detected = detect();
if (detected)
onThemeDetected(detected);
const observer = new MutationObserver(() => { const d = detect(); if (d)
onThemeDetected(d); });
let target = container.parentElement;
while (target) {
observer.observe(target, { attributes: true, attributeFilter: ['class', 'data-md-theme', 'data-theme'] });
target = target.parentElement;
if (target === document.documentElement)
break;
}
return () => observer.disconnect();
};
const watch = (source, onChange) => {
if (typeof source === 'function') {
const tick = () => { try {
const t = source();
if (t)
onChange(t);
}
catch (_) { } };
tick();
const id = setInterval(tick, 500);
return () => clearInterval(id);
}
if (typeof source === 'string' && typeof document !== 'undefined') {
const el = document.querySelector(source);
if (!el)
return () => { };
return followExternalTheme({ element: el, attr: 'data-theme' }, onChange);
}
return () => { };
};
// ============ Custom theme registration ============
const registerTheme = (name, config = {}) => {
const baseName = config.extends || 'light';
const base = (THEMES[baseName] || THEMES.light);
THEMES[name] = {
bg: config.bg || base.bg, text: config.text || base.text, border: config.border || base.border,
shadow: config.shadow || base.shadow, hoverShadow: config.hoverShadow || base.hoverShadow,
toolbarBg: config.toolbarBg || base.toolbarBg || base.bg,
textareaBg: config.textareaBg || base.textareaBg || base.bg,
previewBg: config.previewBg || base.previewBg || base.bg,
codeBg: config.codeBg || base.codeBg, codeText: config.codeText || base.codeText,
accent: config.accent || base.accent, muted: config.muted || base.muted,
progressBg: config.progressBg || base.progressBg,
closeHoverBg: config.closeHoverBg || base.closeHoverBg,
};
};
const unregisterTheme = (name) => {
if (name === 'light' || name === 'dark' || name === 'auto' || name === 'warm') {
console.warn('Cannot unregister built-in theme:', name);
return;
}
delete THEMES[name];
};
const getAllThemes = () => ({ ...THEMES });
const getThemeNames = () => Object.keys(THEMES);
const hasTheme = (name) => name in THEMES;
// ============ Instance theme ============
const createInstanceTheme = (editor) => {
let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto';
// 外部跟随订阅列表,随实例销毁断开,避免 observer/定时器泄漏
const unsubs = [];
const apply = (theme) => {
instanceTheme = theme;
const resolved = resolveTheme(theme);
const config = getThemeConfig(theme);
if (editor.el) {
['me-theme-light', 'me-theme-dark', 'me-theme-warm'].forEach((c) => editor.el.classList.remove(c));
if (resolved !== 'auto')
editor.el.classList.add(`me-theme-${resolved}`);
editor.el.setAttribute('data-md-theme', resolved);
setThemeVariables(config, editor.el);
}
if (typeof editor._emit === 'function')
editor._emit('themeChange', { theme, resolved, config });
if (typeof editor.refresh === 'function')
editor.refresh();
return instanceTheme;
};
const get = () => instanceTheme;
const set = (theme) => apply(theme);
const toggle = () => { const r = resolveTheme(instanceTheme); return apply(r === 'dark' ? 'light' : 'dark'); };
const getResolved = () => resolveTheme(instanceTheme);
const getConfig = () => getThemeConfig(instanceTheme);
const getVars = () => editor.el ? exportCSSVars(editor.el) : {};
const syncWithElement = (element, opts = {}) => {
const unsub = followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected));
unsubs.push(unsub);
return unsub;
};
const adopt = () => {
const unsub = editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => { };
unsubs.push(unsub);
return unsub;
};
const watchExternal = (source) => {
const unsub = watch(source, (theme) => apply(theme));
unsubs.push(unsub);
return unsub;
};
const dispose = () => { unsubs.forEach((fn) => { try {
fn();
}
catch (_) { } }); unsubs.length = 0; };
apply(instanceTheme);
return { apply, get, set, toggle, getResolved, getConfig, getVars, syncWithElement, adopt, watch: watchExternal, dispose };
};
const presetThemes = {
light: { name: '浅色', description: '明亮清晰', config: THEMES.light },
dark: { name: '深色', description: '护眼暗色', config: THEMES.dark },
auto: { name: '自动', description: '跟随系统', config: 'auto' },
warm: { name: '暖色', description: '温馨暖色', config: THEMES.warm },
};
// Ensure built-in themes in THEMES
for (const [name, theme] of Object.entries(presetThemes)) {
if (name !== 'auto' && theme.config !== 'auto')
THEMES[name] = theme.config;
}
const themeUtils = {
getSystemTheme, resolveTheme, getThemeConfig, watchSystemTheme, unwatchSystemTheme,
applyTheme, getCurrentTheme, getResolvedTheme, switchTheme, toggleTheme, resetToAuto, initTheme,
saveTheme, loadTheme, addThemeListener, removeThemeListener, clearThemeListeners,
registerTheme, unregisterTheme, getAllThemes, getThemeNames, hasTheme,
setThemeVariables, exportCSSVars, getCSSVariable, applyThemeToElement,
followExternalTheme, adoptFromParent, watch, createInstanceTheme,
};
/**
* MetonaEditor Commands — editing command implementations
* @module commands
* @version 0.4.0
*/
// ============ Selection wrapping ============
function wrapSelection(before, after) {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const selected = ta.value.slice(start, end);
const text = selected || 'text';
const inserted = before + text + after;
ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end);
ta.focus();
if (selected) {
ta.selectionStart = start + before.length;
ta.selectionEnd = start + before.length + text.length;
}
else {
ta.selectionStart = ta.selectionEnd = start + before.length;
}
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
}
// ============ Line prefix toggling ============
function toggleLinePrefix(prefix) {
const ta = this.textarea;
const start = ta.selectionStart;
const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1;
const lineEndPos = ta.value.indexOf('\n', start);
const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos;
const line = ta.value.slice(lineStart, lineEnd);
const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/);
let newLine;
if (existingMatch && existingMatch[0] === prefix)
newLine = line.slice(prefix.length);
else if (existingMatch)
newLine = prefix + line.slice(existingMatch[0].length);
else
newLine = prefix + line;
ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd);
ta.focus();
ta.selectionStart = ta.selectionEnd = lineStart + newLine.length;
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
}
// ============ Block insertion ============
function insertBlock(text) {
const ta = this.textarea;
const start = ta.selectionStart;
const before = ta.value.slice(0, start);
const needNL = before && !before.endsWith('\n');
const insert = (needNL ? '\n' : '') + text;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd);
ta.focus();
const pos = start + insert.length;
ta.selectionStart = ta.selectionEnd = pos;
this._value = ta.value;
this._pushHistory();
this._render();
this._updateWordCount();
this._emit('change', this._value);
}
function insertLink() {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = ta.value.slice(start, end) || this.t('link') || 'link';
const url = 'https://';
const insert = `[${sel}](${url})`;
ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end);
ta.focus();
ta.selectionStart = start + sel.length + 3;
ta.selectionEnd = ta.selectionStart + url.length;
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
}
function insertImage() {
const ta = this.textarea;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const sel = ta.value.slice(start, end) || this.t('image') || 'image';
const url = 'https://';
const insert = ``;
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);
}
function insertTable(rows = 3, cols = 3) {
const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | ');
const sep = Array.from({ length: cols }, () => '---').join(' | ');
let md = `| ${header} |\n| ${sep} |\n`;
for (let r = 1; r < rows; r++)
md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`;
insertBlock.call(this, '\n' + md);
}
// ============ Table auto-format ============
function formatTable() {
const ta = this.textarea;
const start = ta.selectionStart;
const before = ta.value.substring(0, start);
const after = ta.value.substring(start);
const blockStart = before.lastIndexOf('\n\n');
const blockEnd = after.indexOf('\n\n');
const tableStart = blockStart === -1 ? 0 : blockStart + 2;
const tableEnd = blockEnd === -1 ? ta.value.length : start + blockEnd;
const tableText = ta.value.substring(tableStart, tableEnd);
const lines = tableText.split('\n').filter((l) => l.includes('|'));
if (lines.length < 2)
return;
const splitRow = (r) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/);
const allCells = lines.map(splitRow);
const colCount = Math.max(...allCells.map((c) => c.length));
const colWidths = Array(colCount).fill(3);
allCells.forEach((cells) => {
cells.forEach((cell, ci) => {
colWidths[ci] = Math.max(colWidths[ci], cell.trim().length);
});
});
const pad = (s, w) => { const padLen = w - s.length; return s + ' '.repeat(Math.max(0, padLen)); };
const formatted = allCells.map((cells) => {
const padded = [];
for (let ci = 0; ci < colCount; ci++) {
padded.push(pad((cells[ci] || '').trim(), colWidths[ci]));
}
return '| ' + padded.join(' | ') + ' |';
});
ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd);
this._value = ta.value;
this._pushHistory();
this._render();
this._emit('change', this._value);
}
// ============ Prototype installation ============
const installCommands = (proto) => {
proto._wrapSelection = wrapSelection;
proto._toggleLinePrefix = toggleLinePrefix;
proto._insertBlock = insertBlock;
proto._insertLink = insertLink;
proto._insertImage = insertImage;
proto._insertTable = insertTable;
proto._formatTable = formatTable;
};
/**
* MetonaEditor Floating Toolbar — selection format toolbar
* @module floating-toolbar
* @version 0.4.0
*/
function initFloatingToolbar() {
if (typeof document === 'undefined')
return;
// Inject floating toolbar CSS once globally
if (!document.getElementById('me-float-style')) {
const style = document.createElement('style');
style.id = 'me-float-style';
style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`;
document.head.appendChild(style);
}
if (!this._floatingEnabled)
return;
const ta = this.textarea;
const show = () => {
if (this._destroyed || this.config.readOnly || !this._floatingEnabled)
return;
const start = ta.selectionStart;
const end = ta.selectionEnd;
if (start === end) {
this._hideFloatingToolbar();
return;
}
if (!this._floatingToolbar || !this._floatingToolbar.parentNode)
this._buildFloatingToolbar();
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', () => {
// selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射
if (ta.selectionStart !== ta.selectionEnd)
setTimeout(show, 0);
else
setTimeout(hide, 0);
});
ta.addEventListener('blur', () => setTimeout(hide, 300));
ta.addEventListener('click', () => {
if (ta.selectionStart === ta.selectionEnd)
hide();
});
}
function toggleFloatingToolbar() {
this._floatingEnabled = !this._floatingEnabled;
if (!this._floatingEnabled) {
if (this._floatingToolbar) {
if (this._floatingToolbar.parentNode)
this._floatingToolbar.parentNode.removeChild(this._floatingToolbar);
this._floatingToolbar = null;
}
}
return this;
}
function isFloatingToolbar() { return this._floatingEnabled; }
function buildFloatingToolbar() {
const bar = document.createElement('div');
bar.className = 'me-float-toolbar';
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;
}
function hideFloatingToolbar() {
if (this._floatingToolbar) {
this._floatingToolbar.classList.remove('me-visible');
}
}
// ============ Prototype installation ============
const installFloatingToolbar = (proto) => {
proto._initFloatingToolbar = initFloatingToolbar;
proto.toggleFloatingToolbar = toggleFloatingToolbar;
proto.isFloatingToolbar = isFloatingToolbar;
proto._buildFloatingToolbar = buildFloatingToolbar;
proto._hideFloatingToolbar = hideFloatingToolbar;
};
/**
* MetonaEditor Context Menu — right-click menu
* @module context-menu
* @version 0.4.0
*/
function registerContextMenu(items = []) {
this._contextMenuItems = items;
return this;
}
function bindContextMenu() {
if (!this.el)
return;
const onContextMenu = (e) => {
const existing = document.querySelector('.me-context-menu');
if (existing)
existing.remove();
this._showContextMenu(e);
};
this.el.addEventListener('contextmenu', onContextMenu);
this._cleanups.push(() => this.el.removeEventListener('contextmenu', onContextMenu));
}
function showContextMenu(e) {
e.preventDefault();
const ta = this.textarea;
const hasSelection = ta && ta.selectionStart !== ta.selectionEnd;
const defaultItems = [
{ label: this.t('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() },
{ label: this.t('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() },
{ sep: true },
{ label: this.t('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
{ label: this.t('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
{ label: this.t('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
{ label: this.t('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' },
];
const items = [...defaultItems];
if (this._contextMenuItems.length) {
items.push({ sep: true });
this._contextMenuItems.forEach((item) => items.push(item));
}
const menu = document.createElement('div');
menu.className = 'me-context-menu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
// Adjust if off-screen
requestAnimationFrame(() => {
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth)
menu.style.left = (e.clientX - rect.width) + 'px';
if (rect.bottom > window.innerHeight)
menu.style.top = (e.clientY - rect.height) + 'px';
});
items.forEach((item) => {
if (item.sep) {
const sep = document.createElement('div');
sep.className = 'me-context-menu-sep';
menu.appendChild(sep);
return;
}
const el = document.createElement('div');
el.className = 'me-context-menu-item';
if (item.disabled)
el.classList.add('me-disabled');
el.innerHTML = `${item.label} ${item.shortcut ? `` : ''}`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
if (item.disabled)
return;
if (item.onClick) {
item.onClick();
}
else if (item.action)
this._execContextAction(item.action);
this._hideContextMenu();
});
menu.appendChild(el);
});
document.body.appendChild(menu);
const close = (ev) => {
if (!menu.contains(ev.target)) {
this._hideContextMenu();
}
};
document.addEventListener('click', close, { once: true });
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape')
this._hideContextMenu(); }, { once: true });
}
function hideContextMenu() {
const menu = document.querySelector('.me-context-menu');
if (menu)
menu.remove();
}
function execContextAction(action) {
const ta = this.textarea;
if (!ta)
return;
switch (action) {
case 'undo':
this.undo();
break;
case 'redo':
this.redo();
break;
case 'cut':
document.execCommand('cut');
break;
case 'copy':
document.execCommand('copy');
break;
case 'paste':
document.execCommand('paste');
break;
case 'selectAll':
ta.focus();
ta.select();
break;
default:
this.exec(action);
break;
}
}
// ============ Prototype installation ============
const installContextMenu = (proto) => {
proto.registerContextMenu = registerContextMenu;
proto._bindContextMenu = bindContextMenu;
proto._showContextMenu = showContextMenu;
proto._hideContextMenu = hideContextMenu;
proto._execContextAction = execContextAction;
};
/**
* MetonaEditor Outline — heading navigation panel
* @module outline
* @version 0.4.0
*/
function buildOutline() {
if (!this.config.outline || !this.previewEl)
return;
const old = this.el.querySelector('.me-outline');
if (old)
old.remove();
const headings = [];
const headingRe = /]*>(.+?)<\/h\1>/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 = `${this.t('outline') || '大纲'}
`;
// 迭代式树构建(栈模拟嵌套 ul),O(n) 而非递归的 O(n²)
const buildTree = (items, minLevel) => {
let h = '';
const stack = [];
const closeTo = (targetLevel) => {
while (stack.length && stack[stack.length - 1] >= targetLevel) {
h += '';
stack.pop();
}
};
for (const item of items) {
if (item.level < minLevel)
continue;
closeTo(item.level);
h += '