'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); /** * MetonaEditor Utils — utility functions * @module utils * @version 0.2.0 */ /** Generate a unique ID */ const generateId = () => { return 'me-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); }; /** HTML-escape a string */ const escapeHTML = (s) => { const str = String(s == null ? '' : s); if (typeof document === 'undefined') { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }; /** Detect dark mode preference */ const prefersDark = () => { if (typeof window === 'undefined' || !window.matchMedia) return false; return window.matchMedia('(prefers-color-scheme: dark)').matches; }; /** Check if running in browser */ const isBrowser = () => { return typeof window !== 'undefined' && typeof document !== 'undefined'; }; /** * MetonaEditor Icons — SVG toolbar icons * @module icons * @version 0.2.0 */ const ICONS = { bold: ``, italic: ``, underline: ``, strikethrough: ``, h1: ``, h2: ``, h3: ``, quote: ``, code: ``, link: ``, image: ``, table: ``, ul: ``, ol: ``, indent: ``, outdent: ``, hr: ``, undo: ``, redo: ``, preview: ``, split: ``, edit: ``, fullscreen: ``, theme: ``, }; /** * MetonaEditor Locales — translation data * @module locales * @version 0.2.3 */ const LOCALES = { 'zh-CN': { bold: '粗体', italic: '斜体', underline: '下划线', strikethrough: '删除线', h1: '标题1', h2: '标题2', h3: '标题3', quote: '引用', code: '代码', link: '链接', image: '图片', table: '表格', ul: '无序列表', ol: '有序列表', indent: '增加缩进', outdent: '减少缩进', hr: '分隔线', undo: '撤销', redo: '重做', edit: '编辑', split: '分屏', preview: '预览', fullscreen: '全屏', fullscreenExit: '退出全屏', theme: '主题', light: '亮色', dark: '暗色', auto: '自动', warm: '暖色', wordCount: '字数统计', characters: '字符', words: '词数', lines: '行数', readingTime: '阅读', minutes: '分钟', placeholder: '开始输入 Markdown...', empty: '暂无内容', copied: '已复制', copyContent: '复制内容', copyHTML: '复制 HTML', copySuccess: '复制成功', copyFailed: '复制失败', clearContent: '清空内容', clearConfirm: '确定要清空所有内容吗?', linkPlaceholder: '请输入链接地址', imagePlaceholder: '请输入图片地址', altPlaceholder: '请输入替代文本', tableRows: '行数', tableCols: '列数', confirm: '确认', cancel: '取消', exportMarkdown: '导出 Markdown', exportHTML: '导出 HTML', search: '搜索', replace: '替换', replaceAll: '全部替换', searchPlaceholder: '查找内容', replacePlaceholder: '替换为', findNext: '查找下一个', findPrev: '查找上一个', matchCase: '区分大小写', wholeWord: '全字匹配', close: '关闭', open: '打开', save: '保存', saved: '已保存', saving: '保存中...', delete: '删除', confirmDelete: '确定要删除吗?', unsavedChanges: '有未保存的更改', error: '错误', success: '成功', warning: '警告', info: '信息', loading: '加载中...', retry: '重试', renderError: '渲染失败', outline: '大纲', cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选', }, 'en-US': { bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough', h1: 'Heading 1', h2: 'Heading 2', h3: 'Heading 3', quote: 'Quote', code: 'Code', link: 'Link', image: 'Image', table: 'Table', ul: 'Bullet List', ol: 'Numbered List', indent: 'Indent', outdent: 'Outdent', hr: 'Horizontal Rule', undo: 'Undo', redo: 'Redo', edit: 'Edit', split: 'Split', preview: 'Preview', fullscreen: 'Fullscreen', fullscreenExit: 'Exit Fullscreen', theme: 'Theme', light: 'Light', dark: 'Dark', auto: 'Auto', warm: 'Warm', wordCount: 'Word Count', characters: 'Characters', words: 'Words', lines: 'Lines', readingTime: 'Reading', minutes: 'min', placeholder: 'Start typing Markdown...', empty: 'No content', copied: 'Copied', copyContent: 'Copy Content', copyHTML: 'Copy HTML', copySuccess: 'Copied successfully', copyFailed: 'Copy failed', clearContent: 'Clear Content', clearConfirm: 'Clear all content?', linkPlaceholder: 'Enter link URL', imagePlaceholder: 'Enter image URL', altPlaceholder: 'Enter alt text', tableRows: 'Rows', tableCols: 'Columns', confirm: 'Confirm', cancel: 'Cancel', exportMarkdown: 'Export Markdown', exportHTML: 'Export HTML', search: 'Search', replace: 'Replace', replaceAll: 'Replace All', searchPlaceholder: 'Find', replacePlaceholder: 'Replace with', findNext: 'Find Next', findPrev: 'Find Previous', matchCase: 'Match Case', wholeWord: 'Whole Word', close: 'Close', open: 'Open', save: 'Save', saved: 'Saved', saving: 'Saving...', delete: 'Delete', confirmDelete: 'Are you sure you want to delete?', unsavedChanges: 'You have unsaved changes', error: 'Error', success: 'Success', warning: 'Warning', info: 'Info', loading: 'Loading...', retry: 'Retry', renderError: 'Render failed', outline: 'Outline', cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All', }, ja: { bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線', h1: '見出し1', h2: '見出し2', h3: '見出し3', quote: '引用', code: 'コード', link: 'リンク', image: '画像', table: '表', ul: '箇条書き', ol: '番号付きリスト', indent: 'インデント', outdent: 'インデント解除', hr: '水平線', undo: '元に戻す', redo: 'やり直し', edit: '編集', split: '分割', preview: 'プレビュー', fullscreen: '全画面', fullscreenExit: '全画面解除', theme: 'テーマ', light: 'ライト', dark: 'ダーク', auto: '自動', warm: 'ウォーム', wordCount: '文字数', characters: '文字', words: '単語', lines: '行', readingTime: '読了時間', minutes: '分', placeholder: 'Markdownを入力...', empty: '内容なし', copied: 'コピー済み', copyContent: '内容をコピー', copyHTML: 'HTMLをコピー', copySuccess: 'コピー成功', copyFailed: 'コピー失敗', clearContent: '内容をクリア', clearConfirm: 'すべての内容をクリアしますか?', linkPlaceholder: 'リンクURLを入力', imagePlaceholder: '画像URLを入力', altPlaceholder: '代替テキストを入力', tableRows: '行', tableCols: '列', confirm: '確認', cancel: 'キャンセル', exportMarkdown: 'Markdownエクスポート', exportHTML: 'HTMLエクスポート', search: '検索', replace: '置換', replaceAll: 'すべて置換', searchPlaceholder: '検索', replacePlaceholder: '置換後', findNext: '次を検索', findPrev: '前を検索', matchCase: '大文字小文字', wholeWord: '単語単位', close: '閉じる', open: '開く', save: '保存', saved: '保存済み', saving: '保存中...', delete: '削除', confirmDelete: '削除してもよろしいですか?', unsavedChanges: '未保存の変更があります', error: 'エラー', success: '成功', warning: '警告', info: '情報', loading: '読み込み中...', retry: '再試行', renderError: 'レンダリング失敗', outline: 'アウトライン', cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択', }, ko: { bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', h1: '제목1', h2: '제목2', h3: '제목3', quote: '인용', code: '코드', link: '링크', image: '이미지', table: '표', ul: '순서 없는 목록', ol: '순서 있는 목록', indent: '들여쓰기', outdent: '내어쓰기', hr: '수평선', undo: '실행 취소', redo: '다시 실행', edit: '편집', split: '분할', preview: '미리보기', fullscreen: '전체 화면', fullscreenExit: '전체 화면 종료', theme: '테마', light: '라이트', dark: '다크', auto: '자동', warm: '웜', wordCount: '글자 수', characters: '글자', words: '단어', lines: '줄', readingTime: '읽기 시간', minutes: '분', placeholder: 'Markdown 입력...', empty: '내용 없음', copied: '복사됨', copyContent: '내용 복사', copyHTML: 'HTML 복사', copySuccess: '복사 성공', copyFailed: '복사 실패', clearContent: '내용 지우기', clearConfirm: '모든 내용을 지우시겠습니까?', linkPlaceholder: '링크 URL 입력', imagePlaceholder: '이미지 URL 입력', altPlaceholder: '대체 텍스트 입력', tableRows: '행', tableCols: '열', confirm: '확인', cancel: '취소', exportMarkdown: 'Markdown 내보내기', exportHTML: 'HTML 내보내기', search: '검색', replace: '바꾸기', replaceAll: '모두 바꾸기', searchPlaceholder: '찾기', replacePlaceholder: '바꿀 내용', findNext: '다음 찾기', findPrev: '이전 찾기', matchCase: '대소문자 구분', wholeWord: '단어 단위', close: '닫기', open: '열기', save: '저장', saved: '저장됨', saving: '저장 중...', delete: '삭제', confirmDelete: '삭제하시겠습니까?', unsavedChanges: '저장되지 않은 변경 사항이 있습니다', error: '오류', success: '성공', warning: '경고', info: '정보', loading: '로딩 중...', retry: '재시도', renderError: '렌더링 실패', outline: '개요', cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택', }, fr: { bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré', h1: 'Titre 1', h2: 'Titre 2', h3: 'Titre 3', quote: 'Citation', code: 'Code', link: 'Lien', image: 'Image', table: 'Tableau', ul: 'Liste à puces', ol: 'Liste numérotée', indent: 'Indenter', outdent: 'Désindenter', hr: 'Ligne horizontale', undo: 'Annuler', redo: 'Rétablir', edit: 'Éditer', split: 'Fractionné', preview: 'Aperçu', fullscreen: 'Plein écran', fullscreenExit: 'Quitter plein écran', theme: 'Thème', light: 'Clair', dark: 'Sombre', auto: 'Auto', warm: 'Chaud', wordCount: 'Nombre de mots', characters: 'Caractères', words: 'Mots', lines: 'Lignes', readingTime: 'Lecture', minutes: 'min', placeholder: 'Commencez à taper Markdown...', empty: 'Aucun contenu', copied: 'Copié', copyContent: 'Copier le contenu', copyHTML: 'Copier HTML', copySuccess: 'Copié avec succès', copyFailed: 'Échec de la copie', clearContent: 'Effacer le contenu', clearConfirm: 'Effacer tout le contenu ?', linkPlaceholder: 'Entrez l\'URL du lien', imagePlaceholder: 'Entrez l\'URL de l\'image', altPlaceholder: 'Entrez le texte alternatif', tableRows: 'Lignes', tableCols: 'Colonnes', confirm: 'Confirmer', cancel: 'Annuler', exportMarkdown: 'Exporter Markdown', exportHTML: 'Exporter HTML', search: 'Rechercher', replace: 'Remplacer', replaceAll: 'Tout remplacer', searchPlaceholder: 'Rechercher', replacePlaceholder: 'Remplacer par', findNext: 'Suivant', findPrev: 'Précédent', matchCase: 'Respecter la casse', wholeWord: 'Mot entier', close: 'Fermer', open: 'Ouvrir', save: 'Enregistrer', saved: 'Enregistré', saving: 'Enregistrement...', delete: 'Supprimer', confirmDelete: 'Confirmer la suppression ?', unsavedChanges: 'Modifications non enregistrées', error: 'Erreur', success: 'Succès', warning: 'Avertissement', info: 'Info', loading: 'Chargement...', retry: 'Réessayer', renderError: 'Échec du rendu', outline: 'Plan', cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner', }, de: { bold: 'Fett', italic: 'Kursiv', underline: 'Unterstrichen', strikethrough: 'Durchgestrichen', h1: 'Überschrift 1', h2: 'Überschrift 2', h3: 'Überschrift 3', quote: 'Zitat', code: 'Code', link: 'Link', image: 'Bild', table: 'Tabelle', ul: 'Aufzählung', ol: 'Nummerierte Liste', indent: 'Einrücken', outdent: 'Ausrücken', hr: 'Trennlinie', undo: 'Rückgängig', redo: 'Wiederholen', edit: 'Bearbeiten', split: 'Geteilt', preview: 'Vorschau', fullscreen: 'Vollbild', fullscreenExit: 'Vollbild beenden', theme: 'Design', light: 'Hell', dark: 'Dunkel', auto: 'Auto', warm: 'Warm', wordCount: 'Wörter', characters: 'Zeichen', words: 'Wörter', lines: 'Zeilen', readingTime: 'Lesezeit', minutes: 'min', placeholder: 'Markdown eingeben...', empty: 'Kein Inhalt', copied: 'Kopiert', copyContent: 'Inhalt kopieren', copyHTML: 'HTML kopieren', copySuccess: 'Erfolgreich kopiert', copyFailed: 'Kopieren fehlgeschlagen', clearContent: 'Inhalt löschen', clearConfirm: 'Gesamten Inhalt löschen?', linkPlaceholder: 'Link-URL eingeben', imagePlaceholder: 'Bild-URL eingeben', altPlaceholder: 'Alternativtext eingeben', tableRows: 'Zeilen', tableCols: 'Spalten', confirm: 'Bestätigen', cancel: 'Abbrechen', exportMarkdown: 'Markdown exportieren', exportHTML: 'HTML exportieren', search: 'Suchen', replace: 'Ersetzen', replaceAll: 'Alle ersetzen', searchPlaceholder: 'Suchen', replacePlaceholder: 'Ersetzen mit', findNext: 'Nächstes', findPrev: 'Vorheriges', matchCase: 'Groß-/Kleinschreibung', wholeWord: 'Ganzes Wort', close: 'Schließen', open: 'Öffnen', save: 'Speichern', saved: 'Gespeichert', saving: 'Speichern...', delete: 'Löschen', confirmDelete: 'Wirklich löschen?', unsavedChanges: 'Ungespeicherte Änderungen', error: 'Fehler', success: 'Erfolg', warning: 'Warnung', info: 'Info', loading: 'Laden...', retry: 'Wiederholen', renderError: 'Rendern fehlgeschlagen', outline: 'Gliederung', cut: 'Ausschneiden', copy: 'Kopieren', paste: 'Einfügen', selectAll: 'Alles auswählen', }, }; /** * MetonaEditor Constants — default configs, themes, animations * @module constants * @version 0.2.0 */ /** Default toolbar button sequence */ const DEFAULT_TOOLBAR = [ 'bold', 'italic', 'strikethrough', 'code', '|', 'h1', 'h2', 'h3', '|', 'quote', 'ul', 'ol', '|', 'link', 'image', 'table', 'hr', '|', 'undo', 'redo', '|', 'edit', 'split', 'preview', 'fullscreen', ]; /** Default options */ const DEFAULTS = Object.freeze({ value: '', placeholder: '', mode: 'split', height: 400, toolbar: DEFAULT_TOOLBAR, wordCount: true, autofocus: false, spellcheck: false, historyLimit: 100, historyDebounce: 400, syncScroll: true, tabSize: 2, readOnly: false, lineNumbers: true, outline: false, autoBrackets: true, zenMode: false, wordWrap: true, maxLength: 0, theme: 'auto', locale: 'zh-CN', render: null, highlight: null, sanitize: null, className: '', style: {}, plugins: [], onChange: null, onInput: null, onFocus: null, onBlur: null, onSave: null, onModeChange: null, onFullscreen: null, onCreate: null, onDestroy: null, onLinkClick: null, floatingToolbar: true, }); const THEMES = { light: { bg: 'rgba(255, 255, 255, 0.96)', text: '#1f2937', border: 'rgba(0, 0, 0, 0.08)', shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.18), 0 4px 14px -4px rgba(0, 0, 0, 0.08)', hoverShadow: '0 14px 48px -10px rgba(0, 0, 0, 0.22), 0 6px 18px -4px rgba(0, 0, 0, 0.10)', toolbarBg: 'rgba(248, 249, 250, 0.92)', textareaBg: '#ffffff', previewBg: '#ffffff', codeBg: 'rgba(243, 244, 246, 1)', codeText: '#1f2937', accent: '#3b82f6', muted: '#6b7280', }, dark: { bg: 'rgba(28, 32, 40, 0.94)', text: '#e6e8eb', border: 'rgba(255, 255, 255, 0.1)', shadow: '0 10px 36px -10px rgba(0, 0, 0, 0.6), 0 4px 14px -4px rgba(0, 0, 0, 0.4)', hoverShadow: '0 14px 48px -8px rgba(0, 0, 0, 0.6), 0 6px 18px -4px rgba(0, 0, 0, 0.4)', toolbarBg: 'rgba(22, 26, 33, 0.92)', textareaBg: '#1c2028', previewBg: '#1c2028', codeBg: 'rgba(15, 18, 24, 1)', codeText: '#e6e8eb', accent: '#60a5fa', muted: '#9ca3af', }, auto: 'auto', warm: { bg: 'rgba(255, 251, 235, 0.96)', text: '#78350f', border: 'rgba(245, 158, 11, 0.2)', shadow: '0 10px 36px -10px rgba(245, 158, 11, 0.18), 0 4px 14px -4px rgba(245, 158, 11, 0.08)', hoverShadow: '0 14px 48px -10px rgba(245, 158, 11, 0.22), 0 6px 18px -4px rgba(245, 158, 11, 0.10)', toolbarBg: 'rgba(254, 243, 199, 0.92)', textareaBg: '#fffbeb', previewBg: '#fffbeb', codeBg: 'rgba(254, 215, 170, 0.6)', codeText: '#78350f', accent: '#d97706', muted: '#a16207', progressBg: 'rgba(217, 119, 6, 0.15)', closeHoverBg: 'rgba(217, 119, 6, 0.2)', }, }; const ANIMATIONS = { slide: { enter: { transform: 'translateX(80px)', opacity: '0' }, leave: { transform: 'translateX(120%)', opacity: '0' }, duration: 400, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }, fade: { enter: { opacity: '0', filter: 'blur(3px)' }, leave: { opacity: '0', filter: 'blur(3px)' }, duration: 500, easing: 'ease' }, scale: { enter: { transform: 'scale(0.55)', opacity: '0' }, leave: { transform: 'scale(0.55)', opacity: '0' }, duration: 450, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' }, bounce: { enter: { transform: 'translateY(-80px)', opacity: '0' }, leave: { transform: 'translateY(20px)', opacity: '0' }, duration: 650, easing: 'ease' }, flip: { enter: { transform: 'perspective(500px) rotateX(-90deg)', opacity: '0' }, leave: { transform: 'perspective(500px) rotateX(90deg)', opacity: '0' }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }, rotate: { enter: { transform: 'rotate(-25deg) scale(0.6)', opacity: '0' }, leave: { transform: 'rotate(25deg) scale(0.6)', opacity: '0' }, duration: 500, easing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' }, zoom: { enter: { transform: 'scale(0.1)', opacity: '0' }, leave: { transform: 'scale(0.1)', opacity: '0' }, duration: 500, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' }, }; const EDIT_MODES = ['edit', 'split', 'preview']; const TOOLBAR_ACTIONS = [ 'bold', 'italic', 'strikethrough', 'underline', 'code', 'h1', 'h2', 'h3', 'quote', 'ul', 'ol', 'indent', 'outdent', 'hr', 'link', 'image', 'table', 'undo', 'redo', 'edit', 'split', 'preview', 'fullscreen', ]; /** * MetonaEditor Styles — CSS-in-JS injection * @module styles * @version 0.2.0 */ let styleElement = null; const generateCSS = () => { return `:root{--md-bg:#ffffff;--md-text:#1f2937;--md-border:rgba(0,0,0,0.08);--md-shadow:0 10px 36px -10px rgba(0,0,0,0.18),0 4px 14px -4px rgba(0,0,0,0.08);--md-hover-shadow:0 14px 48px -10px rgba(0,0,0,0.22),0 6px 18px -4px rgba(0,0,0,0.10);--md-toolbar-bg:rgba(248,249,250,0.92);--md-textarea-bg:#ffffff;--md-preview-bg:#ffffff;--md-code-bg:rgba(243,244,246,1);--md-code-text:#1f2937;--md-accent:#3b82f6;--md-muted:#6b7280;--md-radius:10px;--md-font:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei","Hiragino Sans GB",sans-serif;--md-mono:"SF Mono","Cascadia Code","Consolas","Liberation Mono","Courier New",monospace} .me-wrapper{display:flex;flex-direction:column;box-sizing:border-box;border:1px solid var(--md-border);border-radius:var(--md-radius);overflow:hidden;background:var(--md-bg);color:var(--md-text);box-shadow:var(--md-shadow);font-family:var(--md-font);font-size:14px;line-height:1.6;position:relative;transition:box-shadow .25s,border-color .25s;width:100%} .me-wrapper:hover{box-shadow:var(--md-hover-shadow)} .me-wrapper.me-disabled{opacity:.6;pointer-events:none} .me-wrapper *{box-sizing:border-box} .me-wrapper.me-fullscreen{position:fixed;top:0;left:0;right:0;bottom:0;width:100vw;height:100vh;z-index:9999;border-radius:0;border:0} .me-toolbar{display:flex;align-items:center;flex-wrap:wrap;gap:2px;padding:6px 8px;background:var(--md-toolbar-bg);border-bottom:1px solid var(--md-border);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);min-height:40px} .me-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--md-text);cursor:pointer;transition:background .15s,color .15s,transform .1s;flex-shrink:0} .me-btn:hover{background:var(--md-code-bg);color:var(--md-accent)} .me-btn:active{transform:scale(.92)} .me-btn.me-active{background:var(--md-accent);color:#fff} .me-btn:disabled{opacity:.35;cursor:not-allowed;pointer-events:none} .me-btn:focus-visible{outline:2px solid var(--md-accent);outline-offset:2px} .me-btn:focus:not(:focus-visible){outline:none} .me-btn svg{width:17px;height:17px;display:block} .me-btn span{font-size:12px;font-weight:500} .me-toolbar-sep{display:inline-block;width:1px;height:20px;background:var(--md-border);margin:0 4px;flex-shrink:0} .me-toolbar-group{display:inline-flex;gap:2px;margin-left:auto;padding-left:6px;border-left:1px solid var(--md-border)} .me-toolbar-group .me-btn{width:auto;padding:0 8px} .me-body{display:flex;flex:1;min-height:0;position:relative} .me-editor-pane,.me-preview-pane{flex:1 1 50%;min-width:0;overflow:hidden;position:relative} .me-editor-inner{display:flex;height:100%;overflow:hidden;position:relative} .me-gutter{flex:0 0 auto;min-width:36px;padding:16px 8px 16px 6px;overflow:hidden;background:var(--md-code-bg);border-right:1px solid var(--md-border);color:var(--md-muted);font-family:var(--md-mono);font-size:13.5px;line-height:1.7;text-align:right;user-select:none;white-space:pre} .me-gutter-line{padding-right:4px} .me-gutter-active{color:var(--md-accent);font-weight:600;background:rgba(59,130,246,.08);border-radius:3px} .me-divider{flex:0 0 1px;background:var(--md-border);cursor:col-resize;position:relative} .me-divider::after{content:'';position:absolute;top:0;bottom:0;left:-3px;right:-3px} .me-divider:hover{background:var(--md-accent)} .me-textarea{width:100%;height:100%;padding:16px 18px;border:0;outline:0;resize:none;background:var(--md-textarea-bg);color:var(--md-text);font-family:var(--md-mono);font-size:13.5px;line-height:1.7;display:block;white-space:pre-wrap;word-wrap:break-word;position:relative;z-index:1} .me-textarea::placeholder{color:var(--md-muted);opacity:.7} .me-textarea:focus{outline:0} .me-preview-pane{overflow:auto;background:var(--md-preview-bg)} .me-preview{padding:18px 22px;max-width:100%;word-wrap:break-word;overflow-wrap:break-word} .me-body.me-mode-edit .me-preview-pane,.me-body.me-mode-edit .me-divider{display:none} .me-body.me-mode-edit .me-editor-pane{flex:1 1 100%} .me-body.me-mode-preview .me-editor-pane,.me-body.me-mode-preview .me-divider{display:none} .me-body.me-mode-preview .me-preview-pane{flex:1 1 100%} .me-preview>:first-child{margin-top:0} .me-preview>:last-child{margin-bottom:0} .me-preview h1,.me-preview h2,.me-preview h3,.me-preview h4,.me-preview h5,.me-preview h6{margin:1.4em 0 .6em;font-weight:650;line-height:1.3} .me-preview h1{font-size:1.9em;padding-bottom:.3em;border-bottom:1px solid var(--md-border)} .me-preview h2{font-size:1.55em;padding-bottom:.3em;border-bottom:1px solid var(--md-border)} .me-preview h3{font-size:1.3em} .me-preview h4{font-size:1.12em} .me-preview h5{font-size:1em} .me-preview h6{font-size:.9em;color:var(--md-muted)} .me-preview p{margin:.7em 0} .me-preview a{color:var(--md-accent);text-decoration:none} .me-preview a:hover{text-decoration:underline} .me-preview strong{font-weight:650} .me-preview em{font-style:italic} .me-preview del{text-decoration:line-through;opacity:.75} .me-preview ul,.me-preview ol{margin:.6em 0;padding-left:1.6em} .me-preview li{margin:.25em 0} .me-preview li.me-task-item{list-style:none;margin-left:-1.4em} .me-preview li.me-task-item input{margin-right:.5em;vertical-align:middle} .me-preview blockquote{margin:.8em 0;padding:.4em 1em;border-left:3px solid var(--md-accent);background:var(--md-code-bg);border-radius:0 6px 6px 0;color:var(--md-text)} .me-preview blockquote>:first-child{margin-top:0} .me-preview blockquote>:last-child{margin-bottom:0} .me-preview hr{border:0;height:1px;background:var(--md-border);margin:1.6em 0} .me-preview code{font-family:var(--md-mono);font-size:.88em;padding:.15em .4em;background:var(--md-code-bg);color:var(--md-code-text);border-radius:4px} .me-preview pre{margin:.9em 0;padding:14px 16px;background:var(--md-code-bg);border-radius:8px;overflow-x:auto;border:1px solid var(--md-border)} .me-code-title{font-family:var(--md-mono);font-size:.85em;padding:8px 14px;background:var(--md-border);border-radius:8px 8px 0 0;color:var(--md-text);margin:.9em 0 -0.9em;font-weight:600} .me-code-title+pre{margin-top:0;border-radius:0 0 8px 8px;border-top:none} .me-preview pre code{padding:0;background:transparent;color:var(--md-code-text);font-size:.9em;line-height:1.6;border-radius:0} .me-preview img{max-width:100%;height:auto;border-radius:6px;vertical-align:middle} .me-table-wrap{overflow-x:auto;margin:.9em 0} .me-preview table{border-collapse:collapse;width:100%;font-size:.93em;display:block} .me-preview th,.me-preview td{border:1px solid var(--md-border);padding:7px 12px;text-align:left} .me-preview th{background:var(--md-code-bg);font-weight:600} .me-preview tr:nth-child(even) td{background:var(--md-code-bg)} .me-statusbar{display:flex;align-items:center;justify-content:flex-end;gap:14px;padding:4px 12px;border-top:1px solid var(--md-border);background:var(--md-toolbar-bg);color:var(--md-muted);font-size:12px;min-height:26px} .me-statusbar:empty{display:none} .me-wrapper:focus-within{border-color:var(--md-accent)} .me-textarea:focus-visible{outline:0} @media(prefers-reduced-motion:reduce){.me-wrapper,.me-btn,.me-textarea{transition:none!important}} .me-wrapper.me-fullscreen .me-preview,.me-wrapper.me-fullscreen .me-textarea{font-size:15px} .me-wrapper.me-readonly .me-textarea{background:var(--md-code-bg);cursor:default;opacity:.88} .me-wrapper.me-readonly .me-toolbar{opacity:.75} .me-wrapper.me-readonly .me-btn:disabled{opacity:.3;cursor:not-allowed;pointer-events:none} .me-preview mark{background:rgba(250,204,21,.3);color:inherit;padding:.1em .2em;border-radius:3px} .me-preview sup{font-size:.75em;vertical-align:super;line-height:1} .me-preview sub{font-size:.75em;vertical-align:sub;line-height:1} .me-preview .me-math-block{display:block;margin:1.2em 0;padding:12px 16px;background:var(--md-code-bg);border-radius:8px;overflow-x:auto;font-family:var(--md-mono);font-size:.95em;text-align:center} .me-preview .me-math-inline{font-family:var(--md-mono);font-size:.95em;padding:.05em .2em} .me-preview dl{margin:.8em 0} .me-preview dt{font-weight:650;margin:.6em 0 .2em} .me-preview dd{margin:0 0 .3em 1.6em;color:var(--md-text)} .me-preview .me-footnote-ref a{font-size:.75em;vertical-align:super;text-decoration:none;color:var(--md-accent)} .me-preview .me-footnotes{margin-top:2em;border-top:1px solid var(--md-border);padding-top:.8em;font-size:.9em;color:var(--md-muted)} .me-preview .me-footnotes hr{display:none} .me-preview .me-footnotes ol{padding-left:1.2em} .me-preview .me-footnote-item{margin:.3em 0} .me-preview .me-footnote-backref{text-decoration:none;color:var(--md-accent);margin-right:.4em} .me-toast{position:absolute;bottom:16px;right:16px;z-index:30;display:flex;align-items:center;gap:8px;padding:10px 16px;border-radius:8px;font-size:13px;pointer-events:auto;cursor:pointer;opacity:0;transform:translateY(12px);transition:opacity .3s,transform .3s;box-shadow:0 6px 20px rgba(0,0,0,.15);max-width:360px} .me-toast.me-toast-enter{opacity:1;transform:translateY(0)} .me-toast.me-toast-leave{opacity:0;transform:translateY(12px)} .me-toast-success{background:#10b981;color:#fff} .me-toast-error{background:#ef4444;color:#fff} .me-toast-warning{background:#f59e0b;color:#fff} .me-toast-info{background:var(--md-accent,#3b82f6);color:#fff} .me-toast-icon{font-weight:700;font-size:16px;flex-shrink:0} .me-toast-msg{line-height:1.4} .me-context-menu{position:fixed;z-index:40;min-width:180px;padding:4px 0;background:var(--md-bg);border:1px solid var(--md-border);border-radius:8px;box-shadow:0 8px 28px rgba(0,0,0,.18);font-size:13px} .me-context-menu-item{display:flex;align-items:center;justify-content:space-between;padding:6px 14px;cursor:pointer;color:var(--md-text);transition:background .1s} .me-context-menu-item:hover{background:var(--md-code-bg);color:var(--md-accent)} .me-context-menu-sep{height:1px;background:var(--md-border);margin:4px 0} .me-context-menu-shortcut{color:var(--md-muted);font-size:11px;margin-left:24px} .me-context-menu-item.me-disabled{opacity:.4;cursor:not-allowed;pointer-events:none} .me-outline{position:absolute;top:0;right:0;width:220px;height:100%;overflow-y:auto;background:var(--md-preview-bg);border-left:1px solid var(--md-border);padding:12px 14px;font-size:13px;z-index:15} .me-outline-title{font-weight:650;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--md-border);color:var(--md-text)} .me-outline ul{list-style:none;padding:0;margin:0} .me-outline li{margin:2px 0} .me-outline a{color:var(--md-muted);text-decoration:none;display:block;padding:2px 6px;border-radius:4px;transition:background .1s,color .1s} .me-outline a:hover{background:var(--md-code-bg);color:var(--md-accent)} .me-outline a.me-outline-active{background:var(--md-accent);color:#fff;font-weight:600} .me-outline-l1 a{font-weight:600;color:var(--md-text)} .me-outline-l2 a{padding-left:12px} .me-outline-l3 a{padding-left:20px;font-size:12px} .me-outline-l4 a{padding-left:28px;font-size:12px} .me-wrapper.me-zen .me-body{max-width:820px;margin:0 auto} .me-wrapper.me-zen .me-textarea{font-size:15px;line-height:1.8} .me-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} @media print{.me-wrapper{border:0!important;box-shadow:none!important} .me-toolbar,.me-statusbar,.me-divider{display:none!important} .me-body.me-mode-split .me-editor-pane{display:none} .me-body.me-mode-split .me-preview-pane{flex:1 1 100%!important} .me-preview{padding:0!important;font-size:11pt} .me-wrapper.me-fullscreen{position:static!important;width:auto!important;height:auto!important}} [dir=rtl] .me-gutter{border-right:none;border-left:1px solid var(--md-border);text-align:left} [dir=rtl] .me-gutter-line{padding-right:0;padding-left:4px} [dir=rtl] .me-toolbar-group{margin-left:0;margin-right:auto;padding-left:0;padding-right:6px;border-left:none;border-right:1px solid var(--md-border)} [dir=rtl] .me-outline{right:auto;left:0;border-left:none;border-right:1px solid var(--md-border)} [dir=rtl] .me-outline-l2 a{padding-left:0;padding-right:12px} [dir=rtl] .me-outline-l3 a{padding-left:0;padding-right:20px} [dir=rtl] .me-outline-l4 a{padding-left:0;padding-right:28px} [dir=rtl] .me-preview blockquote{border-left:none;border-right:3px solid var(--md-accent);border-radius:6px 0 0 6px} [dir=rtl] .me-preview ul,[dir=rtl] .me-preview ol{padding-left:0;padding-right:1.6em} [dir=rtl] .me-preview li.me-task-item{margin-left:0;margin-right:-1.4em} [dir=rtl] .me-preview dd{margin-left:0;margin-right:1.6em} [dir=rtl] .me-preview .me-footnotes ol{padding-left:0;padding-right:1.2em} [dir=rtl] .me-preview .me-footnote-backref{margin-right:0;margin-left:.4em} [dir=rtl] .me-search{right:auto;left:12px} [dir=rtl] .me-toast{right:auto;left:16px} [dir=rtl] .me-context-menu-shortcut{margin-left:0;margin-right:24px} [dir=rtl] .me-statusbar{justify-content:flex-start}`; }; const injectStyles = () => { if (typeof document === 'undefined') return; if (styleElement && document.getElementById('metona-editor-styles')) return; const css = generateCSS(); styleElement = document.createElement('style'); styleElement.id = 'metona-editor-styles'; styleElement.textContent = css; document.head.appendChild(styleElement); }; /** * MetonaEditor i18n — internationalization * @module i18n * @version 0.2.0 */ let currentLocale = 'zh-CN'; let localeListeners = new Set(); let fallbackLocale = 'zh-CN'; const pluralRules = { zh: () => 'other', en: (n) => n === 1 ? 'one' : 'other', ru: (n) => { const m = n % 10, h = n % 100; if (m === 1 && h !== 11) return 'one'; if (m >= 2 && m <= 4 && !(h >= 12 && h <= 14)) return 'few'; if (m === 0 || (m >= 5 && m <= 9) || (h >= 11 && h <= 14)) return 'many'; return 'other'; }, }; const getPluralForm = (locale, count) => { const lang = locale.split('-')[0].toLowerCase(); const rule = pluralRules[lang] || pluralRules.en; return rule(Math.abs(count)); }; const getCurrentLocale = () => currentLocale; const setCurrentLocale = (locale) => { let target = locale; if (!LOCALES[target]) { console.warn(`Locale "${target}" not found, falling back to "${fallbackLocale}"`); target = fallbackLocale; } currentLocale = target; notifyLocaleListeners(target); saveLocale(target); }; const t = (key, params = {}, locale) => { const loc = locale || currentLocale; let translation = getTranslation(loc, key); if (translation && typeof translation === 'object' && params.count !== undefined) { const form = getPluralForm(loc, params.count); translation = translation[form] || translation.other || key; } if (typeof translation === 'string') return interpolate(translation, params); if (loc !== fallbackLocale) { let fb = getTranslation(fallbackLocale, key); if (fb && typeof fb === 'object' && params.count !== undefined) { const form = getPluralForm(fallbackLocale, params.count); fb = fb[form] || fb.other; } if (typeof fb === 'string') return interpolate(fb, params); } console.warn(`Translation missing for key "${key}" in locale "${loc}"`); return key; }; const getTranslation = (locale, key) => { const localeData = LOCALES[locale]; if (!localeData) return undefined; const keys = key.split('.'); let result = localeData; for (const k of keys) { if (result && typeof result === 'object' && k in result) { result = result[k]; } else return undefined; } return result; }; const interpolate = (str, params) => { return str.replace(/\{(\w+)\}/g, (_match, key) => params[key] !== undefined ? String(params[key]) : _match); }; const hasTranslation = (key) => { return getTranslation(currentLocale, key) !== undefined || getTranslation(fallbackLocale, key) !== undefined; }; const getTranslations = (locale) => LOCALES[locale] || {}; const addTranslations = (locale, translations) => { if (!LOCALES[locale]) LOCALES[locale] = {}; deepMergeI18n(LOCALES[locale], translations); }; const loadRemote = async (url, locale) => { try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); addTranslations(locale, data); return true; } catch (e) { console.error(`MeEditor: failed to load locale "${locale}" from ${url}`, e); return false; } }; const deepMergeI18n = (target, source) => { for (const key in source) { if (source[key] instanceof Object && key in target && target[key] instanceof Object && !Array.isArray(source[key])) { deepMergeI18n(target[key], source[key]); } else { target[key] = source[key]; } } return target; }; const getSupportedLocales = () => Object.keys(LOCALES); const isLocaleSupported = (locale) => locale in LOCALES; const getLocaleName = (locale) => { const names = { 'zh-CN': '简体中文', 'zh-TW': '繁體中文', 'en-US': 'English (US)', 'en-GB': 'English (UK)', ja: '日本語', ko: '한국어', fr: 'Français', de: 'Deutsch', es: 'Español', pt: 'Português', ru: 'Русский', ar: 'العربية', hi: 'हिन्दी', th: 'ไทย', vi: 'Tiếng Việt', id: 'Bahasa Indonesia', tr: 'Türkçe', it: 'Italiano', nl: 'Nederlands', pl: 'Polski', }; return names[locale] || locale; }; const getLocaleDirection = (locale) => { const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug', 'dv', 'ku', 'syr']; const short = locale.split('-')[0].toLowerCase(); return rtlLocales.includes(short) ? 'rtl' : 'ltr'; }; const formatNumber = (number, options = {}) => { try { return new Intl.NumberFormat(currentLocale, options).format(number); } catch (_) { return String(number); } }; const formatCurrency = (amount, currency = 'USD', options = {}) => { try { return new Intl.NumberFormat(currentLocale, { style: 'currency', currency, ...options }).format(amount); } catch (_) { return String(amount); } }; const formatDate = (date, options = {}) => { try { const d = date instanceof Date ? date : new Date(date); return new Intl.DateTimeFormat(currentLocale, options).format(d); } catch (_) { return String(date); } }; const addLocaleListener = (fn) => { localeListeners.add(fn); return () => { localeListeners.delete(fn); }; }; const removeLocaleListener = (fn) => { localeListeners.delete(fn); }; const clearLocaleListeners = () => { localeListeners.clear(); }; const notifyLocaleListeners = (locale) => { localeListeners.forEach((fn) => { try { fn(locale); } catch (e) { console.error('Locale listener error:', e); } }); }; const saveLocale = (locale) => { if (typeof localStorage !== 'undefined') { try { localStorage.setItem('metona-editor-locale', locale); } catch (_) { } } }; const loadLocale = () => { if (typeof localStorage !== 'undefined') { try { return localStorage.getItem('metona-editor-locale') || getDefaultLocale(); } catch (_) { } } return getDefaultLocale(); }; const getDefaultLocale = () => { if (typeof navigator !== 'undefined') { const browserLocale = navigator.language || navigator.userLanguage; if (browserLocale && isLocaleSupported(browserLocale)) return browserLocale; const short = browserLocale?.split('-')[0]; if (short && isLocaleSupported(short)) return short; } return fallbackLocale; }; const initI18n = () => { const saved = loadLocale(); setCurrentLocale(saved); }; const switchLocale = (locale) => { setCurrentLocale(locale); }; const createInstanceI18n = (editor) => { let instanceLocale = editor.config?.locale || currentLocale || 'zh-CN'; const instanceT = (key, params = {}) => t(key, params, instanceLocale); const set = (locale) => { if (!locale || instanceLocale === locale) return instanceLocale; instanceLocale = locale; if (editor.el) { editor.el.setAttribute('lang', locale); editor.el.setAttribute('dir', getLocaleDirection(locale)); } if (editor.textarea) { editor.textarea.placeholder = instanceT('placeholder') || ''; editor.textarea.setAttribute('aria-label', instanceT('edit') || ''); editor.textarea.setAttribute('dir', getLocaleDirection(locale)); } if (typeof editor._emit === 'function') { editor._emit('localeChange', { locale, direction: getLocaleDirection(locale) }); } if (editor.toolbarEl) { editor.toolbarEl.querySelectorAll('.me-btn').forEach((btn) => { const action = btn.dataset.action || btn.dataset.mode; if (action) { const label = instanceT(action); if (label && label !== action) { btn.title = label; btn.setAttribute('aria-label', label); } } }); } return instanceLocale; }; return { set, get: () => instanceLocale, getDirection: () => getLocaleDirection(instanceLocale), t: instanceT, formatNumber: (n, opts) => { try { return new Intl.NumberFormat(instanceLocale, opts).format(n); } catch (_) { return String(n); } }, formatDate: (d, opts) => { try { return new Intl.DateTimeFormat(instanceLocale, opts).format(d instanceof Date ? d : new Date(d)); } catch (_) { return String(d); } }, }; }; const createI18nManager = () => ({ t, getCurrentLocale, setCurrentLocale, switchLocale, getFallbackLocale: () => fallbackLocale, setFallbackLocale: (locale) => { fallbackLocale = locale; }, hasTranslation, getTranslations, addTranslations, loadRemote, getSupportedLocales, isLocaleSupported, getLocaleName, getLocaleDirection, formatNumber, formatCurrency, formatDate, addLocaleListener, removeLocaleListener, clearLocaleListeners, initI18n, saveLocale, loadLocale, getDefaultLocale, createInstanceI18n, }); ({ 'zh-CN': { name: '简体中文', nativeName: '简体中文', direction: 'ltr', translations: LOCALES['zh-CN'] }, 'en-US': { name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', translations: LOCALES['en-US'] }, ja: { name: '日本語', nativeName: '日本語', direction: 'ltr', translations: LOCALES['ja'] }, ko: { name: '한국어', nativeName: '한국어', direction: 'ltr', translations: LOCALES['ko'] }, fr: { name: 'Français', nativeName: 'Français', direction: 'ltr', translations: LOCALES['fr'] }, de: { name: 'Deutsch', nativeName: 'Deutsch', direction: 'ltr', translations: LOCALES['de'] }, }); const i18nUtils = createI18nManager(); /** * MetonaEditor Parser — lightweight Markdown parser (TypeScript) * @module parser * @version 0.2.0 */ // ============ Precompiled regex ============ const RE_EMPTY = /^\s*$/; const RE_ATX = /^(#{1,6})\s+(.+?)(?:\s+#{1,6})?\s*$/; const RE_SETEXT_H1 = /^={3,}\s*$/; const RE_SETEXT_H2 = /^-{3,}\s*$/; const RE_FENCE_START = /^(\s{0,3})(`{3,}|~{3,})\s*([\w+#.-]*)(\s+[^\n]*?)?\s*$/; const RE_HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/; const RE_QUOTE = /^\s{0,3}>\s?/; const RE_UL = /^(\s*)([-*+])\s/; const RE_OL = /^(\s*)(\d+)\.\s/; const RE_TASK = /^\[([ xX])\]\s+(.*)$/; const RE_TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/; const RE_INDENT_CODE = /^( {4}|\t)/; const RE_DEF_LIST = /^:\s+/; const RE_FOOTNOTE_DEF = /^\[\^([^\]]+)\]:\s*/; // ============ Emoji map ============ const EMOJI_MAP = { smile: '😊', grinning: '😀', joy: '😂', rofl: '🤣', wink: '😉', blush: '😊', innocent: '😇', heart_eyes: '😍', kissing_heart: '😘', yum: '😋', stuck_out_tongue: '😛', sunglasses: '😎', smirk: '😏', unamused: '😒', disappointed: '😞', worried: '😟', confused: '😕', cry: '😢', sob: '😭', scream: '😱', angry: '😠', rage: '💢', triumph: '😤', sleepy: '😪', dizzy_face: '😵', zipper_mouth: '🤐', nerd: '🤓', thinking: '🤔', rolling_eyes: '🙄', expressionless: '😑', thumbsup: '👍', thumbsdown: '👎', clap: '👏', pray: '🙏', muscle: '💪', ok: '👌', point_up: '👆', point_down: '👇', point_left: '👈', point_right: '👉', raised_hands: '🙌', wave: '👋', punch: '👊', crossed_fingers: '🤞', heart: '❤️', broken_heart: '💔', star: '⭐', star2: '🌟', fire: '🔥', rocket: '🚀', check: '✅', cross: '❌', warning: '⚠️', info: 'ℹ️', question: '❓', exclamation: '❗', bangbang: '‼️', grey_exclamation: '❕', bulb: '💡', book: '📖', memo: '📝', pin: '📌', link: '🔗', lock: '🔒', unlock: '🔓', key: '🔑', hammer: '🔨', wrench: '🔧', gear: '⚙️', tools: '🛠️', magnet: '🧲', zap: '⚡', cloud: '☁️', sun: '☀️', moon: '🌙', rain: '🌧️', snow: '❄️', umbrella: '☂️', rainbow: '🌈', tornado: '🌪️', fog: '🌫️', droplet: '💧', coffee: '☕', pizza: '🍕', cake: '🎂', beer: '🍺', wine: '🍷', hamburger: '🍔', fries: '🍟', apple: '🍎', banana: '🍌', grapes: '🍇', watermelon: '🍉', strawberry: '🍓', peach: '🍑', cherry: '🍒', taco: '🌮', tada: '🎉', gift: '🎁', crown: '👑', gem: '💎', ring: '💍', confetti: '🎊', balloon: '🎈', ribbon: '🎀', medal: '🏅', trophy: '🏆', eye: '👁️', ear: '👂', nose: '👃', tongue: '👅', lips: '👄', brain: '🧠', speech: '💬', thought: '💭', anger: '💢', sweat: '💦', one: '1️⃣', two: '2️⃣', three: '3️⃣', four: '4️⃣', five: '5️⃣', six: '6️⃣', seven: '7️⃣', eight: '8️⃣', nine: '9️⃣', zero: '0️⃣', arrow_up: '⬆️', arrow_down: '⬇️', arrow_left: '⬅️', arrow_right: '➡️', arrow_upper_right: '↗️', arrow_lower_right: '↘️', arrow_lower_left: '↙️', arrow_upper_left: '↖️', computer: '💻', phone: '📱', battery: '🔋', electric_plug: '🔌', copyright: '©️', registered: '®️', tm: '™️', hundred: '💯', boom: '💥', dash: '💨', hole: '🕳️', bomb: '💣', email: '📧', phone2: '📞', clock: '🕐', hourglass: '⏳', calendar: '📅', money: '💰', shopping: '🛒', package: '📦', mailbox: '📫', art: '🎨', music: '🎵', movie: '🎬', game: '🎮', sport: '⚽', earth: '🌍', house: '🏠', car: '🚗', airplane: '✈️', ship: '🚢', }; // ============ Render cache ============ const renderCache = new Map(); const MAX_CACHE_SIZE = 300; const cachedRenderInline = (text, env) => { if (!text) return ''; if (!env.highlight && text.length < 600) { const cached = renderCache.get(text); if (cached !== undefined) return cached; const result = renderInline(text, env); if (renderCache.size >= MAX_CACHE_SIZE) { const firstKey = renderCache.keys().next().value; renderCache.delete(firstKey); } renderCache.set(text, result); return result; } return renderInline(text, env); }; const clearRenderCache = () => { renderCache.clear(); }; // ============ Security utils ============ const safeUrl = (url) => { if (!url) return ''; const u = String(url).trim(); if (/^(javascript|vbscript|file):/i.test(u)) return ''; if (/^data:/i.test(u)) { if (!/^data:image\//i.test(u)) return ''; if (u.length > 500000) return ''; } return u; }; const slugify = (text) => { let slug = String(text).normalize('NFKC').toLowerCase() .replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf\s-]/g, '') .trim().replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); return slug || 'heading'; }; // ============ Block handler registry ============ const blockHandlers = []; const registerBlockHandler = (handler) => { blockHandlers.push(handler); blockHandlers.sort((a, b) => a.priority - b.priority); }; const isBlockStart = (line) => { if (RE_EMPTY.test(line) || RE_FENCE_START.test(line) || RE_ATX.test(line)) return true; if (RE_HR.test(line) || RE_QUOTE.test(line) || RE_UL.test(line) || RE_OL.test(line)) return true; if (RE_FOOTNOTE_DEF.test(line) || RE_DEF_LIST.test(line)) return true; if (/^\$\$/.test(line)) return true; return false; }; // Register all built-in block handlers registerBlockHandler({ name: 'refDef', priority: 0.1, test: (line) => { // Must not match footnote definitions: [^id]: text if (/^\[\^/.test(line)) return null; const m = line.match(/^\[([^\]]+)\]:\s*(?:<(\S+)>|(\S+))(?:\s+['"\(](.*?)['"\)])?\s*$/); return m ? m : null; }, parse: (_lines, i, match, _tokens, _footnotes, refs) => { const refId = match[1].toLowerCase(); const url = match[2] || match[3] || ''; const title = match[4] || ''; if (url && refs) { refs[refId] = JSON.stringify({ url, title }); } return { token: null, newIndex: i + 1 }; }, }); registerBlockHandler({ name: 'blank', priority: 0, test: (line) => RE_EMPTY.test(line) ? true : null, parse: (_lines, i) => ({ token: null, newIndex: i + 1 }), }); registerBlockHandler({ name: 'fencedCode', priority: 1, test: (line) => { const m = line.match(RE_FENCE_START); if (!m) return null; // Reject fences where the info string contains backticks (false positives like ``` `code` ```) const rest = (m[4] || ''); if (rest.includes('`')) return null; return m; }, parse: (lines, i, match) => { const fenceChar = match[2][0]; const fenceLen = match[2].length; const lang = match[3] || ''; const rest = (match[4] || '').trim(); // Parse info string: first word is lang, rest are key=value or standalone flags const infoParts = rest ? rest.split(/\s+/).filter(Boolean) : []; const attrs = {}; for (let p = 0; p < infoParts.length; p++) { const kv = infoParts[p].split('='); if (kv.length === 2) attrs[kv[0]] = kv[1]; else attrs[infoParts[p]] = 'true'; } const codeLines = []; let j = i + 1; while (j < lines.length) { const cm = lines[j].match(/^(\s{0,3})(`{3,}|~{3,})\s*$/); if (cm && cm[2][0] === fenceChar && cm[2].length >= fenceLen) break; codeLines.push(lines[j]); j++; } if (j < lines.length) j++; return { token: { type: 'code', lang, content: codeLines.join('\n'), attrs }, newIndex: j }; }, }); registerBlockHandler({ name: 'indentedCode', priority: 2, test: (line) => { if (!RE_INDENT_CODE.test(line)) return null; if (RE_ATX.test(line) || RE_QUOTE.test(line) || RE_UL.test(line) || RE_OL.test(line)) return null; return true; }, parse: (lines, i) => { const codeLines = []; while (i < lines.length && RE_INDENT_CODE.test(lines[i])) { codeLines.push(lines[i].replace(/^( {4}|\t)/, '')); i++; } if (codeLines.length) return { token: { type: 'code', lang: '', content: codeLines.join('\n'), indent: true }, newIndex: i }; return { token: null, newIndex: i }; }, }); registerBlockHandler({ name: 'atxHeading', priority: 3, test: (line) => line.match(RE_ATX), parse: (_lines, i, match) => ({ token: { type: 'heading', level: match[1].length, text: match[2] }, newIndex: i + 1, }), }); registerBlockHandler({ name: 'setextH1', priority: 4, test: (line, lines, i) => { if (i + 1 >= lines.length) return null; if (!RE_SETEXT_H1.test(lines[i + 1])) return null; if (RE_EMPTY.test(line) || RE_UL.test(line) || RE_OL.test(line) || RE_QUOTE.test(line) || /^\s*`{3,}/.test(line)) return null; return true; }, parse: (lines, i) => ({ token: { type: 'heading', level: 1, text: lines[i] }, newIndex: i + 2 }), }); registerBlockHandler({ name: 'setextH2', priority: 5, test: (line, lines, i) => { if (i + 1 >= lines.length) return null; if (!RE_SETEXT_H2.test(lines[i + 1])) return null; if (RE_EMPTY.test(line) || RE_UL.test(line) || RE_OL.test(line) || RE_QUOTE.test(line) || /^\s*`{3,}/.test(line)) return null; return true; }, parse: (lines, i) => ({ token: { type: 'heading', level: 2, text: lines[i] }, newIndex: i + 2 }), }); registerBlockHandler({ name: 'hr', priority: 6, test: (line) => RE_HR.test(line) ? true : null, parse: (_lines, i) => ({ token: { type: 'hr' }, newIndex: i + 1 }), }); registerBlockHandler({ name: 'blockquote', priority: 7, test: (line) => RE_QUOTE.test(line) ? true : null, parse: (lines, i) => { const quoteLines = []; while (i < lines.length && RE_QUOTE.test(lines[i])) { quoteLines.push(lines[i].replace(RE_QUOTE, '')); i++; } return { token: { type: 'quote', content: quoteLines.join('\n') }, newIndex: i }; }, }); registerBlockHandler({ name: 'table', priority: 8, test: (line, lines, i) => { if (!/\|/.test(line) || i + 1 >= lines.length) return null; if (!isTableSeparator(lines[i + 1])) return null; return { header: line, align: parseAlign(lines[i + 1]) }; }, parse: (lines, i, match) => { let j = i + 2; const rows = []; while (j < lines.length && /\|/.test(lines[j]) && !RE_EMPTY.test(lines[j])) { rows.push(lines[j]); j++; } return { token: { type: 'table', header: match.header, rows, align: match.align }, newIndex: j }; }, }); registerBlockHandler({ name: 'ul', priority: 9, test: (line) => line.match(RE_UL), parse: (lines, i, match) => { const { items, endIdx } = parseList(lines, i, match[2], match[1].length, false); return { token: { type: 'ul', items }, newIndex: endIdx }; }, }); registerBlockHandler({ name: 'ol', priority: 10, test: (line) => line.match(RE_OL), parse: (lines, i, match) => { const start = parseInt(match[2], 10); const { items, endIdx } = parseList(lines, i, match[2], match[1].length, true); return { token: { type: 'ol', items, start }, newIndex: endIdx }; }, }); registerBlockHandler({ name: 'footnoteDef', priority: 11, test: (line) => line.match(RE_FOOTNOTE_DEF), parse: (lines, i, match, _tokens, footnotes) => { const fnId = match[1]; const fnContent = lines[i].slice(match[0].length); const fnLines = [fnContent]; let j = i + 1; while (j < lines.length && !RE_EMPTY.test(lines[j]) && !isBlockStart(lines[j])) { fnLines.push(lines[j]); j++; } footnotes[fnId] = fnLines.join(' '); return { token: null, newIndex: j }; }, }); registerBlockHandler({ name: 'defList', priority: 12, test: (line) => RE_DEF_LIST.test(line) ? true : null, parse: (lines, i, _match, tokens) => { const term = (tokens.length > 0 && tokens[tokens.length - 1].type === 'paragraph') ? tokens.pop().text : ''; const defs = [lines[i].replace(RE_DEF_LIST, '')]; let j = i + 1; while (j < lines.length && RE_DEF_LIST.test(lines[j])) { defs.push(lines[j].replace(RE_DEF_LIST, '')); j++; } return { token: { type: 'defList', term, defs }, newIndex: j }; }, }); registerBlockHandler({ name: 'mathBlock', priority: 13, test: (line) => /^\$\$/.test(line) ? true : null, parse: (lines, i) => { const single = lines[i].match(/^\$\$([\s\S]+?)\$\$\s*$/); if (single) return { token: { type: 'mathBlock', content: single[1] }, newIndex: i + 1 }; const mathLines = [lines[i].replace(/^\$\$/, '')]; let j = i + 1; while (j < lines.length && !/\$\$/.test(lines[j])) { mathLines.push(lines[j]); j++; } if (j < lines.length) { mathLines.push(lines[j].replace(/\$\$\s*$/, '')); j++; } return { token: { type: 'mathBlock', content: mathLines.join('\n') }, newIndex: j }; }, }); // ============ Block parsing ============ const parseTokens = (md) => { if (md == null) return { tokens: [], footnotes: {}, refs: {} }; const text = String(md).replace(/\r\n?/g, '\n'); const lines = text.split('\n'); const tokens = []; const footnotes = {}; const refs = {}; let i = 0; while (i < lines.length) { const line = lines[i]; let handled = false; for (const handler of blockHandlers) { const match = handler.test(line, lines, i); if (match !== null && match !== false) { const result = handler.parse(lines, i, match, tokens, footnotes, refs); if (result.token) tokens.push(result.token); i = result.newIndex; handled = true; break; } } if (handled) continue; // Paragraph fallback const para = []; while (i < lines.length) { const l = lines[i]; if (RE_EMPTY.test(l)) break; if (isBlockStart(l)) break; if (i + 1 < lines.length && (RE_SETEXT_H1.test(lines[i + 1]) || RE_SETEXT_H2.test(lines[i + 1]))) break; if (/\|/.test(l) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) break; para.push(l); i++; } if (para.length) tokens.push({ type: 'paragraph', text: para.join('\n') }); } return { tokens, footnotes, refs }; }; // ============ Token rendering ============ const renderTokens = (tokens, env = {}, footnotes = {}) => { let html = tokens.map((tok) => renderToken(tok, env)).join('\n'); const fnIds = Object.keys(footnotes); if (fnIds.length) { html += '\n

    '; fnIds.forEach((id) => { html += `
  1. ${cachedRenderInline(footnotes[id], env)}
  2. `; }); html += '
'; } return html; }; const parseMarkdown = (md, env = {}) => { const { tokens, footnotes, refs } = parseTokens(md); return renderTokens(tokens, { ...env, refs }, footnotes); }; // ============ List parsing ============ const parseList = (lines, startIdx, marker, baseIndent, isOl, olStart) => { const items = []; let i = startIdx; const itemIndent = baseIndent + (isOl ? String(marker).length + 2 : 2); while (i < lines.length) { const line = lines[i]; let itemMatch; if (isOl) { itemMatch = line.match(new RegExp(`^(\\s{${baseIndent}})\\d+\\.\\s`)); if (!itemMatch) break; } else { itemMatch = line.match(new RegExp(`^(\\s{${baseIndent}})[-*+]\\s`)); if (!itemMatch) break; } let raw = line.slice(itemMatch[0].length); const task = raw.match(RE_TASK); let content = task ? task[2] : raw; const checked = task ? task[1].toLowerCase() === 'x' : false; const isTask = !!task; i++; const subContent = []; const subTokens = []; while (i < lines.length) { const cl = lines[i]; if (RE_EMPTY.test(cl)) { let peek = i + 1; while (peek < lines.length && RE_EMPTY.test(lines[peek])) peek++; if (peek < lines.length) { const pl = lines[peek]; const isCont = pl.length > itemIndent && pl[itemIndent] !== ' ' ? false : new RegExp(`^\\s{${itemIndent},}`).test(pl); if (isCont) { subContent.push(''); i++; continue; } } break; } if (isOl) { if (new RegExp(`^(\\s{0,${baseIndent}})\\d+\\.\\s`).test(cl)) break; } else { if (new RegExp(`^(\\s{0,${baseIndent}})[-*+]\\s`).test(cl)) break; } if (RE_QUOTE.test(cl) || RE_ATX.test(cl) || RE_HR.test(cl) || RE_FOOTNOTE_DEF.test(cl)) break; const nUl = cl.match(new RegExp(`^(\\s{${itemIndent},})([-*+])\\s`)); const nOl = cl.match(new RegExp(`^(\\s{${itemIndent},})(\\d+)\\.\\s`)); if (nUl || nOl) { const isNOl = !!nOl; const nM = isNOl ? nOl[2] : nUl[2]; const nI = isNOl ? nOl[1].length : nUl[1].length; const nS = isNOl ? parseInt(nOl[2], 10) : undefined; const nested = parseList(lines, i, nM, nI, isNOl); subTokens.push({ type: isNOl ? 'ol' : 'ul', items: nested.items, start: nS }); i = nested.endIdx; continue; } if (cl.length > itemIndent && cl.slice(0, itemIndent).trim() === '') { subContent.push(cl.slice(itemIndent)); } else { subContent.push(cl); } i++; } if (subContent.length) { content = content ? content + '\n' + subContent.join('\n') : subContent.join('\n'); } items.push({ type: 'listItem', text: content, checked, task: isTask, subTokens: subTokens.length ? subTokens : undefined }); } return { items, endIdx: i }; }; // ============ Table utils ============ const isTableSeparator = (line) => RE_TABLE_SEP.test(line) && /-/.test(line); const parseAlign = (line) => { const cells = line.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/); return cells.map((c) => { if (/^:/.test(c) && /:$/.test(c)) return 'center'; if (/:$/.test(c)) return 'right'; if (/^:/.test(c)) return 'left'; return 'left'; }); }; // ============ Token renderer ============ const renderToken = (tok, env, footnotes) => { switch (tok.type) { case 'heading': { const id = slugify(tok.text); return `${cachedRenderInline(tok.text, env)}`; } case 'paragraph': return `

${cachedRenderInline(tok.text, env)}

`; case 'hr': return '
'; case 'quote': return `
${parseMarkdown(tok.content, env)}
`; case 'code': return renderCode(tok.content, tok.lang, env, tok.attrs); case 'ul': return ``; 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 `
    ${escapeHTML(code)}
    `; const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : ''; const titleHtml = attrs?.title ? `
    ${escapeHTML(attrs.title)}
    ` : ''; if (env.highlight && typeof env.highlight === 'function' && lang) { try { const highlighted = env.highlight(code, lang); if (typeof highlighted === 'string') return `${titleHtml}
    ${highlighted}
    `; } catch (e) { console.error('MeEditor highlight error:', e); } } return `${titleHtml}
    ${escapeHTML(code)}
    `; }; const renderTable = (tok, env) => { // Split respecting backslash-escaped pipes const splitRow = (r) => { const cells = []; let current = ''; let escaped = false; for (let i = 0; i < r.length; i++) { const ch = r[i]; if (escaped) { current += ch; escaped = false; } else if (ch === '\\') { escaped = true; } else if (ch === '|') { cells.push(current.trim()); current = ''; } else { current += ch; } } cells.push(current.trim()); // Remove leading/trailing empty cells from outer pipes if (cells.length > 0 && cells[0] === '') cells.shift(); if (cells.length > 0 && cells[cells.length - 1] === '') cells.pop(); return cells; }; const headers = splitRow(tok.header); const align = tok.align || []; const as = (i) => align[i] && align[i] !== 'left' ? ` style="text-align:${align[i]}"` : ''; let h = '
    '; h += headers.map((hd, i) => `${cachedRenderInline(hd, env)}`).join(''); h += ''; h += tok.rows.map((r) => { const cells = splitRow(r); return `${cells.map((c, i) => `${cachedRenderInline(c, env)}`).join('')}`; }).join(''); return h + '
    '; }; // ============ Inline rendering ============ const renderInline = (text, env) => { if (!text) return ''; const codes = []; let s = extractInlineCodes(text, codes); const protectedItems = []; s = s.replace(/&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});/g, (m) => { protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`; }); s = s.replace(//g, (m) => { protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`; }); s = escapeHTML(s); s = s.replace(/\u0005(\d+)\u0005/g, (_m, idx) => protectedItems[+idx] || _m); s = scanInline(s, codes, env); s = s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => { const code = codes[+idx]; if (!code) return _m; return `${escapeHTML(code.content)}`; }); s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m); s = s.replace(/ \n/g, '
    \n'); s = s.replace(/\n/g, '
    '); return s; }; const extractInlineCodes = (text, codes) => { let result = ''; let i = 0; while (i < text.length) { if (text[i] === '`') { let startLen = 1; while (i + startLen < text.length && text[i + startLen] === '`') startLen++; let j = i + startLen; let found = false; while (j < text.length) { const nextTick = text.indexOf('`', j); if (nextTick === -1) break; let endLen = 1; while (nextTick + endLen < text.length && text[nextTick + endLen] === '`') endLen++; if (endLen === startLen) { const content = text.slice(i + startLen, nextTick); codes.push({ content, len: startLen }); result += `\u0000${codes.length - 1}\u0000`; i = nextTick + endLen; found = true; break; } j = nextTick + endLen; } if (!found) { result += text.slice(i, i + startLen); i += startLen; } } else { result += text[i]; i++; } } return result; }; const INLINE_RE = new RegExp([ '(\\*\\*\\*[^*\\n][^*\\n]*?\\*\\*\\*)', '|(___[^_\\n][^_\\n]*?___)', '|(!\\[[^\\]]*\\]\\([^)]+\\))', '|(? { const placeholderMap = new Map(); s = s.replace(/\u0000\d+\u0000/g, (m) => { const key = `\u0003${placeholderMap.size}\u0003`; placeholderMap.set(key, m); return key; }); s = s.replace(INLINE_RE, (fullMatch) => { const match = fullMatch; if (match.startsWith('***')) return `${match.slice(3, -3)}`; if (match.startsWith('___')) return `${match.slice(3, -3)}`; if (match.startsWith('![') && match.includes('](')) { const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/); if (!m) return match; const u = safeUrl(m[2]); if (!u) return escapeHTML(match); const t = m[3] ? ` title="${m[3]}"` : ''; return `${m[1]}`; } if (match.startsWith('![') && match.includes('][')) { const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/); if (!m) return match; const refKey = (m[2] || m[1]).toLowerCase(); if (env.refs && env.refs[refKey]) { try { const ref = JSON.parse(env.refs[refKey]); const u = safeUrl(ref.url); if (!u) return escapeHTML(match); const t = ref.title ? ` title="${ref.title}"` : ''; return `${m[1]}`; } catch (_) { return `${m[1]}`; } } return `${m[1]}`; } if (match.startsWith('[') && match.includes('](')) { const m = match.match(/\[([^\]]+)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/); if (!m) return match; const u = safeUrl(m[2]); if (!u) return match; const t = m[3] ? ` title="${m[3]}"` : ''; const linkText = renderInline(m[1], env); return `${linkText}`; } if (match.startsWith('[') && match.includes('][')) { const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/); if (!m) return match; const refKey = (m[2] || m[1]).toLowerCase(); if (env.refs && env.refs[refKey]) { try { const ref = JSON.parse(env.refs[refKey]); const u = safeUrl(ref.url); if (!u) return escapeHTML(match); const t = ref.title ? ` title="${ref.title}"` : ''; return `${m[1]}`; } catch (_) { return escapeHTML(match); } } return escapeHTML(match); } if (match.startsWith('<http')) { const url = match.slice(4, -4); const u = safeUrl(url); return `${url}`; } if (match.startsWith('**')) return `${match.slice(2, -2)}`; if (match.startsWith('__')) return `${match.slice(2, -2)}`; if (match.startsWith('~~')) return `${match.slice(2, -2)}`; if (match.startsWith('==')) return `${match.slice(2, -2)}`; if (match.startsWith('*') && !match.startsWith('**') && !match.startsWith('***')) return `${match.slice(1, -1)}`; if (match.startsWith('_') && !match.startsWith('__') && !match.startsWith('___')) return `${match.slice(1, -1)}`; if (match.startsWith('^') && !match.startsWith('^^')) return `${match.slice(1, -1)}`; if (match.startsWith('~') && !match.startsWith('~~')) return `${match.slice(1, -1)}`; if (match.startsWith('$') && !match.startsWith('$$')) return `${escapeHTML(match.slice(1, -1))}`; if (/^\[\^/.test(match)) { const fnId = match.slice(2, -1); return `[${fnId}]`; } if (match.startsWith('\\') && match.length === 2) { const escaped = match[1]; if (/[!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~]/.test(escaped)) return escaped; return match; } return match; }); s = s.replace(/\u0003(\d+)\u0003/g, (m) => placeholderMap.get(m) || m); return s; }; /** * MetonaEditor Plugins — plugin system v2 * @module plugins * @version 0.2.0 */ // ============ Plugin Manager ============ class PluginManager { constructor() { this.plugins = new Map(); } register(name, plugin) { if (this.plugins.has(name)) { console.warn(`MeEditor: plugin "${name}" already registered`); return this; } if (!plugin || typeof plugin !== 'object' || (!plugin.name && !name)) { console.error(`MeEditor: invalid plugin "${name}"`); return this; } this.plugins.set(name, { version: plugin.version || '0.0.0', description: plugin.description || '', depends: plugin.depends || [], priority: plugin.priority || 0, ...plugin, name, enabled: true, }); return this; } unregister(name) { this.plugins.delete(name); return this; } get(name) { return this.plugins.get(name) || null; } has(name) { return this.plugins.has(name); } getAll() { return Array.from(this.plugins.values()); } getNames() { return Array.from(this.plugins.keys()); } enable(name) { const p = this.plugins.get(name); if (p) p.enabled = true; return this; } disable(name) { const p = this.plugins.get(name); if (p) p.enabled = false; return this; } isEnabled(name) { const p = this.plugins.get(name); return p ? p.enabled : false; } destroy() { this.plugins.clear(); } } const defaultPluginManager = new PluginManager(); // ============ Topological sort ============ const topologicalSort = (plugins) => { const map = new Map(); plugins.forEach((p) => map.set(p.name, p)); const inDegree = new Map(); const adj = new Map(); plugins.forEach((p) => { inDegree.set(p.name, 0); adj.set(p.name, []); }); plugins.forEach((p) => { (p.depends || []).forEach((dep) => { if (map.has(dep)) { adj.get(dep).push(p.name); inDegree.set(p.name, (inDegree.get(p.name) || 0) + 1); } else { console.warn(`MeEditor: plugin "${p.name}" depends on unknown "${dep}"`); } }); }); const queue = []; inDegree.forEach((deg, name) => { if (deg === 0) queue.push(name); }); const sorted = []; while (queue.length) { queue.sort((a, b) => ((map.get(b).priority || 0) - (map.get(a).priority || 0))); const name = queue.shift(); sorted.push(map.get(name)); (adj.get(name) || []).forEach((n) => { inDegree.set(n, inDegree.get(n) - 1); if (inDegree.get(n) === 0) queue.push(n); }); } if (sorted.length !== plugins.length) { console.warn('MeEditor: circular dependency detected, falling back to original order'); return plugins; } return sorted; }; // ============ Config validation ============ const validateConfig = (schema = {}, config = {}) => { const errors = []; const patched = { ...config }; for (const [key, rule] of Object.entries(schema)) { const val = config[key]; if (rule.required && (val === undefined || val === null)) { errors.push(`"${key}" is required`); continue; } if (val === undefined && rule.default !== undefined) { patched[key] = rule.default; continue; } if (val !== undefined && rule.type) { const actual = Array.isArray(val) ? 'array' : typeof val; if (actual !== rule.type) errors.push(`"${key}" expected ${rule.type}, got ${actual}`); } if (val !== undefined && rule.enum && !rule.enum.includes(val)) errors.push(`"${key}" must be one of [${rule.enum.join(', ')}]`); if (val !== undefined && typeof rule.validator === 'function') { try { const r = rule.validator(val); if (r !== true) errors.push(`"${key}": ${r}`); } catch (e) { errors.push(`"${key}": ${e.message}`); } } } return { valid: errors.length === 0, errors, patched }; }; // ============ Preset plugins ============ const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); const autoSavePlugin = { name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100, install(editor, options) { if (!editor || typeof editor.getValue !== 'function') return; const opts = options || this; const key = opts.key || ('me-draft-' + (editor.id || '')); const delay = opts.delay || 1000; const state = { _timer: null }; const save = () => { if (state._timer) { clearTimeout(state._timer); state._timer = null; } try { localStorage.setItem(key, editor.getValue()); if (typeof editor._emit === 'function') editor._emit('autosave', { key, value: editor.getValue() }); } catch (e) { console.warn('MeEditor autoSave:', e); } }; const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); }; const _onBlur = save; const _onSave = save; editor.on('change', _onInput); editor.on('blur', _onBlur); editor.on('save', _onSave); editor.restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null) editor.setValue(v); return v; } catch (_) { return null; } }; editor.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) { } return editor; }; editor.getDraftKey = () => key; editor.__autoSaveCleanup = { state, _onInput, _onBlur, _onSave, save }; }, destroy(editor) { const cleanup = editor.__autoSaveCleanup; if (cleanup) { if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; } if (editor && typeof editor.off === 'function') { if (cleanup._onInput) editor.off('change', cleanup._onInput); if (cleanup._onBlur) editor.off('blur', cleanup._onBlur); if (cleanup._onSave) editor.off('save', cleanup._onSave); } delete editor.__autoSaveCleanup; } }, }; const exportToolPlugin = { name: 'exportTool', version: '0.1.0', description: 'Export Markdown/HTML', priority: 50, install(editor) { if (!editor || typeof editor.getValue !== 'function') return; const download = (filename, content, mime) => { if (typeof document === 'undefined') return; const blob = new Blob([content], { type: mime + ';charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 0); }; const stamp = () => { const d = new Date(); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; }; editor.exportMarkdown = (filename) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; }; editor.exportHTML = (filename, opts = {}) => { const title = opts.title || 'Document'; const css = opts.css || ''; const body = typeof editor.getHTML === 'function' ? editor.getHTML() : ''; // Build minimal embedded CSS for the exported HTML const embedCSS = opts.embedCSS !== false ? `` : ''; download(filename || `metona-${stamp()}.html`, `\n\n\n\n\n${title}\n${css ? `` : ''}${embedCSS}\n\n\n${body}\n\n`, 'text/html'); return editor; }; }, destroy() { }, }; const searchReplacePlugin = { name: 'searchReplace', version: '0.2.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; // Inject style once globally if (!document.getElementById('me-search-style')) { const style = document.createElement('style'); style.id = 'me-search-style'; style.textContent = `.me-search{position:absolute;top:8px;right:12px;z-index:20;display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);font-size:13px;min-width:280px}.me-search-row{display:flex;gap:4px;align-items:center}.me-search input{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));border-radius:4px;background:var(--md-textarea-bg,#fff);color:var(--md-text,#1f2937);font-size:13px}.me-search input:focus{outline:none;border-color:var(--md-accent,#3b82f6)}.me-search button{padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));background:var(--md-bg,#fff);color:var(--md-text,#1f2937);border-radius:4px;cursor:pointer;font-size:12px;line-height:1}.me-search button:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-count{color:var(--md-muted,#6b7280);font-size:12px;min-width:60px;text-align:center}.me-search .me-search-close{padding:2px 6px}`; document.head.appendChild(style); } const state = { _panel: null, _regexMode: false, _cleanup: null, }; const _updateReplaceVisible = () => { if (!state._panel) return; const show = state._panel.dataset.replace === '1'; const row = state._panel.querySelector('.me-search-replace-row'); if (row) row.style.display = show ? 'flex' : 'none'; }; const _close = () => { if (state._cleanup) { state._cleanup(); state._cleanup = null; } state._panel = null; if (editor && editor.textarea) editor.textarea.focus(); }; const _open = (showReplace) => { if (state._panel) { state._panel.dataset.replace = showReplace ? '1' : '0'; _updateReplaceVisible(); const inp = state._panel.querySelector('.me-search-find'); if (inp) { inp.focus(); inp.select(); } return; } const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd); const panel = document.createElement('div'); panel.className = 'me-search'; panel.dataset.replace = showReplace ? '1' : '0'; panel.innerHTML = `
    `; editor.el.appendChild(panel); state._panel = panel; _updateReplaceVisible(); state._regexMode = false; const fi = panel.querySelector('.me-search-find'); const ri = panel.querySelector('.me-search-replace'); const ce = panel.querySelector('.me-search-count'); const regexBtn = panel.querySelector('.me-search-regex'); regexBtn.addEventListener('click', () => { state._regexMode = !state._regexMode; regexBtn.classList.toggle('me-active', state._regexMode); lastIdxs = findAll(); }); const findAll = () => { const q = fi.value; if (!q) { ce.textContent = ''; return []; } const idxs = []; let from = 0; if (state._regexMode) { try { const re = new RegExp(q, 'g'); let m; while ((m = re.exec(editor.textarea.value)) !== null) { idxs.push(m.index); if (m[0].length === 0) re.lastIndex++; } } catch (_) { ce.textContent = 'err'; return []; } } else { while (true) { const idx = editor.textarea.value.indexOf(q, from); if (idx === -1) break; idxs.push(idx); from = idx + q.length; } } ce.textContent = idxs.length ? `${idxs.length}` : '0'; return idxs; }; let lastIdxs = []; const selectAt = (idx, len) => { editor.textarea.focus(); editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length)); }; const findNext = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionEnd; const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(cur)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length; let next = lastIdxs.find((i) => i >= cur); if (next == null) next = lastIdxs[0]; selectAt(next, qlen); }; const findPrev = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionStart; const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(Math.max(0, cur - 100), cur + 100)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length; let prev = -1; for (let i = lastIdxs.length - 1; i >= 0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } } if (prev === -1) prev = lastIdxs[lastIdxs.length - 1]; selectAt(prev, qlen); }; const replaceOne = () => { const q = fi.value, r = ri.value; if (!q) return; const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd; const matchText = ta.value.substring(s, e); if (state._regexMode) { try { if (new RegExp(q).test(matchText)) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); } } catch (_) { } } else if (matchText === q) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); } editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory(); if (typeof editor._render === 'function') editor._render(); if (typeof editor._emit === 'function') editor._emit('change', editor._value); findNext(); }; const replaceAll = () => { const q = fi.value, r = ri.value; if (!q) return; const ta = editor.textarea; if (state._regexMode) { try { ta.value = ta.value.replace(new RegExp(q, 'g'), r); } catch (_) { return; } } else { ta.value = ta.value.split(q).join(r); } ta.setSelectionRange(0, 0); editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory(); if (typeof editor._render === 'function') editor._render(); if (typeof editor._emit === 'function') editor._emit('change', editor._value); findAll(); }; fi.addEventListener('input', () => { lastIdxs = findAll(); }); fi.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); } if (e.key === 'Escape') { e.preventDefault(); _close(); } }); ri.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); replaceOne(); } if (e.key === 'Escape') { e.preventDefault(); _close(); } }); panel.querySelector('.me-search-next').addEventListener('click', findNext); panel.querySelector('.me-search-prev').addEventListener('click', findPrev); panel.querySelector('.me-search-close').addEventListener('click', () => _close()); panel.querySelector('.me-search-replace-one').addEventListener('click', replaceOne); panel.querySelector('.me-search-replace-all').addEventListener('click', replaceAll); fi.focus(); fi.select(); state._cleanup = () => { if (panel.parentNode) panel.parentNode.removeChild(panel); }; }; const _onKeydown = (e) => { const mod = e.ctrlKey || e.metaKey; if (!mod) return; const k = e.key.toLowerCase(); if (k === 'f') { e.preventDefault(); _open(false); } else if (k === 'h') { e.preventDefault(); _open(true); } else if (k === 'escape' && state._panel) { _close(); } }; editor.textarea.addEventListener('keydown', _onKeydown); // Expose for tests editor.__srState = state; editor.__srOpen = _open; editor.__srClose = _close; editor.__srKeydown = _onKeydown; }, destroy(editor) { const state = editor.__srState; if (state) { if (state._cleanup) { state._cleanup(); state._cleanup = null; } state._panel = null; } const _onKeydown = editor.__srKeydown; if (_onKeydown && editor && editor.textarea) { editor.textarea.removeEventListener('keydown', _onKeydown); } delete editor.__srState; delete editor.__srOpen; delete editor.__srClose; delete editor.__srKeydown; }, }; const imagePastePlugin = { name: 'imagePaste', version: '0.2.0', description: 'Paste image as base64', priority: 60, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; const _onPaste = (e) => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type?.startsWith('image/')) { e.preventDefault(); const reader = new FileReader(); reader.onload = () => { editor.insert(`![image-${Date.now().toString(36)}.png](${reader.result})\n`); }; reader.readAsDataURL(item.getAsFile()); break; } } }; editor.textarea.addEventListener('paste', _onPaste); editor.__ipOnPaste = _onPaste; }, destroy(editor) { const _onPaste = editor.__ipOnPaste; if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste); delete editor.__ipOnPaste; }, }; const shortcutHelpPlugin = { name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; if (!document.getElementById('me-shortcut-style')) { const s = document.createElement('style'); s.id = 'me-shortcut-style'; s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`; document.head.appendChild(s); } let _panel = null; const _close = () => { if (_panel) { _panel.remove(); _panel = null; } }; const _open = () => { if (_panel) { _close(); return; } // i18n-aware shortcut labels const builtin = [ ['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'], ['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'], ['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'], ['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'], ['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'], ['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'], ['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'], ['?', t('close') || 'Shortcuts'], ]; let rows = ''; builtin.forEach(([c, d]) => { rows += `${c}${d}`; }); const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay'; overlay.innerHTML = `

    ⌨️ ${t('close') || 'Shortcuts'}

    ${rows}
    `; overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.classList.contains('me-shortcut-close')) _close(); }); document.body.appendChild(overlay); _panel = overlay; }; const _onKeydown = (e) => { if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); _open(); } if (e.key === 'Escape' && _panel) _close(); }; editor.textarea.addEventListener('keydown', _onKeydown); editor.__shState = { _panel, _open, _close, _onKeydown }; }, destroy(editor) { const state = editor.__shState; if (state) { if (state._panel) { state._panel.remove(); } } const _onKeydown = editor.__shState?._onKeydown; if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown); delete editor.__shState; }, }; const fileSystemPlugin = { name: 'fileSystem', version: '0.2.0', description: 'File System Access API', priority: 90, install(editor) { if (!editor || typeof editor.getValue !== 'function') return; const hasAPI = typeof window !== 'undefined' && typeof window.showOpenFilePicker === 'function'; let _fileHandle = null; editor.openFile = async (opts = {}) => { if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; } try { const [handle] = await window.showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md', '.txt', '.markdown'] } }], ...opts }); _fileHandle = handle; const file = await handle.getFile(); const content = await file.text(); editor.setValue(content); editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle }; } catch (e) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; } }; editor.saveFile = async (opts = {}) => { let handle = _fileHandle; if (!handle || opts.saveAs) { if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; } try { handle = await window.showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' }); _fileHandle = handle; } catch (e) { if (e.name !== 'AbortError') console.error('Save error:', e); return false; } } try { const w = await handle.createWritable(); await w.write(editor.getValue()); await w.close(); editor._emit?.('fileSaved', { handle }); return true; } catch (e) { _fileHandle = null; if (!opts.saveAs) return editor.saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; } }; editor.saveFileAs = (name) => editor.saveFile({ saveAs: true, name }); editor.getFileHandle = () => _fileHandle; editor.__fsCleanup = { getHandle: () => _fileHandle }; }, destroy(editor) { if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; } delete editor.__fsCleanup; }, }; // ============ Preset plugins table ============ const presetPlugins = { autoSave: autoSavePlugin, exportTool: exportToolPlugin, searchReplace: searchReplacePlugin, imagePaste: imagePastePlugin, shortcutHelp: shortcutHelpPlugin, fileSystem: fileSystemPlugin, }; // ============ Plugin utils ============ const pluginUtils = { createManager: () => new PluginManager(), manager: defaultPluginManager, register: (name, plugin) => defaultPluginManager.register(name, plugin), unregister: (name) => defaultPluginManager.unregister(name), get: (name) => defaultPluginManager.get(name), has: (name) => defaultPluginManager.has(name), getAll: () => defaultPluginManager.getAll(), getNames: () => defaultPluginManager.getNames(), enable: (name) => defaultPluginManager.enable(name), disable: (name) => defaultPluginManager.disable(name), isEnabled: (name) => defaultPluginManager.isEnabled(name), getPreset: (name) => presetPlugins[name] ? { ...presetPlugins[name] } : null, getAllPresets: () => Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {}), createPlugin: (config = {}) => { const result = { name: config.name || 'custom', version: config.version || '0.0.0', description: config.description || '', depends: config.depends || [], priority: config.priority || 0, install: () => { }, destroy: () => { }, ...config }; if (typeof result.install !== 'function') result.install = () => { }; if (typeof result.destroy !== 'function') result.destroy = () => { }; return result; }, validatePlugin: (plugin) => { const errors = []; if (!plugin || typeof plugin !== 'object') errors.push('plugin must be an object'); if (plugin && !plugin.name) errors.push('plugin must have a name'); if (plugin && plugin.install && typeof plugin.install !== 'function') errors.push('install must be a function'); return { valid: errors.length === 0, errors }; }, topologicalSort, validateConfig, }; /** * MetonaEditor Themes — theme system * @module themes * @version 0.2.0 */ // ============ State ============ let globalCurrentTheme = 'auto'; let globalThemeListeners = new Set(); let _sysWatchUnsub = null; // ============ System theme ============ const getSystemTheme = () => { if (typeof window === 'undefined') return 'light'; return prefersDark() ? 'dark' : 'light'; }; const _watchSystem = (callback) => { if (typeof window === 'undefined' || !window.matchMedia) return () => { }; const mql = window.matchMedia('(prefers-color-scheme: dark)'); const handler = (e) => callback(e.matches ? 'dark' : 'light'); mql.addEventListener('change', handler); return () => mql.removeEventListener('change', handler); }; const watchSystemTheme = () => { if (typeof window === 'undefined' || !window.matchMedia) return; if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; } _sysWatchUnsub = _watchSystem((sysTheme) => { if (globalCurrentTheme === 'auto') applyTheme('auto'); }); }; const unwatchSystemTheme = () => { if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; } }; // ============ Theme resolution ============ const resolveTheme = (theme) => { if (!theme || theme === 'auto') return getSystemTheme(); return theme; }; const getThemeConfig = (theme) => { const resolved = resolveTheme(theme); const config = THEMES[resolved]; return (config && typeof config === 'object' ? config : THEMES.light); }; // ============ CSS variables ============ const setThemeVariables = (config, target) => { if (typeof document === 'undefined' || !config || typeof config !== 'object') return; const root = target || document.documentElement; const vars = { '--md-bg': config.bg, '--md-text': config.text, '--md-border': config.border, '--md-shadow': config.shadow, '--md-hover-shadow': config.hoverShadow, '--md-toolbar-bg': config.toolbarBg || config.bg, '--md-textarea-bg': config.textareaBg || config.bg, '--md-preview-bg': config.previewBg || config.bg, '--md-code-bg': config.codeBg || 'rgba(127,127,127,0.1)', '--md-code-text': config.codeText || config.text, '--md-accent': config.accent || '#3b82f6', '--md-muted': config.muted || '#9ca3af', }; for (const [k, v] of Object.entries(vars)) { if (v !== undefined && v !== null) root.style.setProperty(k, String(v)); } if (config.progressBg) root.style.setProperty('--md-progress-bg', config.progressBg); if (config.closeHoverBg) root.style.setProperty('--md-close-hover-bg', config.closeHoverBg); }; const exportCSSVars = (el) => { const root = el || document.documentElement; if (typeof document === 'undefined') return {}; const style = getComputedStyle(root); const varNames = ['--md-bg', '--md-text', '--md-border', '--md-shadow', '--md-hover-shadow', '--md-toolbar-bg', '--md-textarea-bg', '--md-preview-bg', '--md-code-bg', '--md-code-text', '--md-accent', '--md-muted']; const result = {}; varNames.forEach((name) => { const val = style.getPropertyValue(name).trim(); if (val) result[name] = val; }); return result; }; const getCSSVariable = (name, el) => { const root = el || document.documentElement; if (typeof document === 'undefined') return ''; const fullName = name.startsWith('--') ? name : `--md-${name}`; return getComputedStyle(root).getPropertyValue(fullName).trim(); }; // ============ Apply theme ============ const applyTheme = (theme) => { globalCurrentTheme = theme; const resolved = resolveTheme(theme); if (typeof document !== 'undefined') { document.documentElement.setAttribute('data-md-theme', resolved); document.documentElement.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm'); document.documentElement.classList.add(`md-theme-${resolved}`); setThemeVariables(getThemeConfig(theme)); } notifyGlobalListeners(theme, resolved); }; const applyThemeToElement = (theme, target) => { if (!target || typeof document === 'undefined') return; const resolved = resolveTheme(theme); target.setAttribute('data-md-theme', resolved); target.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm'); if (resolved !== 'auto') target.classList.add(`md-theme-${resolved}`); setThemeVariables(getThemeConfig(theme), target); }; const switchTheme = (theme) => { applyTheme(theme); saveTheme(theme); }; const toggleTheme = () => { const r = getResolvedTheme(); switchTheme(r === 'dark' ? 'light' : 'dark'); }; const resetToAuto = () => { switchTheme('auto'); }; const getCurrentTheme = () => globalCurrentTheme; const getResolvedTheme = () => resolveTheme(globalCurrentTheme); // ============ Persistence ============ const saveTheme = (theme) => { if (typeof localStorage !== 'undefined') { try { localStorage.setItem('metona-editor-theme', theme); } catch (_) { } } }; const loadTheme = () => { if (typeof localStorage !== 'undefined') { try { return localStorage.getItem('metona-editor-theme') || 'auto'; } catch (_) { } } return 'auto'; }; // ============ Listeners ============ const notifyGlobalListeners = (theme, resolved) => { globalThemeListeners.forEach((fn) => { try { fn(theme, resolved); } catch (e) { console.error('Theme listener error:', e); } }); }; const addThemeListener = (fn) => { globalThemeListeners.add(fn); return () => { globalThemeListeners.delete(fn); }; }; const removeThemeListener = (fn) => { globalThemeListeners.delete(fn); }; const clearThemeListeners = () => { globalThemeListeners.clear(); }; // ============ System watch ============ const startSystemWatch = () => { watchSystemTheme(); }; // ============ Init ============ const initTheme = () => { applyTheme(loadTheme()); startSystemWatch(); }; // ============ External follow ============ const followExternalTheme = (options, onThemeChange) => { if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => { }; const { element = document.documentElement, attr = 'data-theme', classMap = null, callback = null } = options; const detect = () => { if (typeof callback === 'function') return callback(element); const attrVal = element.getAttribute(attr); if (attrVal) return attrVal; if (classMap) { for (const [cls, theme] of Object.entries(classMap)) { if (element.classList.contains(cls)) return theme; } } const mdTheme = element.getAttribute('data-md-theme'); if (mdTheme) return mdTheme; return null; }; let lastTheme = detect(); if (lastTheme) onThemeChange(lastTheme); const observer = new MutationObserver(() => { const current = detect(); if (current && current !== lastTheme) { lastTheme = current; onThemeChange(current); } }); observer.observe(element, { attributes: true, attributeFilter: [attr, 'class', 'data-md-theme'] }); return () => observer.disconnect(); }; const adoptFromParent = (container, onThemeDetected) => { if (typeof document === 'undefined' || !container) return () => { }; const detect = () => { let el = container.parentElement; while (el) { const t = el.getAttribute('data-md-theme'); if (t) return t; if (el.classList.contains('theme-dark') || el.classList.contains('dark')) return 'dark'; if (el.classList.contains('theme-light') || el.classList.contains('light')) return 'light'; el = el.parentElement; } if (container.parentElement) { const bg = getComputedStyle(container.parentElement).getPropertyValue('--md-bg').trim() || getComputedStyle(container.parentElement).backgroundColor; if (bg) { const rgb = bg.match(/\d+/g); if (rgb && rgb.length >= 3) { const brightness = (parseInt(rgb[0]) * 299 + parseInt(rgb[1]) * 587 + parseInt(rgb[2]) * 114) / 1000; return brightness < 128 ? 'dark' : 'light'; } } } return null; }; const detected = detect(); if (detected) onThemeDetected(detected); const observer = new MutationObserver(() => { const d = detect(); if (d) onThemeDetected(d); }); let target = container.parentElement; while (target) { observer.observe(target, { attributes: true, attributeFilter: ['class', 'data-md-theme', 'data-theme'] }); target = target.parentElement; if (target === document.documentElement) break; } return () => observer.disconnect(); }; const watch = (source, onChange) => { if (typeof source === 'function') { const tick = () => { try { const t = source(); if (t) onChange(t); } catch (_) { } }; tick(); const id = setInterval(tick, 500); return () => clearInterval(id); } if (typeof source === 'string' && typeof document !== 'undefined') { const el = document.querySelector(source); if (!el) return () => { }; return followExternalTheme({ element: el, attr: 'data-theme' }, onChange); } return () => { }; }; // ============ Custom theme registration ============ const registerTheme = (name, config = {}) => { const baseName = config.extends || 'light'; const base = (THEMES[baseName] || THEMES.light); THEMES[name] = { bg: config.bg || base.bg, text: config.text || base.text, border: config.border || base.border, shadow: config.shadow || base.shadow, hoverShadow: config.hoverShadow || base.hoverShadow, toolbarBg: config.toolbarBg || base.toolbarBg || base.bg, textareaBg: config.textareaBg || base.textareaBg || base.bg, previewBg: config.previewBg || base.previewBg || base.bg, codeBg: config.codeBg || base.codeBg, codeText: config.codeText || base.codeText, accent: config.accent || base.accent, muted: config.muted || base.muted, progressBg: config.progressBg || base.progressBg, closeHoverBg: config.closeHoverBg || base.closeHoverBg, }; }; const unregisterTheme = (name) => { if (name === 'light' || name === 'dark' || name === 'auto' || name === 'warm') { console.warn('Cannot unregister built-in theme:', name); return; } delete THEMES[name]; }; const getAllThemes = () => ({ ...THEMES }); const getThemeNames = () => Object.keys(THEMES); const hasTheme = (name) => name in THEMES; // ============ Instance theme ============ const createInstanceTheme = (editor) => { let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto'; const apply = (theme) => { instanceTheme = theme; const resolved = resolveTheme(theme); const config = getThemeConfig(theme); if (editor.el) { ['me-theme-light', 'me-theme-dark', 'me-theme-warm'].forEach((c) => editor.el.classList.remove(c)); if (resolved !== 'auto') editor.el.classList.add(`me-theme-${resolved}`); editor.el.setAttribute('data-md-theme', resolved); setThemeVariables(config, editor.el); } if (typeof editor._emit === 'function') editor._emit('themeChange', { theme, resolved, config }); if (typeof editor.refresh === 'function') editor.refresh(); return instanceTheme; }; const get = () => instanceTheme; const set = (theme) => apply(theme); const toggle = () => { const r = resolveTheme(instanceTheme); return apply(r === 'dark' ? 'light' : 'dark'); }; const getResolved = () => resolveTheme(instanceTheme); const getConfig = () => getThemeConfig(instanceTheme); const getVars = () => editor.el ? exportCSSVars(editor.el) : {}; const syncWithElement = (element, opts = {}) => followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected)); const adopt = () => editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => { }; const watchExternal = (source) => watch(source, (theme) => apply(theme)); apply(instanceTheme); return { apply, get, set, toggle, getResolved, getConfig, getVars, syncWithElement, adopt, watch: watchExternal }; }; const presetThemes = { light: { name: '浅色', description: '明亮清晰', config: THEMES.light }, dark: { name: '深色', description: '护眼暗色', config: THEMES.dark }, auto: { name: '自动', description: '跟随系统', config: 'auto' }, warm: { name: '暖色', description: '温馨暖色', config: THEMES.warm }, }; // Ensure built-in themes in THEMES for (const [name, theme] of Object.entries(presetThemes)) { if (name !== 'auto' && theme.config !== 'auto') THEMES[name] = theme.config; } const themeUtils = { getSystemTheme, resolveTheme, getThemeConfig, watchSystemTheme, unwatchSystemTheme, applyTheme, getCurrentTheme, getResolvedTheme, switchTheme, toggleTheme, resetToAuto, initTheme, saveTheme, loadTheme, addThemeListener, removeThemeListener, clearThemeListeners, registerTheme, unregisterTheme, getAllThemes, getThemeNames, hasTheme, setThemeVariables, exportCSSVars, getCSSVariable, applyThemeToElement, followExternalTheme, adoptFromParent, watch, createInstanceTheme, }; /** * MetonaEditor Core — MarkdownEditor class (TypeScript) * @module core * @version 0.2.0 */ const MODES = ['edit', 'split', 'preview']; const TOAST_ICONS = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' }; const BRACKET_PAIRS = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`', '*': '*', '_': '_', }; const resolveThemeName = (theme) => { if (theme && theme !== 'auto') return theme; return resolveTheme('auto'); }; class MarkdownEditor { static on(name, fn) { if (!this._hooks.has(name)) this._hooks.set(name, []); this._hooks.get(name).push(fn); return () => this.off(name, fn); } static off(name, fn) { const list = this._hooks.get(name); if (list) this._hooks.set(name, list.filter((f) => f !== fn)); } static trigger(name, instance) { const list = this._hooks.get(name); if (list) list.forEach((fn) => { try { fn(instance); } catch (e) { console.error(`MeEditor hook "${name}" error:`, e); } }); } constructor(container, options = {}) { if (!isBrowser()) { this._destroyed = true; this._value = options.value || ''; return this; } this.id = options.id || generateId(); this.container = typeof container === 'string' ? document.querySelector(container) : container; if (!this.container) { console.error('MeEditor: container not found:', container); this._destroyed = true; return this; } this.config = { ...DEFAULTS, ...options }; if (options.style && typeof options.style === 'object') { this.config.style = { ...DEFAULTS.style, ...options.style }; } this._value = this.config.value || ''; this._mode = MODES.includes(this.config.mode) ? this.config.mode : 'split'; this._history = []; this._historyIndex = -1; this._cleanups = []; this._plugins = []; this._listeners = {}; this._renderRaf = null; this._historyTimer = null; this._fullscreen = false; this._destroyed = false; this._lastRenderedValue = null; this._shortcuts = []; this._contextMenuItems = []; this._customActions = {}; this._outlineTimer = null; this._zenMode = false; this._wordWrap = true; this._syncing = false; this._zenMouseHandler = null; this._floatingToolbar = null; this._selectionTimer = null; this._floatingEnabled = this.config.floatingToolbar !== false; this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown; this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null; MarkdownEditor.trigger('beforeCreate', this); injectStyles(); this._buildDOM(); this._themeCtx = createInstanceTheme(this); this._i18nCtx = createInstanceI18n(this); if (options.theme && typeof document !== 'undefined') { document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme)); } const resolvedThemeConfig = getThemeConfig(this.config.theme); setThemeVariables(resolvedThemeConfig, this.el); this._buildToolbar(); this._updateModeButtons(); this._bindToolbarKeyboard(); this._initAriaLive(); this._bindEvents(); this._bindContextMenu(); this._trackOutlineScroll(); if (this.config.floatingToolbar !== false) this._initFloatingToolbar(); this.textarea.value = this._value; this._pushHistory(); this._render(); this._updateWordCount(); if (Array.isArray(this.config.plugins)) { const plugins = this.config.plugins.map((p) => { if (typeof p === 'string') return { ...presetPlugins[p], _isStringRef: true }; return p; }).filter(Boolean); const sorted = topologicalSort(plugins); sorted.forEach((p) => this.use(p)); } if (this.config.autofocus && !this.config.readOnly) this.focus(); if (typeof this.config.onCreate === 'function') { try { this.config.onCreate(this); } catch (e) { console.error('onCreate error:', e); } } MarkdownEditor.trigger('afterCreate', this); } _buildDOM() { const wrapper = document.createElement('div'); wrapper.className = `me-wrapper me-theme-${resolveThemeName(this.config.theme)}`; if (this.config.readOnly) wrapper.classList.add('me-readonly'); wrapper.dataset.id = this.id; wrapper.setAttribute('role', 'application'); wrapper.setAttribute('aria-label', t('edit')); if (this.config.className) { this.config.className.split(/\s+/).filter(Boolean).forEach((c) => wrapper.classList.add(c)); } const h = this.config.height; wrapper.style.height = typeof h === 'number' ? `${h}px` : (h || '400px'); if (this.config.style && typeof this.config.style === 'object') { Object.entries(this.config.style).forEach(([k, v]) => { if (v) wrapper.style[k] = v; }); } const toolbar = document.createElement('div'); toolbar.className = 'me-toolbar'; toolbar.setAttribute('role', 'toolbar'); const body = document.createElement('div'); body.className = `me-body me-mode-${this._mode}`; const editorPane = document.createElement('div'); editorPane.className = 'me-editor-pane'; const editorInner = document.createElement('div'); editorInner.className = 'me-editor-inner'; const gutter = document.createElement('div'); gutter.className = 'me-gutter'; if (!this.config.lineNumbers) gutter.style.display = 'none'; editorInner.appendChild(gutter); const textarea = document.createElement('textarea'); textarea.className = 'me-textarea'; textarea.spellcheck = !!this.config.spellcheck; textarea.readOnly = !!this.config.readOnly; textarea.style.tabSize = String(this.config.tabSize || 2); textarea.placeholder = this.config.placeholder || t('placeholder'); textarea.setAttribute('aria-label', t('edit')); editorInner.appendChild(textarea); editorPane.appendChild(editorInner); const divider = document.createElement('div'); divider.className = 'me-divider'; divider.setAttribute('role', 'separator'); const previewPane = document.createElement('div'); previewPane.className = 'me-preview-pane'; previewPane.setAttribute('role', 'region'); previewPane.setAttribute('aria-label', t('preview')); const preview = document.createElement('div'); preview.className = 'me-preview'; previewPane.appendChild(preview); body.appendChild(editorPane); body.appendChild(divider); body.appendChild(previewPane); wrapper.appendChild(toolbar); wrapper.appendChild(body); let statusbar = null; if (this.config.wordCount) { statusbar = document.createElement('div'); statusbar.className = 'me-statusbar'; wrapper.appendChild(statusbar); } this.container.appendChild(wrapper); this.el = wrapper; this.toolbarEl = toolbar; this.bodyEl = body; this.editorPane = editorPane; this.editorInner = editorInner; this.gutter = gutter; this.previewPane = previewPane; this.dividerEl = divider; this.textarea = textarea; this.previewEl = preview; this.statusEl = statusbar; // Restore divider position if saved this._restoreDividerPosition(); } _buildToolbar() { const tools = this.config.toolbar; if (!tools || !Array.isArray(tools) || tools.length === 0) return; const modeActions = []; tools.forEach((item) => { if (item === '|') { const sep = document.createElement('span'); sep.className = 'me-toolbar-sep'; this.toolbarEl.appendChild(sep); return; } if (item === 'edit' || item === 'split' || item === 'preview' || item === 'fullscreen') { modeActions.push(item); return; } this.toolbarEl.appendChild(this._createBtn(item)); }); if (modeActions.length) { const group = document.createElement('div'); group.className = 'me-toolbar-group'; modeActions.forEach((item) => { const btn = this._createBtn(item); if (item === 'edit' || item === 'split' || item === 'preview') { btn.dataset.mode = item; if (item === this._mode) btn.classList.add('me-active'); } group.appendChild(btn); }); this.toolbarEl.appendChild(group); } } _createBtn(item) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = `me-btn me-btn-${item}`; btn.dataset.action = item; const label = t(item) || item; btn.title = label; btn.setAttribute('aria-label', label); btn.innerHTML = ICONS[item] || `${escapeHTML(item)}`; return btn; } _bindEvents() { const ta = this.textarea; const onInput = () => { const oldValue = this._value; this._value = ta.value; MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', oldValue, this._value); this._scheduleRender(); this._scheduleHistory(); this._updateWordCount(); this._renderGutter(); this._updateOutline(); this._emit('input', this._value); this._emit('change', this._value); if (typeof this.config.onInput === 'function') { try { this.config.onInput(this._value, this); } catch (e) { console.error(e); } } if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } } this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this); }; ta.addEventListener('input', onInput); const onKeydown = (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) this._handleSmartEnter(e); this._handleKeydown(e); }; ta.addEventListener('keydown', onKeydown); const onKeypress = (e) => this._handleBracketAutoClose(e); ta.addEventListener('keypress', onKeypress); const onCursorActivity = () => { this._updateCurrentLine(); }; ta.addEventListener('click', onCursorActivity); ta.addEventListener('keyup', onCursorActivity); const onScroll = () => { this._syncScroll(); }; ta.addEventListener('scroll', onScroll); const onPreviewScroll = () => { if (!this.config.syncScroll || this._mode !== 'split') return; if (this._syncing) return; this._syncing = true; const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight; if (pmax <= 0) { this._syncing = false; return; } const ratio = this.previewPane.scrollTop / pmax; const tmax = ta.scrollHeight - ta.clientHeight; ta.scrollTop = ratio * tmax; if (this.gutter) { const gmax = this.gutter.scrollHeight - this.gutter.clientHeight; this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ratio * tmax; } requestAnimationFrame(() => { this._syncing = false; }); }; this.previewPane.addEventListener('scroll', onPreviewScroll); const onFocus = () => { this._emit('focus'); if (typeof this.config.onFocus === 'function') { try { this.config.onFocus(this); } catch (e) { console.error(e); } } }; const onBlur = () => { this._emit('blur'); if (typeof this.config.onBlur === 'function') { try { this.config.onBlur(this); } catch (e) { console.error(e); } } }; ta.addEventListener('focus', onFocus); ta.addEventListener('blur', onBlur); const onPreviewClick = (e) => { const a = e.target.closest('a'); if (!a) return; const href = a.getAttribute('href'); if (!href || href.startsWith('#')) return; e.preventDefault(); this._emit('linkClick', { href, text: a.textContent }); if (typeof this.config.onLinkClick === 'function') { try { this.config.onLinkClick(href, a.textContent, this); } catch (_) { } return; } window.open(href, '_blank', 'noopener'); }; this.previewEl.addEventListener('click', onPreviewClick); const onToolbarClick = (e) => { const btn = e.target.closest('.me-btn'); if (!btn) return; const mode = btn.dataset.mode; if (mode) { this.setMode(mode); return; } const action = btn.dataset.action; if (action) this.exec(action); }; this.toolbarEl.addEventListener('click', onToolbarClick); const onDividerDown = (e) => this._bindDividerDrag(e); this.dividerEl.addEventListener('pointerdown', onDividerDown); this._bindDragDrop(); this._cleanups.push(() => { ta.removeEventListener('input', onInput); ta.removeEventListener('keydown', onKeydown); ta.removeEventListener('keypress', onKeypress); ta.removeEventListener('scroll', onScroll); this.previewPane.removeEventListener('scroll', onPreviewScroll); ta.removeEventListener('click', onCursorActivity); ta.removeEventListener('keyup', onCursorActivity); ta.removeEventListener('focus', onFocus); ta.removeEventListener('blur', onBlur); this.toolbarEl.removeEventListener('click', onToolbarClick); this.dividerEl.removeEventListener('pointerdown', onDividerDown); }); } _handleKeydown(e) { if (e.key === 'Tab') { e.preventDefault(); this._handleTab(e.shiftKey); return; } if (this._shortcuts.length) { for (const sc of this._shortcuts) { const ctrlOk = sc.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey; const shiftOk = sc.shift ? e.shiftKey : !e.shiftKey; const altOk = sc.alt ? e.altKey : !e.altKey; if (e.key.toLowerCase() === sc.key && ctrlOk && shiftOk && altOk) { e.preventDefault(); if (typeof sc.handler === 'string') this.exec(sc.handler); else if (typeof sc.handler === 'function') { try { sc.handler(this); } catch (err) { console.error('Shortcut error:', err); } } return; } } } const mod = e.ctrlKey || e.metaKey; if (!mod) return; const key = e.key.toLowerCase(); const shortcuts = { b: 'bold', i: 'italic', k: 'link', u: 'underline', e: 'code', '1': 'h1', '2': 'h2', '3': 'h3', q: 'quote' }; if (key === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); } else if ((key === 'z' && e.shiftKey) || key === 'y') { e.preventDefault(); this.redo(); } else if (key === 's') { e.preventDefault(); this._emit('save', this._value); if (typeof this.config.onSave === 'function') { try { this.config.onSave(this._value, this); } catch (err) { console.error(err); } } } else if (shortcuts[key]) { e.preventDefault(); this.exec(shortcuts[key]); } } _handleTab(shift) { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const tabSize = this.config.tabSize || 0; const tabStr = tabSize > 0 ? ' '.repeat(tabSize) : '\t'; if (start === end) { ta.value = ta.value.slice(0, start) + tabStr + ta.value.slice(end); ta.selectionStart = ta.selectionEnd = start + tabStr.length; } else { const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1; const block = ta.value.slice(lineStart, end); const lines = block.split('\n'); let newBlock; if (shift) { newBlock = lines.map((l) => l.replace(/^(\t| {1,4})/, '')).join('\n'); } else { newBlock = lines.map((l) => tabStr + l).join('\n'); } ta.value = ta.value.slice(0, lineStart) + newBlock + ta.value.slice(end); ta.selectionStart = lineStart; ta.selectionEnd = lineStart + newBlock.length; } this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); } _bindDividerDrag(e) { if (this._mode !== 'split') return; e.preventDefault(); const startX = e.clientX; const editorWidth = this.editorPane.getBoundingClientRect().width; const totalWidth = this.bodyEl.getBoundingClientRect().width; if (totalWidth <= 0) return; const divider = this.dividerEl; const onMove = (ev) => { const dx = ev.clientX - startX; let newW = editorWidth + dx; const min = 80; const max = totalWidth - 80 - divider.offsetWidth; newW = Math.max(min, Math.min(max, newW)); const pct = (newW / totalWidth) * 100; this.editorPane.style.flex = `0 0 ${pct}%`; this.previewPane.style.flex = `1 1 ${100 - pct}%`; }; const onUp = () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); this._saveDividerPosition(); }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); } _saveDividerPosition() { try { const epFlex = this.editorPane.style.flex; if (epFlex) localStorage.setItem('metona-editor-divider', epFlex); } catch (_) { } } _restoreDividerPosition() { try { const saved = localStorage.getItem('metona-editor-divider'); if (saved && this._mode === 'split') { this.editorPane.style.flex = saved; this.previewPane.style.flex = '1 1 auto'; } } catch (_) { } } _scheduleRender() { if (this._renderRaf) return; this._renderRaf = requestAnimationFrame(() => { this._renderRaf = null; this._render(); }); } _render() { if (this._mode === 'edit') return; if (this._lastRenderedValue === this._value) return; this._lastRenderedValue = this._value; MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this); let html; try { html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); } catch (err) { console.error('MeEditor render error:', err); html = `

    ${t('renderError')}: ${escapeHTML(err.message)}

    `; } if (typeof this.config.sanitize === 'function') { try { html = this.config.sanitize(html); } catch (e) { console.error('sanitize error:', e); } } this.previewEl.innerHTML = html; this._renderGutter(); MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this); } _renderGutter() { if (!this.config.lineNumbers || !this.gutter) return; const lines = this._value ? this._value.split('\n').length : 1; const current = this.gutter.children.length; if (current === lines) return; if (current < lines) { // Add new line numbers let h = ''; for (let i = current + 1; i <= lines; i++) h += `
    ${i}
    `; this.gutter.insertAdjacentHTML('beforeend', h); } else { // Remove excess line numbers while (this.gutter.children.length > lines) { this.gutter.removeChild(this.gutter.lastChild); } } } _updateCurrentLine() { if (!this.gutter) return; const pos = this.textarea.selectionStart; const lineNum = this._value.substring(0, pos).split('\n').length; const prev = this.gutter.querySelector('.me-gutter-active'); if (prev) prev.classList.remove('me-gutter-active'); const cur = this.gutter.children[lineNum - 1]; if (cur) cur.classList.add('me-gutter-active'); } _handleSmartEnter(e) { const ta = this.textarea; const start = ta.selectionStart; const val = ta.value; const lineStart = val.lastIndexOf('\n', start - 1) + 1; const line = val.slice(lineStart, start); const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s(.*)/); if (listMatch) { const indent = listMatch[1]; const marker = listMatch[2]; const content = listMatch[3]; if (!content.trim()) { e.preventDefault(); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return; } let nextMarker = marker; if (/^\d+\.$/.test(marker)) { const num = parseInt(marker, 10); if (!isNaN(num)) nextMarker = (num + 1) + '.'; } e.preventDefault(); const insert = '\n' + indent + nextMarker + ' '; ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.selectionStart = ta.selectionEnd = start + insert.length; this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return; } const quoteMatch = line.match(/^(\s*>+\s?)(.*)/); if (quoteMatch) { const prefix = quoteMatch[1]; const content = quoteMatch[2]; if (!content.trim()) { e.preventDefault(); ta.value = val.slice(0, lineStart) + '\n' + val.slice(start); ta.selectionStart = ta.selectionEnd = lineStart; this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); return; } e.preventDefault(); const insert = '\n' + prefix; ta.value = val.slice(0, start) + insert + val.slice(ta.selectionEnd); ta.selectionStart = ta.selectionEnd = start + insert.length; this._value = ta.value; this._pushHistory(); this._renderGutter(); this._emit('change', this._value); } } _handleBracketAutoClose(e) { if (!this.config.autoBrackets) return; const ta = this.textarea; const key = e.key; const close = BRACKET_PAIRS[key]; if (!close) return; const start = ta.selectionStart; const end = ta.selectionEnd; if (start !== end) { e.preventDefault(); const selected = ta.value.slice(start, end); const wrap = key + selected + close; ta.value = ta.value.slice(0, start) + wrap + ta.value.slice(end); ta.selectionStart = start + 1; ta.selectionEnd = start + 1 + selected.length; this._value = ta.value; this._pushHistory(); this._emit('change', this._value); return; } const nextChar = ta.value[start] || ''; if (key === close) { if (nextChar === close) { e.preventDefault(); ta.selectionStart = ta.selectionEnd = start + 1; return; } if (/\w/.test(nextChar)) return; } e.preventDefault(); const pair = key + close; ta.value = ta.value.slice(0, start) + pair + ta.value.slice(end); ta.selectionStart = ta.selectionEnd = start + 1; this._value = ta.value; this._pushHistory(); this._emit('change', this._value); } _bindDragDrop() { const ta = this.textarea; if (!ta) return; const onDragOver = (e) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }; const onDrop = (e) => { e.preventDefault(); const files = e.dataTransfer?.files; if (files && files.length) { Array.from(files).forEach((file) => { if (file.type.startsWith('image/')) { const reader = new FileReader(); reader.onload = () => { this.insert(`![${file.name}](${reader.result})\n`); }; reader.readAsDataURL(file); } else if (file.type.startsWith('text/') || /\.(md|txt|js|ts|json|css|html|xml|yml|yaml)$/i.test(file.name)) { const reader = new FileReader(); reader.onload = () => this.insert(reader.result); reader.readAsText(file); } }); return; } const text = e.dataTransfer?.getData('text/plain'); if (text) this.insert(text); }; ta.addEventListener('dragover', onDragOver); ta.addEventListener('drop', onDrop); this._cleanups.push(() => { ta.removeEventListener('dragover', onDragOver); ta.removeEventListener('drop', onDrop); }); } _buildOutline() { if (!this.config.outline || !this.previewEl) return; const old = this.el.querySelector('.me-outline'); if (old) old.remove(); const headings = []; const headingRe = /]*>(.+?)<\/h\1>/gi; let m; while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) { headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') }); } if (!headings.length) return; const panel = document.createElement('div'); panel.className = 'me-outline'; panel.innerHTML = `
    ${t('outline') || '大纲'}
    `; const buildTree = (items, minLevel) => { let h = ''; }; panel.innerHTML += buildTree(headings, 1); this.el.appendChild(panel); panel.addEventListener('click', (e) => { const a = e.target.closest('a'); if (!a) return; e.preventDefault(); const id = a.getAttribute('href').slice(1); const target = this.previewEl.querySelector('#' + CSS.escape(id)); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); const idx = this._value.indexOf(target.textContent || ''); if (idx !== -1) { this.textarea.focus(); this.textarea.setSelectionRange(idx, idx); } } }); } _updateOutline() { if (!this.config.outline) return; if (this._outlineTimer) clearTimeout(this._outlineTimer); this._outlineTimer = setTimeout(() => this._buildOutline(), 300); } _trackOutlineScroll() { if (!this.config.outline || !this.previewPane) return; let ticking = false; const onScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { ticking = false; if (!this.el) return; const panel = this.el.querySelector('.me-outline'); if (!panel) return; const headings = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6'); let activeId = ''; const scrollTop = this.previewPane.scrollTop + 80; // offset for better UX headings.forEach((h) => { if (h.offsetTop <= scrollTop) { activeId = h.id; } }); panel.querySelectorAll('a').forEach((a) => { a.classList.toggle('me-outline-active', a.getAttribute('href') === '#' + activeId); }); }); }; this.previewPane.addEventListener('scroll', onScroll, { passive: true }); this._cleanups.push(() => this.previewPane.removeEventListener('scroll', onScroll)); } _scheduleHistory() { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } _pushHistory() { const cur = this._history[this._historyIndex]; if (cur === this._value) return; this._history = this._history.slice(0, this._historyIndex + 1); this._history.push(this._value); const limit = this.config.historyLimit > 0 ? this.config.historyLimit : 100; while (this._history.length > limit) this._history.shift(); this._historyIndex = this._history.length - 1; } undo() { if (this._historyIndex <= 0) return this; this._historyIndex--; this._applyHistory(); return this; } redo() { if (this._historyIndex >= this._history.length - 1) return this; this._historyIndex++; this._applyHistory(); return this; } canUndo() { return this._historyIndex > 0; } canRedo() { return this._historyIndex < this._history.length - 1; } _applyHistory() { this._value = this._history[this._historyIndex]; this.textarea.value = this._value; this._render(); this._renderGutter(); this._updateWordCount(); if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop; if (this.previewPane && this.config.syncScroll) this.previewPane.scrollTop = 0; this._emit('change', this._value); if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } } } exec(action, ...args) { const actions = { bold: () => this._wrapSelection('**', '**'), italic: () => this._wrapSelection('*', '*'), strikethrough: () => this._wrapSelection('~~', '~~'), underline: () => this._wrapSelection('', ''), code: () => this._wrapSelection('`', '`'), h1: () => this._toggleLinePrefix('# '), h2: () => this._toggleLinePrefix('## '), h3: () => this._toggleLinePrefix('### '), quote: () => this._toggleLinePrefix('> '), ul: () => this._toggleLinePrefix('- '), ol: () => this._toggleLinePrefix('1. '), indent: () => this._handleTab(false), outdent: () => this._handleTab(true), hr: () => this._insertBlock('\n---\n'), link: () => this._insertLink(), image: () => this._insertImage(), table: () => this._insertTable(), undo: () => this.undo(), redo: () => this.redo(), edit: () => this.setMode('edit'), split: () => this.setMode('split'), preview: () => this.setMode('preview'), fullscreen: () => this.toggleFullscreen(), zen: () => this.toggleZen(), wordwrap: () => this.toggleWordWrap(), formatTable: () => this._formatTable(), }; const fn = actions[action]; if (fn) { fn.apply(this, args); return this; } if (this._customActions && typeof this._customActions[action] === 'function') { try { this._customActions[action].apply(this, args); } catch (e) { console.error('custom action error:', e); } } return this; } _wrapSelection(before, after) { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const selected = ta.value.slice(start, end); const text = selected || 'text'; const inserted = before + text + after; ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end); ta.focus(); if (selected) { ta.selectionStart = start + before.length; ta.selectionEnd = start + before.length + text.length; } else { ta.selectionStart = ta.selectionEnd = start + before.length; } this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); } _toggleLinePrefix(prefix) { const ta = this.textarea; const start = ta.selectionStart; const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1; const lineEndPos = ta.value.indexOf('\n', start); const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos; const line = ta.value.slice(lineStart, lineEnd); const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/); let newLine; if (existingMatch && existingMatch[0] === prefix) newLine = line.slice(prefix.length); else if (existingMatch) newLine = prefix + line.slice(existingMatch[0].length); else newLine = prefix + line; ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.focus(); ta.selectionStart = ta.selectionEnd = lineStart + newLine.length; this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); } _insertBlock(text) { const ta = this.textarea; const start = ta.selectionStart; const before = ta.value.slice(0, start); const needNL = before && !before.endsWith('\n'); const insert = (needNL ? '\n' : '') + text; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd); ta.focus(); const pos = start + insert.length; ta.selectionStart = ta.selectionEnd = pos; this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); } _insertLink() { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || t('link') || 'link'; const url = 'https://'; const insert = `[${sel}](${url})`; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); } _insertImage() { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || t('image') || 'image'; const url = 'https://'; const insert = `![${sel}](${url})`; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); } _insertTable(rows = 3, cols = 3) { const header = Array.from({ length: cols }, (_, i) => `${t('tableCols') || '列'}${i + 1}`).join(' | '); const sep = Array.from({ length: cols }, () => '---').join(' | '); let md = `| ${header} |\n| ${sep} |\n`; for (let r = 1; r < rows; r++) md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`; this._insertBlock('\n' + md); } _formatTable() { const ta = this.textarea; const start = ta.selectionStart; // Find table boundaries around cursor const before = ta.value.substring(0, start); const after = ta.value.substring(start); const blockStart = before.lastIndexOf('\n\n'); const blockEnd = after.indexOf('\n\n'); const tableStart = blockStart === -1 ? 0 : blockStart + 2; const tableEnd = blockEnd === -1 ? ta.value.length : start + blockEnd; const tableText = ta.value.substring(tableStart, tableEnd); const lines = tableText.split('\n').filter((l) => l.includes('|')); if (lines.length < 2) return; // need at least header + separator // Parse columns const splitRow = (r) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/); const allCells = lines.map(splitRow); const colCount = Math.max(...allCells.map((c) => c.length)); // Calculate max width per column const colWidths = Array(colCount).fill(3); allCells.forEach((cells) => { cells.forEach((cell, ci) => { colWidths[ci] = Math.max(colWidths[ci], cell.trim().length); }); }); // Rebuild table const pad = (s, w) => { const padLen = w - s.length; return s + ' '.repeat(Math.max(0, padLen)); }; const formatted = allCells.map((cells) => { const padded = []; for (let ci = 0; ci < colCount; ci++) { padded.push(pad((cells[ci] || '').trim(), colWidths[ci])); } return '| ' + padded.join(' | ') + ' |'; }); // Replace in value ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd); this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); } setMode(mode) { if (!MODES.includes(mode) || mode === this._mode) return this; const taS = this.textarea.scrollTop; const pvS = this.previewPane.scrollTop; this._mode = mode; this.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit') this._render(); this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${t(mode) || mode} mode`); if (typeof this.config.onModeChange === 'function') { try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); } } return this; } getMode() { return this._mode; } _updateModeButtons() { const isPreview = this._mode === 'preview'; const isReadonly = this.config.readOnly; this.toolbarEl.querySelectorAll('.me-btn').forEach((b) => { const btn = b; const mode = btn.dataset.mode; const action = btn.dataset.action; if (mode) { btn.classList.toggle('me-active', mode === this._mode); btn.disabled = false; return; } if (action === 'fullscreen') { btn.disabled = false; return; } if (isReadonly) { btn.disabled = !(action === 'undo' || action === 'redo'); return; } if (isPreview) { btn.disabled = !(action === 'undo' || action === 'redo'); return; } btn.disabled = false; }); } toggleFullscreen() { this._fullscreen = !this._fullscreen; this.el.classList.toggle('me-fullscreen', this._fullscreen); const fsBtn = this.toolbarEl.querySelector('.me-btn-fullscreen'); if (fsBtn) fsBtn.title = this._fullscreen ? (t('fullscreenExit') || '退出全屏') : (t('fullscreen') || '全屏'); this._emit('fullscreen', this._fullscreen); if (typeof this.config.onFullscreen === 'function') { try { this.config.onFullscreen(this._fullscreen, this); } catch (e) { console.error(e); } } return this; } isFullscreen() { return this._fullscreen; } exitFullscreen() { if (this._fullscreen) this.toggleFullscreen(); return this; } toggleZen() { this._zenMode = !this._zenMode; if (this.el) this.el.classList.toggle('me-zen', this._zenMode); if (this._zenMode && this.toolbarEl) { this._zenMouseHandler = (e) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; }; this.toolbarEl.style.transition = 'opacity 0.2s'; this.toolbarEl.style.opacity = '0'; this.toolbarEl.style.pointerEvents = 'none'; document.addEventListener('mousemove', this._zenMouseHandler); if (this.statusEl) this.statusEl.style.display = 'none'; } else { if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; } if (this.toolbarEl) { this.toolbarEl.style.opacity = ''; this.toolbarEl.style.pointerEvents = ''; this.toolbarEl.style.transition = ''; } if (this.statusEl) this.statusEl.style.display = ''; } this._emit('zenChange', this._zenMode); return this; } isZen() { return this._zenMode; } toggleWordWrap() { this._wordWrap = !this._wordWrap; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; } setWordWrap(on) { this._wordWrap = !!on; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; } isWordWrap() { return this._wordWrap; } _initFloatingToolbar() { if (typeof document === 'undefined') return; // Inject floating toolbar CSS once globally if (!document.getElementById('me-float-style')) { const style = document.createElement('style'); style.id = 'me-float-style'; style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`; document.head.appendChild(style); } if (!this._floatingEnabled) return; const ta = this.textarea; const show = () => { if (this._destroyed || this.config.readOnly || !this._floatingEnabled) return; const start = ta.selectionStart; const end = ta.selectionEnd; if (start === end) { this._hideFloatingToolbar(); return; } if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar(); // Position the toolbar near the selection (toolbar is absolute inside editorPane) const taRect = ta.getBoundingClientRect(); const paneRect = this.editorPane.getBoundingClientRect(); const textBefore = ta.value.substring(0, start); const lines = textBefore.split('\n'); const currentLine = lines.length - 1; const lineHeight = parseInt(getComputedStyle(ta).lineHeight, 10) || 22; const paddingTop = parseInt(getComputedStyle(ta).paddingTop, 10) || 16; const scrollOffset = ta.scrollTop; // Line top relative to viewport const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - scrollOffset; // Convert to editorPane-local coordinates const lineLocalTop = lineViewportTop - paneRect.top; const toolbarHeight = 36; const gap = 4; // Try above first let top = lineLocalTop - toolbarHeight - gap; // If not enough room above, place below the selection if (top < gap) { top = lineLocalTop + lineHeight + gap; } // Horizontal: center near selection, in pane-local coords const selMidX = taRect.left + (ta.selectionEnd - ta.selectionStart) / 2 * (taRect.width / Math.max(1, ta.value.length || 1)); let left = selMidX - paneRect.left - 80; // ~half toolbar width (5*28 + gaps) 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(); }; let lastSelStart = ta.selectionStart; let lastSelEnd = ta.selectionEnd; this._selectionTimer = setInterval(() => { if (this._destroyed || !document.body.contains(ta) || !this._floatingEnabled) return; const s = ta.selectionStart; const e = ta.selectionEnd; if (s !== lastSelStart || e !== lastSelEnd) { lastSelStart = s; lastSelEnd = e; const text = this._value.slice(s, e); this._emit('selectionChange', { start: s, end: e, text }); } }, 500); this._cleanups.push(() => { if (this._selectionTimer) { clearInterval(this._selectionTimer); this._selectionTimer = null; } }); ta.addEventListener('mouseup', () => { if (this._floatingEnabled) setTimeout(show, 0); }); ta.addEventListener('keyup', () => { if (!this._floatingEnabled) return; if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0); else setTimeout(hide, 0); this._emit('cursorMove', this.getCursorPosition()); }); ta.addEventListener('blur', () => setTimeout(hide, 300)); ta.addEventListener('click', () => { if (ta.selectionStart === ta.selectionEnd) setTimeout(hide, 0); }); } toggleFloatingToolbar() { this._floatingEnabled = !this._floatingEnabled; if (this._floatingEnabled) { // Re-enable: restart the interval if (!this._selectionTimer) { const ta = this.textarea; this._selectionTimer = setInterval(() => { if (this._destroyed || !this._floatingEnabled) return; ta.selectionStart; ta.selectionEnd; }, 500); this._cleanups.push(() => { if (this._selectionTimer) { clearInterval(this._selectionTimer); this._selectionTimer = null; } }); } } else { // Disable: clear interval and hide toolbar if (this._selectionTimer) { clearInterval(this._selectionTimer); this._selectionTimer = null; } if (this._floatingToolbar) { if (this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); this._floatingToolbar = null; } } return this; } isFloatingToolbar() { return this._floatingEnabled; } _buildFloatingToolbar() { const bar = document.createElement('div'); bar.className = 'me-float-toolbar'; const actions = ['bold', 'italic', 'code', 'link', 'strikethrough']; actions.forEach((action) => { const btn = this._createBtn(action); btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); this.exec(action); // Keep selection after exec setTimeout(() => this.textarea.focus(), 0); }); bar.appendChild(btn); }); this.editorPane.appendChild(bar); this._floatingToolbar = bar; } _hideFloatingToolbar() { if (this._floatingToolbar) { this._floatingToolbar.classList.remove('me-visible'); } } _bindToolbarKeyboard() { if (!this.toolbarEl) return; const onKeydown = (e) => { const btns = [...this.toolbarEl.querySelectorAll('.me-btn:not(:disabled)')]; if (!btns.length) return; const idx = btns.indexOf(document.activeElement); if (idx === -1) return; if (e.key === 'ArrowRight') { e.preventDefault(); btns[(idx + 1) % btns.length].focus(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); btns[(idx - 1 + btns.length) % btns.length].focus(); } else if (e.key === 'Home') { e.preventDefault(); btns[0].focus(); } else if (e.key === 'End') { e.preventDefault(); btns[btns.length - 1].focus(); } }; this.toolbarEl.addEventListener('keydown', onKeydown); this._cleanups.push(() => this.toolbarEl.removeEventListener('keydown', onKeydown)); } _initAriaLive() { if (!this.el) return; const live = document.createElement('div'); live.className = 'me-sr-only'; live.setAttribute('aria-live', 'polite'); live.setAttribute('aria-atomic', 'true'); this.el.appendChild(live); this._ariaLive = live; } _announce(msg) { if (this._ariaLive) { this._ariaLive.textContent = ''; requestAnimationFrame(() => { this._ariaLive.textContent = msg; }); } } _syncScroll() { if (this._syncing) return; this._syncing = true; const ta = this.textarea; if (!ta) { this._syncing = false; return; } const tmax = ta.scrollHeight - ta.clientHeight; if (tmax <= 0) { this._syncing = false; return; } const ratio = ta.scrollTop / tmax; if (this.gutter) { const gmax = this.gutter.scrollHeight - this.gutter.clientHeight; this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ta.scrollTop; } if (this.config.syncScroll && this._mode === 'split' && this.previewPane) { const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight; if (pmax > 0) this.previewPane.scrollTop = ratio * pmax; } requestAnimationFrame(() => { this._syncing = false; }); } _updateWordCount() { if (!this.config.wordCount || !this.statusEl) return; const text = this._value || ''; const chars = text.length; const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length; const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length; const words = cnChars + enWords; const lines = text ? text.split('\n').length : 0; const readingMin = Math.max(1, Math.ceil(words / 300)); this.statusEl.textContent = [`${t('characters')}: ${chars}`, `${t('words')}: ${words}`, `${t('lines')}: ${lines}`, `${t('readingTime')}: ${readingMin} ${t('minutes')}`].join(' · '); } getStats() { const text = this._value || ''; const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length; const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length; return { characters: text.length, words: cnChars + enWords, chineseChars: cnChars, englishWords: enWords, lines: text ? text.split('\n').length : 0, readingTime: Math.max(1, Math.ceil((cnChars + enWords) / 300)), }; } getSelectedText() { if (!this.textarea) return ''; return this._value.slice(this.textarea.selectionStart, this.textarea.selectionEnd); } getCursorPosition() { if (!this.textarea) return { line: 1, column: 0 }; const pos = this.textarea.selectionStart; const before = this._value.substring(0, pos); const line = before.split('\n').length; const column = pos - before.lastIndexOf('\n') - 1; return { line, column: column < 0 ? 0 : column }; } setCursorPosition(line, column = 0) { if (!this.textarea) return this; const lines = this._value.split('\n'); const clampedLine = Math.max(1, Math.min(line, lines.length)); let pos = 0; for (let i = 0; i < clampedLine - 1; i++) pos += lines[i].length + 1; pos += Math.min(column, lines[clampedLine - 1]?.length || 0); this.textarea.focus(); this.textarea.setSelectionRange(pos, pos); return this; } scrollToLine(line) { if (!this.textarea) return this; const lines = this._value.split('\n'); const clampedLine = Math.max(1, Math.min(line, lines.length)); const lineHeight = 22; // approximate this.textarea.scrollTop = (clampedLine - 1) * lineHeight; if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop; return this; } selectLine(line) { if (!this.textarea) return this; const lines = this._value.split('\n'); const clampedLine = Math.max(1, Math.min(line, lines.length)); let startPos = 0; for (let i = 0; i < clampedLine - 1; i++) startPos += lines[i].length + 1; const endPos = startPos + lines[clampedLine - 1].length; this.textarea.focus(); this.textarea.setSelectionRange(startPos, endPos); return this; } selectAll() { if (this.textarea) { this.textarea.focus(); this.textarea.select(); } return this; } replaceAll(search, replace, caseSensitive = true) { if (!search) return 0; const flags = caseSensitive ? 'g' : 'gi'; const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(escaped, flags); const matches = this._value.match(re); if (!matches) return 0; this._value = this._value.replace(re, replace); this.textarea.value = this._value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return matches.length; } replaceAllRegex(pattern, replace) { if (!pattern) return 0; const re = new RegExp(pattern, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g'); const matches = this._value.match(re); if (!matches) return 0; this._value = this._value.replace(re, replace); this.textarea.value = this._value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return matches.length; } lineCount() { return this._value ? this._value.split('\n').length : 0; } getLine(line) { const lines = this._value.split('\n'); const idx = line - 1; if (idx < 0 || idx >= lines.length) return ''; return lines[idx]; } getValue() { return this._destroyed ? '' : this._value; } setValue(md, opts = {}) { if (this._destroyed) return this; MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', this._value, md); this._value = md || ''; this.textarea.value = this._value; if (!opts.silent) this._pushHistory(); this._render(); this._renderGutter(); this._updateWordCount(); this._updateOutline(); if (this.gutter) this.gutter.scrollTop = 0; this.textarea.scrollTop = 0; if (!opts.silent) { this._emit('change', this._value); if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } } } this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this); return this; } getHTML() { MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this); let html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); if (typeof this.config.sanitize === 'function') { try { html = this.config.sanitize(html); } catch (e) { console.error(e); } } MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this); return html; } refresh() { this._lastRenderedValue = null; this._render(); return this; } copyAsMarkdown() { if (typeof navigator !== 'undefined' && navigator.clipboard) { navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'markdown' })).catch(() => { }); } return this; } copyAsHTML() { const html = this.getHTML(); if (typeof navigator !== 'undefined' && navigator.clipboard) { const blob = new Blob([html], { type: 'text/html' }); const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) }); navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => { }); } return this; } insert(text, opts = {}) { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; ta.value = ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start); ta.focus(); ta.selectionStart = ta.selectionEnd = start + text.length; this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); return this; } wrap(before, after) { this._wrapSelection(before, after || before); return this; } focus() { if (this.textarea) this.textarea.focus(); return this; } blur() { if (this.textarea) this.textarea.blur(); return this; } enable() { if (this.textarea) this.textarea.disabled = false; if (this.el) this.el.classList.remove('me-disabled'); return this; } disable() { if (this.textarea) this.textarea.disabled = true; if (this.el) this.el.classList.add('me-disabled'); return this; } isDisabled() { return this.textarea ? this.textarea.disabled : false; } setReadOnly(readOnly) { this.config.readOnly = !!readOnly; if (this.textarea) this.textarea.readOnly = !!readOnly; if (this.el) this.el.classList.toggle('me-readonly', !!readOnly); this._updateModeButtons(); return this; } isReadOnly() { return !!this.config.readOnly; } on(name, fn) { if (!this._listeners[name]) this._listeners[name] = []; this._listeners[name].push(fn); return () => this.off(name, fn); } off(name, fn) { const list = this._listeners[name]; if (list) this._listeners[name] = list.filter((f) => f !== fn); return this; } _emit(name, ...args) { const list = this._listeners[name]; if (list) list.forEach((fn) => { try { fn(...args, this); } catch (e) { console.error(`MeEditor event "${name}" error:`, e); } }); } use(plugin, options = {}) { let p = plugin; if (typeof plugin === 'string') { p = presetPlugins[plugin]; if (!p) { console.warn(`MeEditor: preset plugin "${plugin}" not found`); return this; } } if (!p || typeof p !== 'object') { console.warn('MeEditor: invalid plugin'); return this; } const merged = { ...p, ...options }; if (typeof merged.install === 'function') { try { const result = merged.install(this, options); if (result && typeof result.then === 'function') { result.catch((e) => console.error(`MeEditor: async plugin "${merged.name}" error:`, e)); } } catch (e) { console.error(`MeEditor: plugin "${merged.name}" install error:`, e); } } this._plugins.push(merged); return this; } unuse(name) { const idx = this._plugins.findIndex((p) => p.name === name); if (idx === -1) { console.warn(`MeEditor: plugin "${name}" not found`); return this; } const plugin = this._plugins[idx]; if (typeof plugin.destroy === 'function') { try { plugin.destroy(this); } catch (e) { console.error(`MeEditor: plugin "${name}" destroy error:`, e); } } this._plugins.splice(idx, 1); return this; } getPlugins() { return [...this._plugins]; } addToolbarButton(config) { if (!config || !config.action) return this; const btn = document.createElement('button'); btn.type = 'button'; btn.className = `me-btn me-btn-custom-${config.action}`; btn.title = config.title || config.action; btn.setAttribute('aria-label', config.title || config.action); btn.innerHTML = config.icon || `${escapeHTML(config.text || config.action)}`; if (typeof config.onClick === 'function') { btn.addEventListener('click', (e) => { e.stopPropagation(); config.onClick(this); }); } else if (config.action) { btn.dataset.action = config.action; this._customActions = this._customActions || {}; this._customActions[config.action] = config.handler; } this.toolbarEl.insertBefore(btn, this.toolbarEl.querySelector('.me-toolbar-group') || null); return this; } registerShortcut(combo, handler, description = '') { if (!combo) return this; const parts = combo.toLowerCase().split('+'); const key = parts.pop(); const ctrl = parts.includes('ctrl') || parts.includes('cmd'); const shift = parts.includes('shift'); const alt = parts.includes('alt'); const meta = parts.includes('meta'); this._shortcuts.push({ combo, key, ctrl, shift, alt, meta, handler, description }); return this; } unregisterShortcut(combo) { this._shortcuts = this._shortcuts.filter((s) => s.combo !== combo); return this; } getShortcuts() { return [...this._shortcuts]; } configureToolbar(tools) { if (!this.toolbarEl) return this; this.config.toolbar = tools; this.toolbarEl.innerHTML = ''; this._buildToolbar(); return this; } removeToolbarButton(action) { if (!this.toolbarEl) return this; const btn = this.toolbarEl.querySelector(`.me-btn[data-action="${action}"], .me-btn[data-mode="${action}"]`); if (btn) btn.remove(); return this; } registerContextMenu(items = []) { this._contextMenuItems = items; return this; } _bindContextMenu() { if (!this.el) return; const onContextMenu = (e) => { const existing = document.querySelector('.me-context-menu'); if (existing) existing.remove(); this._showContextMenu(e); }; this.el.addEventListener('contextmenu', onContextMenu); this._cleanups.push(() => this.el.removeEventListener('contextmenu', onContextMenu)); } _showContextMenu(e) { e.preventDefault(); const ta = this.textarea; const hasSelection = ta && ta.selectionStart !== ta.selectionEnd; const defaultItems = [ { label: t('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() }, { label: t('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() }, { sep: true }, { label: t('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection }, { label: t('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection }, { label: t('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly }, { label: t('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' }, ]; const items = [...defaultItems]; if (this._contextMenuItems.length) { items.push({ sep: true }); this._contextMenuItems.forEach((item) => items.push(item)); } const menu = document.createElement('div'); menu.className = 'me-context-menu'; menu.style.left = e.clientX + 'px'; menu.style.top = e.clientY + 'px'; // Adjust if off-screen requestAnimationFrame(() => { const rect = menu.getBoundingClientRect(); if (rect.right > window.innerWidth) menu.style.left = (e.clientX - rect.width) + 'px'; if (rect.bottom > window.innerHeight) menu.style.top = (e.clientY - rect.height) + 'px'; }); items.forEach((item) => { if (item.sep) { const sep = document.createElement('div'); sep.className = 'me-context-menu-sep'; menu.appendChild(sep); return; } const el = document.createElement('div'); el.className = 'me-context-menu-item'; if (item.disabled) el.classList.add('me-disabled'); el.innerHTML = `${item.label}${item.shortcut ? `${item.shortcut}` : ''}`; el.addEventListener('click', (ev) => { ev.stopPropagation(); if (item.disabled) return; if (item.onClick) { item.onClick(); } else if (item.action) this._execContextAction(item.action); this._hideContextMenu(); }); menu.appendChild(el); }); document.body.appendChild(menu); const close = (ev) => { if (!menu.contains(ev.target)) { this._hideContextMenu(); } }; document.addEventListener('click', close, { once: true }); document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') this._hideContextMenu(); }, { once: true }); } _hideContextMenu() { const menu = document.querySelector('.me-context-menu'); if (menu) menu.remove(); } _execContextAction(action) { const ta = this.textarea; if (!ta) return; switch (action) { case 'undo': this.undo(); break; case 'redo': this.redo(); break; case 'cut': document.execCommand('cut'); break; case 'copy': document.execCommand('copy'); break; case 'paste': document.execCommand('paste'); break; case 'selectAll': ta.focus(); ta.select(); break; default: this.exec(action); break; } } toast(message, opts = {}) { if (!this.el || typeof document === 'undefined') return this; const { type = 'info', duration = 3000, animation = 'fade' } = opts; const toast = document.createElement('div'); toast.className = `me-toast me-toast-${type} me-toast-${animation}`; toast.innerHTML = `${TOAST_ICONS[type] || ''}${escapeHTML(String(message))}`; this.el.appendChild(toast); requestAnimationFrame(() => { toast.classList.add('me-toast-enter'); }); const remove = () => { toast.classList.remove('me-toast-enter'); toast.classList.add('me-toast-leave'); setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300); }; if (duration > 0) setTimeout(remove, duration); toast.addEventListener('click', remove); return this; } setLocale(locale) { if (this._i18nCtx) this._i18nCtx.set(locale); return this; } getLocale() { if (this._i18nCtx) return this._i18nCtx.get(); return getCurrentLocale(); } t(key, params) { if (this._i18nCtx) return this._i18nCtx.t(key, params); return t(key, params); } setTheme(theme) { if (!theme) return this; this.config.theme = theme; if (this._themeCtx) this._themeCtx.set(theme); return this; } getTheme() { if (this._themeCtx) return this._themeCtx.get(); return this.config.theme || 'auto'; } getThemeContext() { return this._themeCtx || null; } getStatus() { return { id: this.id, mode: this._mode, theme: this.getTheme(), locale: getCurrentLocale(), fullscreen: this._fullscreen, readOnly: this.config.readOnly || false, disabled: this.textarea ? this.textarea.disabled : false, destroyed: this._destroyed, plugins: this._plugins.map((p) => p.name || 'custom'), }; } destroy() { if (this._destroyed) return; MarkdownEditor.trigger('beforeDestroy', this); if (this._renderRaf) cancelAnimationFrame(this._renderRaf); if (this._historyTimer) clearTimeout(this._historyTimer); if (this._outlineTimer) clearTimeout(this._outlineTimer); if (this._selectionTimer) { clearInterval(this._selectionTimer); this._selectionTimer = null; } this._cleanups.forEach((fn) => { try { fn(); } catch (_) { } }); this._cleanups = []; this._shortcuts = []; this._contextMenuItems = []; this._customActions = {}; if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); this._floatingToolbar = null; if (this.el) this.el.querySelectorAll('.me-toast').forEach((t) => t.remove()); this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { try { p.destroy(this); } catch (_) { } } }); this._plugins = []; if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el); this.el = null; this.textarea = null; this.previewEl = null; this._destroyed = true; if (typeof this.config.onDestroy === 'function') { try { this.config.onDestroy(this); } catch (e) { console.error(e); } } this._emit('destroy'); this._listeners = {}; MarkdownEditor.trigger('afterDestroy', this); } isDestroyed() { return this._destroyed; } } MarkdownEditor._hooks = new Map(); /** * MetonaEditor Animations — animation metadata * @module animations * @version 0.2.0 */ const animationMap = new Map(); // Register built-in animations for (const [name, config] of Object.entries(ANIMATIONS)) { animationMap.set(name, config); } const animationUtils = { register(name, config) { animationMap.set(name, { name, enter: config.enter || {}, leave: config.leave || {}, duration: config.duration || 300, easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)', }); }, unregister(name) { animationMap.delete(name); }, get(name) { return animationMap.get(name) || ANIMATIONS[name] || null; }, getAnimationNames() { return Array.from(animationMap.keys()); }, getActiveCount() { return 0; }, cancelAll() { // CSS animations managed by browser natively }, reset() { animationMap.clear(); for (const [name, config] of Object.entries(ANIMATIONS)) { animationMap.set(name, config); } }, destroy() { animationMap.clear(); }, }; /** * MetonaEditor — Type-safe, lightweight Markdown Editor * @module metona-editor * @version 0.2.3 */ const VERSION = '0.2.3'; const globalPlugins = []; MarkdownEditor.on('beforeCreate', (editor) => { if (!editor || editor.isDestroyed()) return; globalPlugins.forEach((p) => { try { editor.use(p); } catch (e) { console.error('MeEditor global plugin install error:', e); } }); }); function create(container, options = {}) { return new MarkdownEditor(container, options); } function use(plugin, options = {}) { let p = plugin; if (typeof plugin === 'string') { p = presetPlugins[plugin]; if (!p) { console.warn(`MeEditor: preset plugin "${plugin}" not found`); return api; } } if (!p || typeof p !== 'object') { console.warn('MeEditor: invalid plugin'); return api; } const merged = (options && typeof options === 'object' && Object.keys(options).length > 0) ? { ...p, ...options } : p; globalPlugins.push(merged); return api; } function on(name, fn) { return MarkdownEditor.on(name, fn); } function off(name, fn) { MarkdownEditor.off(name, fn); return api; } function setTheme(theme, _options) { if (themeUtils && typeof themeUtils.switchTheme === 'function') themeUtils.switchTheme(theme); return api; } function setLocale(locale) { if (i18nUtils && typeof i18nUtils.setCurrentLocale === 'function') i18nUtils.setCurrentLocale(locale); return api; } function destroy() { if (themeUtils) { try { themeUtils.clearThemeListeners(); } catch (_) { } try { themeUtils.unwatchSystemTheme(); } catch (_) { } } if (i18nUtils) { try { i18nUtils.clearLocaleListeners(); } catch (_) { } } if (animationUtils) { try { animationUtils.cancelAll(); } catch (_) { } } globalPlugins.length = 0; } function getStatus() { return { version: VERSION, theme: (themeUtils && typeof themeUtils.getCurrentTheme === 'function') ? themeUtils.getCurrentTheme() : 'auto', locale: (i18nUtils && typeof i18nUtils.getCurrentLocale === 'function') ? i18nUtils.getCurrentLocale() : 'zh-CN', globalPlugins: globalPlugins.map((p) => p.name || 'custom'), presetPlugins: Object.keys(presetPlugins || {}), }; } try { if (typeof themeUtils === 'object' && typeof themeUtils.initTheme === 'function') themeUtils.initTheme(); if (typeof i18nUtils === 'object' && typeof i18nUtils.initI18n === 'function') i18nUtils.initI18n(); } catch (_) { } const api = { VERSION, version: VERSION, MarkdownEditor, Editor: MarkdownEditor, create, use, on, off, setTheme, setLocale, destroy, getStatus, parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler, themes: themeUtils, i18n: i18nUtils, animations: animationUtils, plugins: pluginUtils, presetPlugins, topologicalSort, validateConfig, createInstanceI18n, loadRemote, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme, DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS, }; if (typeof window !== 'undefined') { window.MeEditor = api; } exports.DEFAULTS = DEFAULTS; exports.DEFAULT_TOOLBAR = DEFAULT_TOOLBAR; exports.EDIT_MODES = EDIT_MODES; exports.Editor = MarkdownEditor; exports.ICONS = ICONS; exports.MarkdownEditor = MarkdownEditor; exports.MeEditor = api; exports.THEMES = THEMES; exports.TOOLBAR_ACTIONS = TOOLBAR_ACTIONS; exports.VERSION = VERSION; exports.adoptFromParent = adoptFromParent; exports.animationUtils = animationUtils; exports.api = api; exports.clearRenderCache = clearRenderCache; exports.create = create; exports.createInstanceI18n = createInstanceI18n; exports.createInstanceTheme = createInstanceTheme; exports.default = api; exports.destroy = destroy; exports.exportCSSVars = exportCSSVars; exports.followExternalTheme = followExternalTheme; exports.getCSSVariable = getCSSVariable; exports.getStatus = getStatus; exports.i18nUtils = i18nUtils; exports.loadRemote = loadRemote; exports.meEditor = api; exports.off = off; exports.on = on; exports.parseMarkdown = parseMarkdown; exports.parseTokens = parseTokens; exports.pluginUtils = pluginUtils; exports.presetPlugins = presetPlugins; exports.registerBlockHandler = registerBlockHandler; exports.renderTokens = renderTokens; exports.safeUrl = safeUrl; exports.setLocale = setLocale; exports.setTheme = setTheme; exports.slugify = slugify; exports.themeUtils = themeUtils; exports.topologicalSort = topologicalSort; exports.use = use; exports.validateConfig = validateConfig; //# sourceMappingURL=metona-editor.cjs.js.map