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

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

${cachedRenderInline(tok.text, env)}

`; case 'hr': return '
'; case 'quote': return `
${parseMarkdown(tok.content, env)}
`; case 'code': return renderCode(tok.content, tok.lang, env, tok.attrs); case 'ul': return ``; 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-${escapeAttr$1(lang)}"` : ''; const titleHtml = attrs?.title ? `
    ${escapeAttr$1(attrs.title)}
    ` : ''; if (env.highlight && typeof env.highlight === 'function' && lang) { try { const highlighted = env.highlight(code, lang); if (typeof highlighted === 'string') return `${titleHtml}
    ${highlighted}
    `; } catch (e) { console.error('MeEditor highlight error:', e); } } return `${titleHtml}
    ${escapeHTML(code)}
    `; }; const renderTable = (tok, env) => { // Split respecting backslash-escaped pipes const splitRow = (r) => { const cells = []; let current = ''; let escaped = false; for (let i = 0; i < r.length; i++) { const ch = r[i]; if (escaped) { current += ch; escaped = false; } else if (ch === '\\') { escaped = true; } else if (ch === '|') { cells.push(current.trim()); current = ''; } else { current += ch; } } cells.push(current.trim()); // Remove leading/trailing empty cells from outer pipes if (cells.length > 0 && cells[0] === '') cells.shift(); if (cells.length > 0 && cells[cells.length - 1] === '') cells.pop(); return cells; }; const headers = splitRow(tok.header); const align = tok.align || []; const as = (i) => align[i] && align[i] !== 'left' ? ` style="text-align:${align[i]}"` : ''; let h = '
    '; h += headers.map((hd, i) => `${cachedRenderInline(hd, env)}`).join(''); h += ''; h += tok.rows.map((r) => { const cells = splitRow(r); return `${cells.map((c, i) => `${cachedRenderInline(c, env)}`).join('')}`; }).join(''); return h + '
    '; }; // ============ Inline rendering ============ const renderInline = (text, env) => { if (!text) return ''; const codes = []; let s = extractInlineCodes(text, codes); const protectedItems = []; s = s.replace(/&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#\d{1,7}|#x[0-9a-fA-F]{1,6});/g, (m) => { protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`; }); s = s.replace(//g, (m) => { protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`; }); // 白名单裸标签透传:仅 `` / ``(无任何属性),供 Ctrl+U 下划线命令使用。 // 带属性的 `` 不匹配,仍会被 escapeHTML 转义,不引入注入面。 s = s.replace(/<\/?u\s*>/gi, (m) => { protectedItems.push(m); return `\u0005${protectedItems.length - 1}\u0005`; }); s = escapeHTML(s); s = s.replace(/\u0005(\d+)\u0005/g, (_m, idx) => protectedItems[+idx] || _m); s = scanInline(s, codes, env); s = s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => { const code = codes[+idx]; if (!code) return _m; return `${escapeHTML(code.content)}`; }); s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m); s = s.replace(/ \n/g, '
    \n'); s = s.replace(/\n/g, '
    '); return s; }; const extractInlineCodes = (text, codes) => { let result = ''; let i = 0; while (i < text.length) { if (text[i] === '`') { let startLen = 1; while (i + startLen < text.length && text[i + startLen] === '`') startLen++; let j = i + startLen; let found = false; while (j < text.length) { const nextTick = text.indexOf('`', j); if (nextTick === -1) break; let endLen = 1; while (nextTick + endLen < text.length && text[nextTick + endLen] === '`') endLen++; if (endLen === startLen) { const content = text.slice(i + startLen, nextTick); codes.push({ content, len: startLen }); result += `\u0000${codes.length - 1}\u0000`; i = nextTick + endLen; found = true; break; } j = nextTick + endLen; } if (!found) { result += text.slice(i, i + startLen); i += startLen; } } else { result += text[i]; i++; } } return result; }; const INLINE_RE = new RegExp([ '(\\*\\*\\*[^*\\n][^*\\n]*?\\*\\*\\*)', '|(___[^_\\n][^_\\n]*?___)', '|(!\\[[^\\]]*\\]\\([^)]+\\))', '|(? { const placeholderMap = new Map(); s = s.replace(/\u0000\d+\u0000/g, (m) => { const key = `\u0003${placeholderMap.size}\u0003`; placeholderMap.set(key, m); return key; }); s = s.replace(INLINE_RE, (fullMatch) => { const match = fullMatch; if (match.startsWith('***')) return `${match.slice(3, -3)}`; if (match.startsWith('___')) return `${match.slice(3, -3)}`; if (match.startsWith('![') && match.includes('](')) { const m = match.match(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+['"](.+?)['"])?\s*\)/); if (!m) return match; const u = safeUrl(m[2]); if (!u) return escapeHTML(match); return `${attrSafe(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); return `${attrSafe(m[1])}`; } catch (_) { return `${attrSafe(m[1])}`; } } return `${attrSafe(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 linkText = renderInline(m[1], env); return `${linkText}`; } if (match.startsWith('[') && match.includes('][')) { const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/); if (!m) return match; const refKey = (m[2] || m[1]).toLowerCase(); if (env.refs && env.refs[refKey]) { try { const ref = JSON.parse(env.refs[refKey]); const u = safeUrl(ref.url); if (!u) return escapeHTML(match); return `${m[1]}`; } catch (_) { return escapeHTML(match); } } return escapeHTML(match); } if (match.startsWith('<http')) { const url = match.slice(4, -4); const u = safeUrl(url); return `${url}`; } if (match.startsWith('**')) return `${match.slice(2, -2)}`; if (match.startsWith('__')) return `${match.slice(2, -2)}`; if (match.startsWith('~~')) return `${match.slice(2, -2)}`; if (match.startsWith('==')) return `${match.slice(2, -2)}`; if (match.startsWith('*') && !match.startsWith('**') && !match.startsWith('***')) return `${match.slice(1, -1)}`; if (match.startsWith('_') && !match.startsWith('__') && !match.startsWith('___')) return `${match.slice(1, -1)}`; if (match.startsWith('^') && !match.startsWith('^^')) return `${match.slice(1, -1)}`; if (match.startsWith('~') && !match.startsWith('~~')) return `${match.slice(1, -1)}`; if (match.startsWith('$') && !match.startsWith('$$')) return `${escapeHTML(match.slice(1, -1))}`; if (/^\[\^/.test(match)) { const fnId = match.slice(2, -1); const safeId = attrSafe(fnId); return `[${safeId}]`; } if (match.startsWith('\\') && match.length === 2) { const escaped = match[1]; if (/[!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~]/.test(escaped)) return escaped; return match; } return match; }); s = s.replace(/\u0003(\d+)\u0003/g, (m) => placeholderMap.get(m) || m); return s; }; /** * MetonaEditor Plugins — plugin system v2 * @module plugins * @version 0.2.0 */ // ============ Plugin state keys (Symbols avoid property-name collisions) ============ const K_AUTOSAVE = Symbol('me-plugin:autoSave'); const K_SEARCH = Symbol('me-plugin:searchReplace'); const K_IMAGE_PASTE = Symbol('me-plugin:imagePaste'); const K_SHORTCUT = Symbol('me-plugin:shortcutHelp'); const K_FILESYSTEM = Symbol('me-plugin:fileSystem'); const pluginState = (editor, key) => editor[key]; const setPluginState = (editor, key, state) => { editor[key] = state; }; const deletePluginState = (editor, key) => { delete editor[key]; }; // ============ Plugin Manager ============ class PluginManager { constructor() { this.plugins = new Map(); } register(name, plugin) { if (this.plugins.has(name)) { console.warn(`MeEditor: plugin "${name}" already registered`); return this; } if (!plugin || typeof plugin !== 'object' || (!plugin.name && !name)) { console.error(`MeEditor: invalid plugin "${name}"`); return this; } this.plugins.set(name, { version: plugin.version || '0.0.0', description: plugin.description || '', depends: plugin.depends || [], priority: plugin.priority || 0, ...plugin, name, enabled: true, }); return this; } unregister(name) { this.plugins.delete(name); return this; } get(name) { return this.plugins.get(name) || null; } has(name) { return this.plugins.has(name); } getAll() { return Array.from(this.plugins.values()); } getNames() { return Array.from(this.plugins.keys()); } enable(name) { const p = this.plugins.get(name); if (p) p.enabled = true; return this; } disable(name) { const p = this.plugins.get(name); if (p) p.enabled = false; return this; } isEnabled(name) { const p = this.plugins.get(name); return p ? p.enabled : false; } destroy() { this.plugins.clear(); } } const defaultPluginManager = new PluginManager(); // ============ Topological sort ============ const topologicalSort = (plugins) => { const map = new Map(); plugins.forEach((p) => map.set(p.name, p)); const inDegree = new Map(); const adj = new Map(); plugins.forEach((p) => { inDegree.set(p.name, 0); adj.set(p.name, []); }); plugins.forEach((p) => { (p.depends || []).forEach((dep) => { if (map.has(dep)) { adj.get(dep).push(p.name); inDegree.set(p.name, (inDegree.get(p.name) || 0) + 1); } else { console.warn(`MeEditor: plugin "${p.name}" depends on unknown "${dep}"`); } }); }); const queue = []; inDegree.forEach((deg, name) => { if (deg === 0) queue.push(name); }); const sorted = []; while (queue.length) { queue.sort((a, b) => ((map.get(b).priority || 0) - (map.get(a).priority || 0))); const name = queue.shift(); sorted.push(map.get(name)); (adj.get(name) || []).forEach((n) => { inDegree.set(n, inDegree.get(n) - 1); if (inDegree.get(n) === 0) queue.push(n); }); } if (sorted.length !== plugins.length) { console.warn('MeEditor: circular dependency detected, falling back to original order'); return plugins; } return sorted; }; // ============ Config validation ============ const validateConfig = (schema = {}, config = {}) => { const errors = []; const patched = { ...config }; for (const [key, rule] of Object.entries(schema)) { const val = config[key]; if (rule.required && (val === undefined || val === null)) { errors.push(`"${key}" is required`); continue; } if (val === undefined && rule.default !== undefined) { patched[key] = rule.default; continue; } if (val !== undefined && rule.type) { const actual = Array.isArray(val) ? 'array' : typeof val; if (actual !== rule.type) errors.push(`"${key}" expected ${rule.type}, got ${actual}`); } if (val !== undefined && rule.enum && !rule.enum.includes(val)) errors.push(`"${key}" must be one of [${rule.enum.join(', ')}]`); if (val !== undefined && typeof rule.validator === 'function') { try { const r = rule.validator(val); if (r !== true) errors.push(`"${key}": ${r}`); } catch (e) { errors.push(`"${key}": ${e.message}`); } } } return { valid: errors.length === 0, errors, patched }; }; // ============ Preset plugins ============ const escapeAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); const autoSavePlugin = { name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100, install(editor, options) { if (!editor || typeof editor.getValue !== 'function') return; const opts = options || this; const key = opts.key || ('me-draft-' + (editor.id || '')); const delay = opts.delay || 1000; const state = { _timer: null }; const save = () => { if (state._timer) { clearTimeout(state._timer); state._timer = null; } try { localStorage.setItem(key, editor.getValue()); if (typeof editor._emit === 'function') editor._emit('autosave', { key, value: editor.getValue() }); } catch (e) { console.warn('MeEditor autoSave:', e); } }; const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); }; const _onBlur = save; const _onSave = save; if (typeof editor.on === 'function') { editor.on('change', _onInput); editor.on('blur', _onBlur); editor.on('save', _onSave); } editor.restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null && typeof editor.setValue === 'function') editor.setValue(v); return v; } catch (_) { return null; } }; editor.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) { } return editor; }; editor.getDraftKey = () => key; setPluginState(editor, K_AUTOSAVE, { state, _onInput, _onBlur, _onSave, save }); }, destroy(editor) { const cleanup = pluginState(editor, K_AUTOSAVE); if (cleanup) { if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; } if (editor && typeof editor.off === 'function') { if (cleanup._onInput) editor.off('change', cleanup._onInput); if (cleanup._onBlur) editor.off('blur', cleanup._onBlur); if (cleanup._onSave) editor.off('save', cleanup._onSave); } deletePluginState(editor, K_AUTOSAVE); } }, }; const exportToolPlugin = { name: 'exportTool', version: '0.1.0', description: 'Export Markdown/HTML', priority: 50, install(editor) { if (!editor || typeof editor.getValue !== 'function') return; const download = (filename, content, mime) => { if (typeof document === 'undefined') return; const blob = new Blob([content], { type: mime + ';charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 0); }; const stamp = () => { const d = new Date(); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; }; const e = editor; const buildHTML = (opts = {}) => { const title = opts.title || 'Document'; const css = opts.css || ''; const body = typeof editor.getHTML === 'function' ? editor.getHTML() : ''; // Build minimal embedded CSS for the exported HTML const embedCSS = opts.embedCSS !== false ? `` : ''; return `\n\n\n\n\n${title}\n${css ? `` : ''}${embedCSS}\n\n\n${body}\n\n`; }; e.exportMarkdown = (filename) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; }; e.exportHTML = (filename, opts = {}) => { download(filename || `metona-${stamp()}.html`, buildHTML(opts), 'text/html'); return editor; }; e.exportPDF = (opts = {}) => { if (typeof document === 'undefined' || !editor.el) return editor; const iframe = document.createElement('iframe'); iframe.style.position = 'fixed'; iframe.style.right = '0'; iframe.style.bottom = '0'; iframe.style.width = '0'; iframe.style.height = '0'; iframe.style.border = '0'; document.body.appendChild(iframe); const doc = iframe.contentDocument; if (!doc) { iframe.remove(); return editor; } doc.open(); doc.write(buildHTML(opts)); doc.close(); setTimeout(() => { try { iframe.contentWindow?.print(); } catch (_) { } setTimeout(() => { if (iframe.parentNode) iframe.parentNode.removeChild(iframe); }, 1000); }, 50); return editor; }; }, destroy(editor) { if (editor) { delete editor.exportMarkdown; delete editor.exportHTML; delete editor.exportPDF; } }, }; const searchReplacePlugin = { name: 'searchReplace', version: '0.2.1', description: 'Search & Replace (Ctrl+F/H)', priority: 80, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; // Inject style once globally if (!document.getElementById('me-search-style')) { const style = document.createElement('style'); style.id = 'me-search-style'; style.textContent = `.me-search{position:absolute;top:10px;right:16px;z-index:20;display:flex;flex-direction:column;gap:8px;padding:12px;background:var(--md-bg,#1c2029);border:1px solid var(--md-border,rgba(255,255,255,0.12));border-radius:10px;box-shadow:0 12px 40px -8px rgba(0,0,0,0.4),0 0 0 1px rgba(255,255,255,0.05);font-size:13px;min-width:300px;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);animation:meSearchIn .18s ease-out}@keyframes meSearchIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}.me-search-row{display:flex;gap:6px;align-items:center}.me-search-row+.me-search-row{margin-top:2px}.me-search input{flex:1;min-width:0;padding:7px 10px;border:1px solid var(--md-border,rgba(255,255,255,0.12));border-radius:6px;background:var(--md-textarea-bg,rgba(255,255,255,0.04));color:var(--md-text,#e6e8eb);font-size:13px;outline:none;transition:border-color .15s,box-shadow .15s}.me-search input::placeholder{color:var(--md-muted,#6b7280)}.me-search input:focus{border-color:var(--md-accent,#3b82f6);box-shadow:0 0 0 3px rgba(59,130,246,0.15)}.me-search .me-search-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:1px solid rgba(255,255,255,0.08);border-radius:5px;background:rgba(255,255,255,0.04);color:var(--md-muted,#9ca3af);cursor:pointer;transition:all .12s;flex-shrink:0}.me-search .me-search-btn:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-btn:active{transform:scale(.92)}.me-search .me-search-btn.me-active{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-btn svg{width:14px;height:14px}.me-search .me-search-count{color:var(--md-muted,#9ca3af);font-size:11.5px;min-width:28px;text-align:center;font-variant-numeric:tabular-nums}.me-search .me-search-close{margin-left:2px;font-size:16px;line-height:1}.me-search .me-search-close:hover{background:#ef4444;border-color:#ef4444}.me-search .me-search-replace-one,.me-search .me-search-replace-all{width:auto;padding:5px 10px;font-size:12px;font-weight:500;letter-spacing:.01em}.me-search .me-search-replace-all{background:rgba(59,130,246,0.12);border-color:rgba(59,130,246,0.3);color:var(--md-accent,#60a5fa)}.me-search .me-search-replace-all:hover{background:var(--md-accent,#3b82f6);color:#fff}`; document.head.appendChild(style); } const state = { _panel: null, _regexMode: false, _matchCase: true, _wholeWord: false, _cleanup: null, }; const WORD_CLASS = '[\\w\\u4e00-\\u9fff]'; const escapeRegExpText = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const buildPattern = (q) => { if (!q) return null; let src = state._regexMode ? q : escapeRegExpText(q); if (state._wholeWord) src = `(? { const isWord = (c) => !!c && /[\w\u4e00-\u9fff]/.test(c); return !isWord(text[idx - 1]) && !isWord(text[idx + len]); }; const _updateReplaceVisible = () => { if (!state._panel) return; const show = state._panel.dataset.replace === '1'; const row = state._panel.querySelector('.me-search-replace-row'); if (row) row.style.display = show ? 'flex' : 'none'; }; const _close = () => { if (state._cleanup) { state._cleanup(); state._cleanup = null; } state._panel = null; if (editor && editor.textarea) editor.textarea.focus(); }; const _open = (showReplace) => { if (state._panel) { state._panel.dataset.replace = showReplace ? '1' : '0'; _updateReplaceVisible(); const inp = state._panel.querySelector('.me-search-find'); if (inp) { inp.focus(); inp.select(); } return; } const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd); const panel = document.createElement('div'); panel.className = 'me-search'; panel.dataset.replace = showReplace ? '1' : '0'; panel.innerHTML = `
    `; editor.el.appendChild(panel); state._panel = panel; _updateReplaceVisible(); state._regexMode = false; state._matchCase = true; state._wholeWord = false; const fi = panel.querySelector('.me-search-find'); const ri = panel.querySelector('.me-search-replace'); const ce = panel.querySelector('.me-search-count'); const regexBtn = panel.querySelector('.me-search-regex'); const caseBtn = panel.querySelector('.me-search-case'); const wordBtn = panel.querySelector('.me-search-word'); regexBtn.addEventListener('click', () => { state._regexMode = !state._regexMode; regexBtn.classList.toggle('me-active', state._regexMode); lastIdxs = findAll(); }); caseBtn.addEventListener('click', () => { state._matchCase = !state._matchCase; caseBtn.classList.toggle('me-active', state._matchCase); lastIdxs = findAll(); }); wordBtn.addEventListener('click', () => { state._wholeWord = !state._wholeWord; wordBtn.classList.toggle('me-active', state._wholeWord); lastIdxs = findAll(); }); const findAll = () => { const q = fi.value; if (!q) { ce.textContent = ''; return []; } const idxs = []; const re = buildPattern(q); if (!re) { ce.textContent = 'err'; return []; } let m; while ((m = re.exec(editor.textarea.value)) !== null) { idxs.push(m.index); if (m[0].length === 0) re.lastIndex++; } ce.textContent = idxs.length ? `${idxs.length}` : '0'; return idxs; }; let lastIdxs = []; const selectAt = (idx, len) => { editor.textarea.focus(); editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length)); }; const matchLenAt = (idx) => { const text = editor.textarea.value; const q = fi.value; if (!q) return 0; if (!state._regexMode) { if (state._matchCase && text.slice(idx, idx + q.length) !== q) return 0; if (!state._matchCase && text.slice(idx, idx + q.length).toLowerCase() !== q.toLowerCase()) return 0; if (state._wholeWord && !hasWordBoundary(text, idx, q.length)) return 0; return q.length; } const re = buildPattern(q); if (!re) return 0; re.lastIndex = idx; const m = re.exec(text); return m && m.index === idx ? m[0].length : 0; }; const findNext = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionEnd; let next = lastIdxs.find((i) => i >= cur); if (next == null) next = lastIdxs[0]; selectAt(next, matchLenAt(next) || fi.value.length); }; const findPrev = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionStart; let prev = -1; for (let i = lastIdxs.length - 1; i >= 0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } } if (prev === -1) prev = lastIdxs[lastIdxs.length - 1]; selectAt(prev, matchLenAt(prev) || fi.value.length); }; const replaceOne = () => { const q = fi.value, r = ri.value; if (!q) return; const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd; const matchText = ta.value.substring(s, e); let matched = false; if (state._regexMode) { const re = buildPattern(q); if (re) { re.lastIndex = s; const m = re.exec(ta.value); matched = !!(m && m.index === s); } } else { const sameCase = state._matchCase ? matchText === q : matchText.toLowerCase() === q.toLowerCase(); matched = sameCase && (!state._wholeWord || hasWordBoundary(ta.value, s, q.length)); } if (matched) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); } editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory(); if (typeof editor._render === 'function') editor._render(); if (typeof editor._updateWordCount === 'function') editor._updateWordCount(); if (typeof editor._emit === 'function') editor._emit('change', editor._value); findNext(); }; const replaceAll = () => { const q = fi.value, r = ri.value; if (!q) return; const ta = editor.textarea; const re = buildPattern(q); if (!re) return; ta.value = ta.value.replace(re, () => r); ta.setSelectionRange(0, 0); editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory(); if (typeof editor._render === 'function') editor._render(); if (typeof editor._updateWordCount === 'function') editor._updateWordCount(); if (typeof editor._emit === 'function') editor._emit('change', editor._value); findAll(); }; fi.addEventListener('input', () => { lastIdxs = findAll(); }); fi.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); } if (e.key === 'Escape') { e.preventDefault(); _close(); } }); ri.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); replaceOne(); } if (e.key === 'Escape') { e.preventDefault(); _close(); } }); panel.querySelector('.me-search-next').addEventListener('click', findNext); panel.querySelector('.me-search-prev').addEventListener('click', findPrev); panel.querySelector('.me-search-close').addEventListener('click', () => _close()); panel.querySelector('.me-search-replace-one').addEventListener('click', replaceOne); panel.querySelector('.me-search-replace-all').addEventListener('click', replaceAll); fi.focus(); fi.select(); state._cleanup = () => { if (panel.parentNode) panel.parentNode.removeChild(panel); }; }; const _onKeydown = (e) => { const mod = e.ctrlKey || e.metaKey; if (!mod) return; const k = e.key.toLowerCase(); if (k === 'f') { e.preventDefault(); _open(false); } else if (k === 'h') { e.preventDefault(); _open(true); } else if (k === 'escape' && state._panel) { _close(); } }; editor.textarea.addEventListener('keydown', _onKeydown); // Expose for tests via Symbol-keyed state (no public-property pollution) setPluginState(editor, K_SEARCH, { state, _open, _close, _onKeydown }); }, destroy(editor) { const exposed = pluginState(editor, K_SEARCH); if (exposed) { const state = exposed.state; if (state) { if (state._cleanup) { state._cleanup(); state._cleanup = null; } state._panel = null; } if (exposed._onKeydown && editor && editor.textarea) { editor.textarea.removeEventListener('keydown', exposed._onKeydown); } deletePluginState(editor, K_SEARCH); } }, }; const imagePastePlugin = { name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60, install(editor, options = {}) { if (!editor || !editor.textarea || typeof document === 'undefined') return; const maxSizeKB = options.maxSizeKB || 500; const _onPaste = (e) => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type?.startsWith('image/')) { const file = item.getAsFile(); if (!file) continue; const sizeKB = file.size / 1024; if (maxSizeKB > 0 && sizeKB > maxSizeKB) { e.preventDefault(); if (typeof editor.toast === 'function') editor.toast(t('imageTooLarge', { size: sizeKB.toFixed(0), max: maxSizeKB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' }); break; } e.preventDefault(); const reader = new FileReader(); reader.onload = () => { if (typeof editor.insert === 'function') editor.insert(`![image-${Date.now().toString(36)}.png](${reader.result})\n`); }; reader.readAsDataURL(file); break; } } }; editor.textarea.addEventListener('paste', _onPaste); setPluginState(editor, K_IMAGE_PASTE, _onPaste); }, destroy(editor) { const _onPaste = pluginState(editor, K_IMAGE_PASTE); if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste); deletePluginState(editor, K_IMAGE_PASTE); }, }; const shortcutHelpPlugin = { name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; if (!document.getElementById('me-shortcut-style')) { const s = document.createElement('style'); s.id = 'me-shortcut-style'; s.textContent = `.me-shortcut-overlay{position:fixed;top:0;left:0;right:0;bottom:0;z-index:50;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center}.me-shortcut-panel{background:var(--md-bg,#fff);border-radius:12px;padding:24px;max-width:560px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,0.3)}.me-shortcut-panel h3{font-size:16px;margin:0 0 16px;color:var(--md-text)}.me-shortcut-panel table{width:100%;border-collapse:collapse;font-size:13px}.me-shortcut-panel td{padding:6px 10px;border-bottom:1px solid var(--md-border)}.me-shortcut-panel td:first-child{font-family:var(--md-mono);font-size:12px;color:var(--md-accent);white-space:nowrap;width:40%}.me-shortcut-panel .me-shortcut-close{position:absolute;top:16px;right:20px;background:none;border:none;font-size:20px;cursor:pointer;color:var(--md-muted)}`; document.head.appendChild(s); } let _panel = null; const _close = () => { if (_panel) { _panel.remove(); _panel = null; } }; const _open = () => { if (_panel) { _close(); return; } // i18n-aware shortcut labels const builtin = [ ['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'], ['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'], ['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'], ['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'], ['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'], ['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'], ['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'], ['?', t('shortcuts') || 'Shortcuts'], ]; let rows = ''; builtin.forEach(([c, d]) => { rows += `${c}${d}`; }); const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay'; overlay.innerHTML = `

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

    ${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); setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown }); }, destroy(editor) { const state = pluginState(editor, K_SHORTCUT); if (state) { if (state._panel) { state._panel.remove(); } } const _onKeydown = state?._onKeydown; if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown); deletePluginState(editor, K_SHORTCUT); }, }; const fileSystemPlugin = { name: 'fileSystem', version: '0.2.0', description: 'File System Access API', priority: 90, install(editor) { if (!editor || typeof editor.getValue !== 'function') return; const hasAPI = typeof window !== 'undefined' && typeof window.showOpenFilePicker === 'function'; let _fileHandle = null; editor.openFile = async (opts = {}) => { if (!hasAPI) { if (typeof editor.toast === 'function') editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; } try { const [handle] = await window.showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md', '.txt', '.markdown'] } }], ...opts }); _fileHandle = handle; const file = await handle.getFile(); const content = await file.text(); if (typeof editor.setValue === 'function') editor.setValue(content); editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle }; } catch (e) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; } }; editor.saveFile = async (opts = {}) => { let handle = _fileHandle; if (!handle || opts.saveAs) { if (!hasAPI) { if (typeof editor.toast === 'function') editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; } try { handle = await window.showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' }); _fileHandle = handle; } catch (e) { if (e.name !== 'AbortError') console.error('Save error:', e); return false; } } try { const w = await handle.createWritable(); await w.write(editor.getValue()); await w.close(); editor._emit?.('fileSaved', { handle }); return true; } catch (e) { _fileHandle = null; if (!opts.saveAs) return editor.saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; } }; editor.saveFileAs = (name) => editor.saveFile({ saveAs: true, name }); editor.getFileHandle = () => _fileHandle; setPluginState(editor, K_FILESYSTEM, { getHandle: () => _fileHandle }); }, destroy(editor) { if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; } deletePluginState(editor, K_FILESYSTEM); }, }; // ============ Preset plugins table ============ const presetPlugins = { autoSave: autoSavePlugin, exportTool: exportToolPlugin, searchReplace: searchReplacePlugin, imagePaste: imagePastePlugin, shortcutHelp: shortcutHelpPlugin, fileSystem: fileSystemPlugin, }; // ============ Plugin utils ============ const pluginUtils = { createManager: () => new PluginManager(), manager: defaultPluginManager, register: (name, plugin) => defaultPluginManager.register(name, plugin), unregister: (name) => defaultPluginManager.unregister(name), get: (name) => defaultPluginManager.get(name), has: (name) => defaultPluginManager.has(name), getAll: () => defaultPluginManager.getAll(), getNames: () => defaultPluginManager.getNames(), enable: (name) => defaultPluginManager.enable(name), disable: (name) => defaultPluginManager.disable(name), isEnabled: (name) => defaultPluginManager.isEnabled(name), getPreset: (name) => presetPlugins[name] ? { ...presetPlugins[name] } : null, getAllPresets: () => Object.keys(presetPlugins).reduce((acc, k) => { acc[k] = { ...presetPlugins[k] }; return acc; }, {}), createPlugin: (config = {}) => { const result = { name: config.name || 'custom', version: config.version || '0.0.0', description: config.description || '', depends: config.depends || [], priority: config.priority || 0, install: () => { }, destroy: () => { }, ...config }; if (typeof result.install !== 'function') result.install = () => { }; if (typeof result.destroy !== 'function') result.destroy = () => { }; return result; }, validatePlugin: (plugin) => { const errors = []; if (!plugin || typeof plugin !== 'object') errors.push('plugin must be an object'); if (plugin && !plugin.name) errors.push('plugin must have a name'); if (plugin && plugin.install && typeof plugin.install !== 'function') errors.push('install must be a function'); return { valid: errors.length === 0, errors }; }, topologicalSort, validateConfig, }; /** * MetonaEditor Themes — theme system * @module themes * @version 0.2.0 */ // ============ State ============ let globalCurrentTheme = 'auto'; let globalThemeListeners = new Set(); let _sysWatchUnsub = null; // ============ System theme ============ const getSystemTheme = () => { if (typeof window === 'undefined') return 'light'; return prefersDark() ? 'dark' : 'light'; }; const _watchSystem = (callback) => { if (typeof window === 'undefined' || !window.matchMedia) return () => { }; const mql = window.matchMedia('(prefers-color-scheme: dark)'); const handler = (e) => callback(e.matches ? 'dark' : 'light'); mql.addEventListener('change', handler); return () => mql.removeEventListener('change', handler); }; const watchSystemTheme = () => { if (typeof window === 'undefined' || !window.matchMedia) return; if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; } _sysWatchUnsub = _watchSystem((sysTheme) => { if (globalCurrentTheme === 'auto') applyTheme('auto'); }); }; const unwatchSystemTheme = () => { if (_sysWatchUnsub) { _sysWatchUnsub(); _sysWatchUnsub = null; } }; // ============ Theme resolution ============ const resolveTheme = (theme) => { if (!theme || theme === 'auto') return getSystemTheme(); return theme; }; const getThemeConfig = (theme) => { const resolved = resolveTheme(theme); const config = THEMES[resolved]; return (config && typeof config === 'object' ? config : THEMES.light); }; // ============ CSS variables ============ const setThemeVariables = (config, target) => { if (typeof document === 'undefined' || !config || typeof config !== 'object') return; const root = target || document.documentElement; const vars = { '--md-bg': config.bg, '--md-text': config.text, '--md-border': config.border, '--md-shadow': config.shadow, '--md-hover-shadow': config.hoverShadow, '--md-toolbar-bg': config.toolbarBg || config.bg, '--md-textarea-bg': config.textareaBg || config.bg, '--md-preview-bg': config.previewBg || config.bg, '--md-code-bg': config.codeBg || 'rgba(127,127,127,0.1)', '--md-code-text': config.codeText || config.text, '--md-accent': config.accent || '#3b82f6', '--md-muted': config.muted || '#9ca3af', }; for (const [k, v] of Object.entries(vars)) { if (v !== undefined && v !== null) root.style.setProperty(k, String(v)); } if (config.progressBg) root.style.setProperty('--md-progress-bg', config.progressBg); if (config.closeHoverBg) root.style.setProperty('--md-close-hover-bg', config.closeHoverBg); }; const exportCSSVars = (el) => { const root = el || document.documentElement; if (typeof document === 'undefined') return {}; const style = getComputedStyle(root); const varNames = ['--md-bg', '--md-text', '--md-border', '--md-shadow', '--md-hover-shadow', '--md-toolbar-bg', '--md-textarea-bg', '--md-preview-bg', '--md-code-bg', '--md-code-text', '--md-accent', '--md-muted']; const result = {}; varNames.forEach((name) => { const val = style.getPropertyValue(name).trim(); if (val) result[name] = val; }); return result; }; const getCSSVariable = (name, el) => { const root = el || document.documentElement; if (typeof document === 'undefined') return ''; const fullName = name.startsWith('--') ? name : `--md-${name}`; return getComputedStyle(root).getPropertyValue(fullName).trim(); }; // ============ Apply theme ============ const applyTheme = (theme) => { globalCurrentTheme = theme; const resolved = resolveTheme(theme); if (typeof document !== 'undefined') { document.documentElement.setAttribute('data-md-theme', resolved); document.documentElement.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm'); document.documentElement.classList.add(`md-theme-${resolved}`); setThemeVariables(getThemeConfig(theme)); } notifyGlobalListeners(theme, resolved); }; const applyThemeToElement = (theme, target) => { if (!target || typeof document === 'undefined') return; const resolved = resolveTheme(theme); target.setAttribute('data-md-theme', resolved); target.classList.remove('md-theme-light', 'md-theme-dark', 'md-theme-auto', 'md-theme-warm'); if (resolved !== 'auto') target.classList.add(`md-theme-${resolved}`); setThemeVariables(getThemeConfig(theme), target); }; const switchTheme = (theme) => { applyTheme(theme); saveTheme(theme); }; const toggleTheme = () => { const r = getResolvedTheme(); switchTheme(r === 'dark' ? 'light' : 'dark'); }; const resetToAuto = () => { switchTheme('auto'); }; const getCurrentTheme = () => globalCurrentTheme; const getResolvedTheme = () => resolveTheme(globalCurrentTheme); // ============ Persistence ============ const saveTheme = (theme) => { if (typeof localStorage !== 'undefined') { try { localStorage.setItem('metona-editor-theme', theme); } catch (_) { } } }; const loadTheme = () => { if (typeof localStorage !== 'undefined') { try { return localStorage.getItem('metona-editor-theme') || 'auto'; } catch (_) { } } return 'auto'; }; // ============ Listeners ============ const notifyGlobalListeners = (theme, resolved) => { globalThemeListeners.forEach((fn) => { try { fn(theme, resolved); } catch (e) { console.error('Theme listener error:', e); } }); }; const addThemeListener = (fn) => { globalThemeListeners.add(fn); return () => { globalThemeListeners.delete(fn); }; }; const removeThemeListener = (fn) => { globalThemeListeners.delete(fn); }; const clearThemeListeners = () => { globalThemeListeners.clear(); }; // ============ System watch ============ const startSystemWatch = () => { watchSystemTheme(); }; // ============ Init ============ const initTheme = () => { applyTheme(loadTheme()); startSystemWatch(); }; // ============ External follow ============ const followExternalTheme = (options, onThemeChange) => { if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return () => { }; const { element = document.documentElement, attr = 'data-theme', classMap = null, callback = null } = options; const detect = () => { if (typeof callback === 'function') return callback(element); const attrVal = element.getAttribute(attr); if (attrVal) return attrVal; if (classMap) { for (const [cls, theme] of Object.entries(classMap)) { if (element.classList.contains(cls)) return theme; } } const mdTheme = element.getAttribute('data-md-theme'); if (mdTheme) return mdTheme; return null; }; let lastTheme = detect(); if (lastTheme) onThemeChange(lastTheme); const observer = new MutationObserver(() => { const current = detect(); if (current && current !== lastTheme) { lastTheme = current; onThemeChange(current); } }); observer.observe(element, { attributes: true, attributeFilter: [attr, 'class', 'data-md-theme'] }); return () => observer.disconnect(); }; const adoptFromParent = (container, onThemeDetected) => { if (typeof document === 'undefined' || !container) return () => { }; const detect = () => { let el = container.parentElement; while (el) { const t = el.getAttribute('data-md-theme'); if (t) return t; if (el.classList.contains('theme-dark') || el.classList.contains('dark')) return 'dark'; if (el.classList.contains('theme-light') || el.classList.contains('light')) return 'light'; el = el.parentElement; } if (container.parentElement) { const bg = getComputedStyle(container.parentElement).getPropertyValue('--md-bg').trim() || getComputedStyle(container.parentElement).backgroundColor; if (bg) { const rgb = bg.match(/\d+/g); if (rgb && rgb.length >= 3) { const brightness = (parseInt(rgb[0]) * 299 + parseInt(rgb[1]) * 587 + parseInt(rgb[2]) * 114) / 1000; return brightness < 128 ? 'dark' : 'light'; } } } return null; }; const detected = detect(); if (detected) onThemeDetected(detected); const observer = new MutationObserver(() => { const d = detect(); if (d) onThemeDetected(d); }); let target = container.parentElement; while (target) { observer.observe(target, { attributes: true, attributeFilter: ['class', 'data-md-theme', 'data-theme'] }); target = target.parentElement; if (target === document.documentElement) break; } return () => observer.disconnect(); }; const watch = (source, onChange) => { if (typeof source === 'function') { const tick = () => { try { const t = source(); if (t) onChange(t); } catch (_) { } }; tick(); const id = setInterval(tick, 500); return () => clearInterval(id); } if (typeof source === 'string' && typeof document !== 'undefined') { const el = document.querySelector(source); if (!el) return () => { }; return followExternalTheme({ element: el, attr: 'data-theme' }, onChange); } return () => { }; }; // ============ Custom theme registration ============ const registerTheme = (name, config = {}) => { const baseName = config.extends || 'light'; const base = (THEMES[baseName] || THEMES.light); THEMES[name] = { bg: config.bg || base.bg, text: config.text || base.text, border: config.border || base.border, shadow: config.shadow || base.shadow, hoverShadow: config.hoverShadow || base.hoverShadow, toolbarBg: config.toolbarBg || base.toolbarBg || base.bg, textareaBg: config.textareaBg || base.textareaBg || base.bg, previewBg: config.previewBg || base.previewBg || base.bg, codeBg: config.codeBg || base.codeBg, codeText: config.codeText || base.codeText, accent: config.accent || base.accent, muted: config.muted || base.muted, progressBg: config.progressBg || base.progressBg, closeHoverBg: config.closeHoverBg || base.closeHoverBg, }; }; const unregisterTheme = (name) => { if (name === 'light' || name === 'dark' || name === 'auto' || name === 'warm') { console.warn('Cannot unregister built-in theme:', name); return; } delete THEMES[name]; }; const getAllThemes = () => ({ ...THEMES }); const getThemeNames = () => Object.keys(THEMES); const hasTheme = (name) => name in THEMES; // ============ Instance theme ============ const createInstanceTheme = (editor) => { let instanceTheme = editor.config?.theme || globalCurrentTheme || 'auto'; // 外部跟随订阅列表,随实例销毁断开,避免 observer/定时器泄漏 const unsubs = []; const apply = (theme) => { instanceTheme = theme; const resolved = resolveTheme(theme); const config = getThemeConfig(theme); if (editor.el) { ['me-theme-light', 'me-theme-dark', 'me-theme-warm'].forEach((c) => editor.el.classList.remove(c)); if (resolved !== 'auto') editor.el.classList.add(`me-theme-${resolved}`); editor.el.setAttribute('data-md-theme', resolved); setThemeVariables(config, editor.el); } if (typeof editor._emit === 'function') editor._emit('themeChange', { theme, resolved, config }); if (typeof editor.refresh === 'function') editor.refresh(); return instanceTheme; }; const get = () => instanceTheme; const set = (theme) => apply(theme); const toggle = () => { const r = resolveTheme(instanceTheme); return apply(r === 'dark' ? 'light' : 'dark'); }; const getResolved = () => resolveTheme(instanceTheme); const getConfig = () => getThemeConfig(instanceTheme); const getVars = () => editor.el ? exportCSSVars(editor.el) : {}; const syncWithElement = (element, opts = {}) => { const unsub = followExternalTheme({ element, attr: opts.attr || 'data-theme', classMap: opts.classMap, callback: opts.callback }, (detected) => apply(detected)); unsubs.push(unsub); return unsub; }; const adopt = () => { const unsub = editor.container ? adoptFromParent(editor.container, (detected) => apply(detected)) : () => { }; unsubs.push(unsub); return unsub; }; const watchExternal = (source) => { const unsub = watch(source, (theme) => apply(theme)); unsubs.push(unsub); return unsub; }; const dispose = () => { unsubs.forEach((fn) => { try { fn(); } catch (_) { } }); unsubs.length = 0; }; apply(instanceTheme); return { apply, get, set, toggle, getResolved, getConfig, getVars, syncWithElement, adopt, watch: watchExternal, dispose }; }; const presetThemes = { light: { name: '浅色', description: '明亮清晰', config: THEMES.light }, dark: { name: '深色', description: '护眼暗色', config: THEMES.dark }, auto: { name: '自动', description: '跟随系统', config: 'auto' }, warm: { name: '暖色', description: '温馨暖色', config: THEMES.warm }, }; // Ensure built-in themes in THEMES for (const [name, theme] of Object.entries(presetThemes)) { if (name !== 'auto' && theme.config !== 'auto') THEMES[name] = theme.config; } const themeUtils = { getSystemTheme, resolveTheme, getThemeConfig, watchSystemTheme, unwatchSystemTheme, applyTheme, getCurrentTheme, getResolvedTheme, switchTheme, toggleTheme, resetToAuto, initTheme, saveTheme, loadTheme, addThemeListener, removeThemeListener, clearThemeListeners, registerTheme, unregisterTheme, getAllThemes, getThemeNames, hasTheme, setThemeVariables, exportCSSVars, getCSSVariable, applyThemeToElement, followExternalTheme, adoptFromParent, watch, createInstanceTheme, }; /** * MetonaEditor Commands — editing command implementations * @module commands * @version 0.4.0 */ // ============ Selection wrapping ============ function wrapSelection(before, after) { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const selected = ta.value.slice(start, end); const text = selected || 'text'; const inserted = before + text + after; ta.value = ta.value.slice(0, start) + inserted + ta.value.slice(end); ta.focus(); if (selected) { ta.selectionStart = start + before.length; ta.selectionEnd = start + before.length + text.length; } else { ta.selectionStart = ta.selectionEnd = start + before.length; } this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); } // ============ Line prefix toggling ============ function toggleLinePrefix(prefix) { const ta = this.textarea; const start = ta.selectionStart; const lineStart = ta.value.lastIndexOf('\n', start - 1) + 1; const lineEndPos = ta.value.indexOf('\n', start); const lineEnd = lineEndPos === -1 ? ta.value.length : lineEndPos; const line = ta.value.slice(lineStart, lineEnd); const existingMatch = line.match(/^(#{1,6}\s*|>\s*|[-*+]\s*|\d+\.\s*)/); let newLine; if (existingMatch && existingMatch[0] === prefix) newLine = line.slice(prefix.length); else if (existingMatch) newLine = prefix + line.slice(existingMatch[0].length); else newLine = prefix + line; ta.value = ta.value.slice(0, lineStart) + newLine + ta.value.slice(lineEnd); ta.focus(); ta.selectionStart = ta.selectionEnd = lineStart + newLine.length; this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); } // ============ Block insertion ============ function insertBlock(text) { const ta = this.textarea; const start = ta.selectionStart; const before = ta.value.slice(0, start); const needNL = before && !before.endsWith('\n'); const insert = (needNL ? '\n' : '') + text; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(ta.selectionEnd); ta.focus(); const pos = start + insert.length; ta.selectionStart = ta.selectionEnd = pos; this._value = ta.value; this._pushHistory(); this._render(); this._updateWordCount(); this._emit('change', this._value); } function insertLink() { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || this.t('link') || 'link'; const url = 'https://'; const insert = `[${sel}](${url})`; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 3; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); } function insertImage() { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || this.t('image') || 'image'; const url = 'https://'; const insert = `![${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); } function insertTable(rows = 3, cols = 3) { const header = Array.from({ length: cols }, (_, i) => `${this.t('tableCols') || '列'}${i + 1}`).join(' | '); const sep = Array.from({ length: cols }, () => '---').join(' | '); let md = `| ${header} |\n| ${sep} |\n`; for (let r = 1; r < rows; r++) md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`; insertBlock.call(this, '\n' + md); } // ============ Table auto-format ============ function formatTable() { const ta = this.textarea; const start = ta.selectionStart; const before = ta.value.substring(0, start); const after = ta.value.substring(start); const blockStart = before.lastIndexOf('\n\n'); const blockEnd = after.indexOf('\n\n'); const tableStart = blockStart === -1 ? 0 : blockStart + 2; const tableEnd = blockEnd === -1 ? ta.value.length : start + blockEnd; const tableText = ta.value.substring(tableStart, tableEnd); const lines = tableText.split('\n').filter((l) => l.includes('|')); if (lines.length < 2) return; const splitRow = (r) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/); const allCells = lines.map(splitRow); const colCount = Math.max(...allCells.map((c) => c.length)); const colWidths = Array(colCount).fill(3); allCells.forEach((cells) => { cells.forEach((cell, ci) => { colWidths[ci] = Math.max(colWidths[ci], cell.trim().length); }); }); const pad = (s, w) => { const padLen = w - s.length; return s + ' '.repeat(Math.max(0, padLen)); }; const formatted = allCells.map((cells) => { const padded = []; for (let ci = 0; ci < colCount; ci++) { padded.push(pad((cells[ci] || '').trim(), colWidths[ci])); } return '| ' + padded.join(' | ') + ' |'; }); ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd); this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); } // ============ Prototype installation ============ const installCommands = (proto) => { proto._wrapSelection = wrapSelection; proto._toggleLinePrefix = toggleLinePrefix; proto._insertBlock = insertBlock; proto._insertLink = insertLink; proto._insertImage = insertImage; proto._insertTable = insertTable; proto._formatTable = formatTable; }; /** * MetonaEditor Floating Toolbar — selection format toolbar * @module floating-toolbar * @version 0.4.0 */ function initFloatingToolbar() { if (typeof document === 'undefined') return; // Inject floating toolbar CSS once globally if (!document.getElementById('me-float-style')) { const style = document.createElement('style'); style.id = 'me-float-style'; style.textContent = `.me-float-toolbar{position:absolute;z-index:25;display:flex;gap:4px;padding:4px 6px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);opacity:0;transform:translateY(4px);transition:opacity .15s,transform .15s;pointer-events:none}.me-float-toolbar.me-visible{opacity:1;transform:translateY(0);pointer-events:auto}.me-float-toolbar .me-btn{width:28px;height:28px}`; document.head.appendChild(style); } if (!this._floatingEnabled) return; const ta = this.textarea; const show = () => { if (this._destroyed || this.config.readOnly || !this._floatingEnabled) return; const start = ta.selectionStart; const end = ta.selectionEnd; if (start === end) { this._hideFloatingToolbar(); return; } if (!this._floatingToolbar || !this._floatingToolbar.parentNode) this._buildFloatingToolbar(); const taRect = ta.getBoundingClientRect(); const paneRect = this.editorPane.getBoundingClientRect(); const taStyle = getComputedStyle(ta); const lineHeight = parseFloat(taStyle.lineHeight) || 22; const paddingTop = parseFloat(taStyle.paddingTop) || 16; const paddingLeft = parseFloat(taStyle.paddingLeft) || 18; const fontSize = parseFloat(taStyle.fontSize) || 13.5; const charWidth = fontSize * 0.6; const textBefore = ta.value.substring(0, start); const lines = textBefore.split('\n'); const currentLine = lines.length - 1; const currentCol = textBefore.length - textBefore.lastIndexOf('\n') - 1; const midCol = currentCol + Math.floor((end - start) / 2 * ((ta.selectionDirection === 'backward') ? -1 : 1)); const lineViewportTop = taRect.top + paddingTop + currentLine * lineHeight - ta.scrollTop; const lineLocalTop = lineViewportTop - paneRect.top; const toolbarHeight = 36; const gap = 4; let top = lineLocalTop - toolbarHeight - gap; if (top < gap) top = lineLocalTop + lineHeight + gap; const colX = taRect.left + paddingLeft + midCol * charWidth - ta.scrollLeft; let left = colX - paneRect.left - 80; left = Math.max(4, Math.min(left, paneRect.width - 200)); this._floatingToolbar.style.top = top + 'px'; this._floatingToolbar.style.left = left + 'px'; this._floatingToolbar.classList.add('me-visible'); }; const hide = () => { this._hideFloatingToolbar(); }; ta.addEventListener('mouseup', () => setTimeout(show, 0)); ta.addEventListener('keyup', () => { // selectionChange / cursorMove 事件由 core._emitCursorEvents 统一发射 if (ta.selectionStart !== ta.selectionEnd) setTimeout(show, 0); else setTimeout(hide, 0); }); ta.addEventListener('blur', () => setTimeout(hide, 300)); ta.addEventListener('click', () => { if (ta.selectionStart === ta.selectionEnd) hide(); }); } function toggleFloatingToolbar() { this._floatingEnabled = !this._floatingEnabled; if (!this._floatingEnabled) { if (this._floatingToolbar) { if (this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); this._floatingToolbar = null; } } return this; } function isFloatingToolbar() { return this._floatingEnabled; } function buildFloatingToolbar() { const bar = document.createElement('div'); bar.className = 'me-float-toolbar'; const actions = ['bold', 'italic', 'code', 'link', 'strikethrough']; actions.forEach((action) => { const btn = this._createBtn(action); btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); this.exec(action); // Keep selection after exec setTimeout(() => this.textarea.focus(), 0); }); bar.appendChild(btn); }); this.editorPane.appendChild(bar); this._floatingToolbar = bar; } function hideFloatingToolbar() { if (this._floatingToolbar) { this._floatingToolbar.classList.remove('me-visible'); } } // ============ Prototype installation ============ const installFloatingToolbar = (proto) => { proto._initFloatingToolbar = initFloatingToolbar; proto.toggleFloatingToolbar = toggleFloatingToolbar; proto.isFloatingToolbar = isFloatingToolbar; proto._buildFloatingToolbar = buildFloatingToolbar; proto._hideFloatingToolbar = hideFloatingToolbar; }; /** * MetonaEditor Context Menu — right-click menu * @module context-menu * @version 0.4.0 */ function registerContextMenu(items = []) { this._contextMenuItems = items; return this; } function bindContextMenu() { if (!this.el) return; const onContextMenu = (e) => { const existing = document.querySelector('.me-context-menu'); if (existing) existing.remove(); this._showContextMenu(e); }; this.el.addEventListener('contextmenu', onContextMenu); this._cleanups.push(() => this.el.removeEventListener('contextmenu', onContextMenu)); } function showContextMenu(e) { e.preventDefault(); const ta = this.textarea; const hasSelection = ta && ta.selectionStart !== ta.selectionEnd; const defaultItems = [ { label: this.t('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() }, { label: this.t('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() }, { sep: true }, { label: this.t('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection }, { label: this.t('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection }, { label: this.t('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly }, { label: this.t('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' }, ]; const items = [...defaultItems]; if (this._contextMenuItems.length) { items.push({ sep: true }); this._contextMenuItems.forEach((item) => items.push(item)); } const menu = document.createElement('div'); menu.className = 'me-context-menu'; menu.style.left = e.clientX + 'px'; menu.style.top = e.clientY + 'px'; // Adjust if off-screen requestAnimationFrame(() => { const rect = menu.getBoundingClientRect(); if (rect.right > window.innerWidth) menu.style.left = (e.clientX - rect.width) + 'px'; if (rect.bottom > window.innerHeight) menu.style.top = (e.clientY - rect.height) + 'px'; }); items.forEach((item) => { if (item.sep) { const sep = document.createElement('div'); sep.className = 'me-context-menu-sep'; menu.appendChild(sep); return; } const el = document.createElement('div'); el.className = 'me-context-menu-item'; if (item.disabled) el.classList.add('me-disabled'); el.innerHTML = `${item.label}${item.shortcut ? `${item.shortcut}` : ''}`; el.addEventListener('click', (ev) => { ev.stopPropagation(); if (item.disabled) return; if (item.onClick) { item.onClick(); } else if (item.action) this._execContextAction(item.action); this._hideContextMenu(); }); menu.appendChild(el); }); document.body.appendChild(menu); const close = (ev) => { if (!menu.contains(ev.target)) { this._hideContextMenu(); } }; document.addEventListener('click', close, { once: true }); document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') this._hideContextMenu(); }, { once: true }); } function hideContextMenu() { const menu = document.querySelector('.me-context-menu'); if (menu) menu.remove(); } function execContextAction(action) { const ta = this.textarea; if (!ta) return; switch (action) { case 'undo': this.undo(); break; case 'redo': this.redo(); break; case 'cut': document.execCommand('cut'); break; case 'copy': document.execCommand('copy'); break; case 'paste': document.execCommand('paste'); break; case 'selectAll': ta.focus(); ta.select(); break; default: this.exec(action); break; } } // ============ Prototype installation ============ const installContextMenu = (proto) => { proto.registerContextMenu = registerContextMenu; proto._bindContextMenu = bindContextMenu; proto._showContextMenu = showContextMenu; proto._hideContextMenu = hideContextMenu; proto._execContextAction = execContextAction; }; /** * MetonaEditor Outline — heading navigation panel * @module outline * @version 0.4.0 */ function buildOutline() { if (!this.config.outline || !this.previewEl) return; const old = this.el.querySelector('.me-outline'); if (old) old.remove(); const headings = []; const headingRe = /]*>(.+?)<\/h\1>/gi; let m; while ((m = headingRe.exec(this.previewEl.innerHTML)) !== null) { headings.push({ level: parseInt(m[1], 10), id: m[2], text: m[3].replace(/<[^>]+>/g, '') }); } if (!headings.length) return; const panel = document.createElement('div'); panel.className = 'me-outline'; panel.innerHTML = `
    ${this.t('outline') || '大纲'}
    `; // 迭代式树构建(栈模拟嵌套 ul),O(n) 而非递归的 O(n²) const buildTree = (items, minLevel) => { let h = ''; const stack = []; const closeTo = (targetLevel) => { while (stack.length && stack[stack.length - 1] >= targetLevel) { h += ''; stack.pop(); } }; for (const item of items) { if (item.level < minLevel) continue; closeTo(item.level); h += '
      '; h += `
    • ${escapeHTML(item.text)}`; stack.push(item.level); } closeTo(-1); return h; }; panel.innerHTML += buildTree(headings, 1); this.el.appendChild(panel); panel.addEventListener('click', (e) => { const a = e.target.closest('a'); if (!a) return; e.preventDefault(); const id = a.getAttribute('href').slice(1); const target = this.previewEl.querySelector('#' + 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); } } }); } function updateOutline() { if (!this.config.outline) return; if (this._outlineTimer) clearTimeout(this._outlineTimer); this._outlineTimer = setTimeout(() => this._buildOutline(), 300); } function trackOutlineScroll() { if (!this.config.outline || !this.previewPane) return; let ticking = false; const onScroll = () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { ticking = false; if (!this.el) return; const panel = this.el.querySelector('.me-outline'); if (!panel) return; const headings = this.previewEl.querySelectorAll('h1, h2, h3, h4, h5, h6'); let activeId = ''; const scrollTop = this.previewPane.scrollTop + 80; // offset for better UX headings.forEach((h) => { if (h.offsetTop <= scrollTop) { activeId = h.id; } }); panel.querySelectorAll('a').forEach((a) => { a.classList.toggle('me-outline-active', a.getAttribute('href') === '#' + activeId); }); }); }; this.previewPane.addEventListener('scroll', onScroll, { passive: true }); this._cleanups.push(() => this.previewPane.removeEventListener('scroll', onScroll)); } // ============ Prototype installation ============ const installOutline = (proto) => { proto._buildOutline = buildOutline; proto._updateOutline = updateOutline; proto._trackOutlineScroll = trackOutlineScroll; }; /** * MetonaEditor Core — MarkdownEditor class (TypeScript) * @module core * @version 0.2.0 */ const MODES = ['edit', 'split', 'preview']; const TOAST_ICONS = { success: '✓', error: '✗', warning: '⚠', info: 'ℹ' }; const BRACKET_PAIRS = { '(': ')', '[': ']', '{': '}', '"': '"', "'": "'", '`': '`', '*': '*', '_': '_', }; const resolveThemeName = (theme) => { if (theme && theme !== 'auto') return theme; return resolveTheme('auto'); }; class MarkdownEditor { static on(name, fn) { if (!this._hooks.has(name)) this._hooks.set(name, []); this._hooks.get(name).push(fn); return () => this.off(name, fn); } static off(name, fn) { const list = this._hooks.get(name); if (list) this._hooks.set(name, list.filter((f) => f !== fn)); } static trigger(name, instance) { const list = this._hooks.get(name); if (list) list.forEach((fn) => { try { fn(instance); } catch (e) { console.error(`MeEditor hook "${name}" error:`, e); } }); } constructor(container, options = {}) { this._statsCache = null; this._pendingCursor = null; this._lastSelStart = 0; this._lastSelEnd = 0; if (!isBrowser()) { this._destroyed = true; this._value = options.value || ''; return this; } this.id = options.id || generateId(); this.container = typeof container === 'string' ? document.querySelector(container) : container; if (!this.container) { console.error('MeEditor: container not found:', container); this._destroyed = true; return this; } this.config = { ...DEFAULTS, ...options }; if (options.style && typeof options.style === 'object') { this.config.style = { ...DEFAULTS.style, ...options.style }; } this._value = this.config.value || ''; this._mode = MODES.includes(this.config.mode) ? this.config.mode : 'split'; this._history = []; this._historyIndex = -1; this._cleanups = []; this._plugins = []; this._listeners = {}; this._renderRaf = null; this._historyTimer = null; this._fullscreen = false; this._destroyed = false; this._lastRenderedValue = null; this._shortcuts = []; this._contextMenuItems = []; this._customActions = {}; this._outlineTimer = null; this._zenMode = this.config.zenMode === true; this._wordWrap = this.config.wordWrap !== false; this._syncing = false; this._zenMouseHandler = null; this._floatingToolbar = null; this._floatingEnabled = this.config.floatingToolbar !== false; this._renderFn = (typeof this.config.render === 'function') ? this.config.render : parseMarkdown; this._highlightFn = (typeof this.config.highlight === 'function') ? this.config.highlight : null; MarkdownEditor.trigger('beforeCreate', this); injectStyles(); // 实例 i18n 需在 _buildDOM 之前创建:DOM 构建与工具栏渲染会读取实例语言 this._i18nCtx = createInstanceI18n(this); this._buildDOM(); this._applyZenWidth(); this._themeCtx = createInstanceTheme(this); if (options.theme && typeof document !== 'undefined') { document.documentElement.setAttribute('data-md-theme', resolveTheme(options.theme)); } const resolvedThemeConfig = getThemeConfig(this.config.theme); setThemeVariables(resolvedThemeConfig, this.el); this._buildToolbar(); this._updateModeButtons(); this._bindToolbarKeyboard(); this._initAriaLive(); this._bindEvents(); this._bindContextMenu(); this._trackOutlineScroll(); if (this.config.floatingToolbar !== false) this._initFloatingToolbar(); this.textarea.value = this._value; if (!this._wordWrap) this.textarea.style.whiteSpace = 'pre'; this._pushHistory(); this._render(); this._updateWordCount(); this._buildOutline(); if (this._zenMode) this._setZen(true); if (Array.isArray(this.config.plugins)) { const plugins = this.config.plugins.map((p) => { if (typeof p === 'string') return { ...presetPlugins[p], _isStringRef: true }; return p; }).filter(Boolean); const sorted = topologicalSort(plugins); sorted.forEach((p) => this.use(p)); } if (this.config.autofocus && !this.config.readOnly) this.focus(); if (typeof this.config.onCreate === 'function') { try { this.config.onCreate(this); } catch (e) { console.error('onCreate error:', e); } } MarkdownEditor.trigger('afterCreate', this); } _buildDOM() { const wrapper = document.createElement('div'); wrapper.className = `me-wrapper me-theme-${resolveThemeName(this.config.theme)}`; if (this.config.readOnly) wrapper.classList.add('me-readonly'); wrapper.dataset.id = this.id; wrapper.setAttribute('role', 'application'); wrapper.setAttribute('aria-label', this.t('edit')); if (this.config.className) { this.config.className.split(/\s+/).filter(Boolean).forEach((c) => wrapper.classList.add(c)); } const h = this.config.height; wrapper.style.height = typeof h === 'number' ? `${h}px` : (h || '400px'); if (this.config.style && typeof this.config.style === 'object') { Object.entries(this.config.style).forEach(([k, v]) => { if (v) wrapper.style[k] = v; }); } const toolbar = document.createElement('div'); toolbar.className = 'me-toolbar'; toolbar.setAttribute('role', 'toolbar'); const body = document.createElement('div'); body.className = `me-body me-mode-${this._mode}`; const editorPane = document.createElement('div'); editorPane.className = 'me-editor-pane'; const editorInner = document.createElement('div'); editorInner.className = 'me-editor-inner'; const gutter = document.createElement('div'); gutter.className = 'me-gutter'; if (!this.config.lineNumbers) gutter.style.display = 'none'; editorInner.appendChild(gutter); const textarea = document.createElement('textarea'); textarea.className = 'me-textarea'; textarea.spellcheck = !!this.config.spellcheck; textarea.readOnly = !!this.config.readOnly; if (this.config.maxLength && this.config.maxLength > 0) textarea.maxLength = this.config.maxLength; textarea.style.tabSize = String(this.config.tabSize || 2); textarea.placeholder = this.config.placeholder || this.t('placeholder'); textarea.setAttribute('aria-label', this.t('edit')); editorInner.appendChild(textarea); editorPane.appendChild(editorInner); const divider = document.createElement('div'); divider.className = 'me-divider'; divider.setAttribute('role', 'separator'); const previewPane = document.createElement('div'); previewPane.className = 'me-preview-pane'; previewPane.setAttribute('role', 'region'); previewPane.setAttribute('aria-label', this.t('preview')); const preview = document.createElement('div'); preview.className = 'me-preview'; previewPane.appendChild(preview); body.appendChild(editorPane); body.appendChild(divider); body.appendChild(previewPane); wrapper.appendChild(toolbar); wrapper.appendChild(body); let statusbar = null; if (this.config.wordCount) { statusbar = document.createElement('div'); statusbar.className = 'me-statusbar'; wrapper.appendChild(statusbar); } this.container.appendChild(wrapper); this.el = wrapper; this.toolbarEl = toolbar; this.bodyEl = body; this.editorPane = editorPane; this.editorInner = editorInner; this.gutter = gutter; this.previewPane = previewPane; this.dividerEl = divider; this.textarea = textarea; this.previewEl = preview; this.statusEl = statusbar; // Restore divider position if saved this._restoreDividerPosition(); } _buildToolbar() { const tools = this.config.toolbar; if (!tools || !Array.isArray(tools) || tools.length === 0) return; const modeActions = []; tools.forEach((item) => { if (item === '|') { const sep = document.createElement('span'); sep.className = 'me-toolbar-sep'; this.toolbarEl.appendChild(sep); return; } if (item === 'edit' || item === 'split' || item === 'preview' || item === 'fullscreen') { modeActions.push(item); return; } this.toolbarEl.appendChild(this._createBtn(item)); }); if (modeActions.length) { const group = document.createElement('div'); group.className = 'me-toolbar-group'; modeActions.forEach((item) => { const btn = this._createBtn(item); if (item === 'edit' || item === 'split' || item === 'preview') { btn.dataset.mode = item; if (item === this._mode) btn.classList.add('me-active'); } group.appendChild(btn); }); this.toolbarEl.appendChild(group); } } _createBtn(item) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = `me-btn me-btn-${item}`; btn.dataset.action = item; const label = this.t(item) || item; btn.title = label; btn.setAttribute('aria-label', label); btn.innerHTML = ICONS[item] || `${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(); this._emitCursorEvents(); }; ta.addEventListener('click', onCursorActivity); ta.addEventListener('keyup', onCursorActivity); const onScroll = () => { this._syncScroll(); }; ta.addEventListener('scroll', onScroll); const onPreviewScroll = () => { if (!this.config.syncScroll || this._mode !== 'split') return; if (this._syncing) return; this._syncing = true; const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight; if (pmax <= 0) { this._syncing = false; return; } const ratio = this.previewPane.scrollTop / pmax; const tmax = ta.scrollHeight - ta.clientHeight; ta.scrollTop = ratio * tmax; if (this.gutter) { const gmax = this.gutter.scrollHeight - this.gutter.clientHeight; this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ratio * tmax; } requestAnimationFrame(() => { this._syncing = false; }); }; this.previewPane.addEventListener('scroll', onPreviewScroll); const onFocus = () => { this._emit('focus'); if (typeof this.config.onFocus === 'function') { try { this.config.onFocus(this); } catch (e) { console.error(e); } } }; const onBlur = () => { this._emit('blur'); if (typeof this.config.onBlur === 'function') { try { this.config.onBlur(this); } catch (e) { console.error(e); } } }; ta.addEventListener('focus', onFocus); ta.addEventListener('blur', onBlur); const onPreviewClick = (e) => { const a = e.target.closest('a'); if (!a) return; const href = a.getAttribute('href'); if (!href || href.startsWith('#')) return; e.preventDefault(); this._emit('linkClick', { href, text: a.textContent }); if (typeof this.config.onLinkClick === 'function') { try { this.config.onLinkClick(href, a.textContent, this); } catch (_) { } return; } window.open(href, '_blank', 'noopener'); }; this.previewEl.addEventListener('click', onPreviewClick); const onToolbarClick = (e) => { const btn = e.target.closest('.me-btn'); if (!btn) return; const mode = btn.dataset.mode; if (mode) { this.setMode(mode); return; } const action = btn.dataset.action; if (action) this.exec(action); }; this.toolbarEl.addEventListener('click', onToolbarClick); const onDividerDown = (e) => this._bindDividerDrag(e); this.dividerEl.addEventListener('pointerdown', onDividerDown); 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-${this.id}`, epFlex); } catch (_) { } } _restoreDividerPosition() { try { const saved = localStorage.getItem(`metona-editor-divider-${this.id}`); if (saved && this._mode === 'split') { this.editorPane.style.flex = saved; this.previewPane.style.flex = '1 1 auto'; } } catch (_) { } } _scheduleRender() { if (this._renderRaf) return; this._renderRaf = requestAnimationFrame(() => { this._renderRaf = null; this._render(); }); } _render() { if (this._mode === 'edit') { this._renderGutter(); return; } if (this._lastRenderedValue === this._value) return; this._lastRenderedValue = this._value; MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this); let html; try { html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: this.getLocale() }); } catch (err) { console.error('MeEditor render error:', err); html = `

      ${this.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 stats = this.getStats(); const lines = stats.lines > 0 ? stats.lines : 1; const current = this.gutter.children.length; if (current === lines) return; if (current < lines) { // Add new line numbers let h = ''; for (let i = current + 1; i <= lines; i++) h += `
      ${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'); } /** 选区/光标事件:与浮动工具栏解耦,任何实例都会触发 */ _emitCursorEvents() { if (!this.textarea) return; const s = this.textarea.selectionStart; const e = this.textarea.selectionEnd; if (s !== this._lastSelStart || e !== this._lastSelEnd) { this._lastSelStart = s; this._lastSelEnd = e; this._emit('selectionChange', { start: s, end: e, text: this._value.slice(s, e) }); } this._emit('cursorMove', this.getCursorPosition()); } _handleSmartEnter(e) { const ta = this.textarea; const start = ta.selectionStart; const val = ta.value; const lineStart = val.lastIndexOf('\n', start - 1) + 1; const line = val.slice(lineStart, start); const listMatch = line.match(/^(\s*)([-*+]|\d+\.)\s(.*)/); if (listMatch) { const indent = listMatch[1]; const marker = listMatch[2]; const content = listMatch[3]; if (!content.trim()) { e.preventDefault(); 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 DROP_IMAGE_MAX_KB = 500; const onDragOver = (e) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }; const onDrop = (e) => { e.preventDefault(); const files = e.dataTransfer?.files; if (files && files.length) { Array.from(files).forEach((file) => { if (file.type.startsWith('image/')) { const sizeKB = file.size / 1024; if (sizeKB > DROP_IMAGE_MAX_KB) { this.toast(this.t('imageTooLarge', { size: sizeKB.toFixed(0), max: DROP_IMAGE_MAX_KB }) || `Image too large (${sizeKB.toFixed(0)}KB > ${DROP_IMAGE_MAX_KB}KB)`, { type: 'warning' }); return; } const reader = new FileReader(); reader.onload = () => { this.insert(`![${file.name}](${reader.result})\n`); }; reader.readAsDataURL(file); } else if (file.type.startsWith('text/') || /\.(md|txt|js|ts|json|css|html|xml|yml|yaml)$/i.test(file.name)) { const reader = new FileReader(); reader.onload = () => this.insert(reader.result); reader.readAsText(file); } }); return; } const text = e.dataTransfer?.getData('text/plain'); if (text) this.insert(text); }; ta.addEventListener('dragover', onDragOver); ta.addEventListener('drop', onDrop); this._cleanups.push(() => { ta.removeEventListener('dragover', onDragOver); ta.removeEventListener('drop', onDrop); }); } _scheduleHistory() { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } _pushHistory() { // Enforce maxLength as a last-resort guard for programmatic edit paths // (tab/indent, smart-enter, bracket auto-close, exec commands) that write // to the textarea directly and bypass the native maxlength attribute. if (this.config.maxLength && this.config.maxLength > 0 && this._value.length > this.config.maxLength) { this._value = this._value.slice(0, this.config.maxLength); if (this.textarea) this.textarea.value = this._value; } const cur = this._history[this._historyIndex]; if (cur === this._value) return; this._history = this._history.slice(0, this._historyIndex + 1); this._history.push(this._value); const limit = this.config.historyLimit > 0 ? this.config.historyLimit : 100; while (this._history.length > limit) this._history.shift(); this._historyIndex = this._history.length - 1; } undo() { if (this._historyIndex <= 0) return this; this._captureCursor(); this._historyIndex--; this._applyHistory(); this._restoreCapturedCursor(); return this; } redo() { if (this._historyIndex >= this._history.length - 1) return this; this._captureCursor(); this._historyIndex++; this._applyHistory(); this._restoreCapturedCursor(); return this; } canUndo() { return this._historyIndex > 0; } canRedo() { return this._historyIndex < this._history.length - 1; } _applyHistory() { 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); } } } _captureCursor() { if (!this.textarea) return; this._pendingCursor = { start: this.textarea.selectionStart, end: this.textarea.selectionEnd }; } _restoreCapturedCursor() { if (!this._pendingCursor || !this.textarea) return; const len = this._value.length; const start = Math.min(this._pendingCursor.start, len); const end = Math.min(this._pendingCursor.end, len); try { this.textarea.setSelectionRange(start, end); } catch { } this._pendingCursor = null; } exec(action, ...args) { const actions = { bold: () => this._wrapSelection('**', '**'), italic: () => this._wrapSelection('*', '*'), strikethrough: () => this._wrapSelection('~~', '~~'), underline: () => this._wrapSelection('', ''), code: () => this._wrapSelection('`', '`'), h1: () => this._toggleLinePrefix('# '), h2: () => this._toggleLinePrefix('## '), h3: () => this._toggleLinePrefix('### '), quote: () => this._toggleLinePrefix('> '), ul: () => this._toggleLinePrefix('- '), ol: () => this._toggleLinePrefix('1. '), indent: () => this._handleTab(false), outdent: () => this._handleTab(true), hr: () => this._insertBlock('\n---\n'), link: () => this._insertLink(), image: () => this._insertImage(), table: () => this._insertTable(), undo: () => this.undo(), redo: () => this.redo(), edit: () => this.setMode('edit'), split: () => this.setMode('split'), preview: () => this.setMode('preview'), fullscreen: () => this.toggleFullscreen(), zen: () => this.toggleZen(), wordwrap: () => this.toggleWordWrap(), formatTable: () => this._formatTable(), }; const fn = actions[action]; if (fn) { fn.apply(this, args); return this; } if (this._customActions && typeof this._customActions[action] === 'function') { try { this._customActions[action].apply(this, args); } catch (e) { console.error('custom action error:', e); } } return this; } setMode(mode) { if (!MODES.includes(mode) || mode === this._mode) return this; const taS = this.textarea.scrollTop; const pvS = this.previewPane.scrollTop; this._mode = mode; this.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit') { this._render(); this._buildOutline(); } this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${this.t(mode) || mode} mode`); if (typeof this.config.onModeChange === 'function') { try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); } } return this; } getMode() { return this._mode; } _updateModeButtons() { const isPreview = this._mode === 'preview'; const isReadonly = this.config.readOnly; this.toolbarEl.querySelectorAll('.me-btn').forEach((b) => { const btn = b; const mode = btn.dataset.mode; const action = btn.dataset.action; if (mode) { btn.classList.toggle('me-active', mode === this._mode); btn.disabled = false; return; } if (action === 'fullscreen') { btn.disabled = false; return; } if (isReadonly) { btn.disabled = !(action === 'undo' || action === 'redo'); return; } if (isPreview) { btn.disabled = !(action === 'undo' || action === 'redo'); return; } btn.disabled = false; }); } toggleFullscreen() { this._fullscreen = !this._fullscreen; this.el.classList.toggle('me-fullscreen', this._fullscreen); const fsBtn = this.toolbarEl.querySelector('.me-btn-fullscreen'); if (fsBtn) fsBtn.title = this._fullscreen ? (this.t('fullscreenExit') || '退出全屏') : (this.t('fullscreen') || '全屏'); this._emit('fullscreen', this._fullscreen); if (typeof this.config.onFullscreen === 'function') { try { this.config.onFullscreen(this._fullscreen, this); } catch (e) { console.error(e); } } return this; } isFullscreen() { return this._fullscreen; } exitFullscreen() { if (this._fullscreen) this.toggleFullscreen(); return this; } toggleZen() { this._setZen(!this._zenMode); return this; } _setZen(on) { this._zenMode = !!on; if (!this.el) return; this.el.classList.toggle('me-zen', this._zenMode); this._applyZenWidth(); if (this._zenMode && this.toolbarEl) { if (!this._zenMouseHandler) { this._zenMouseHandler = (e) => { this.toolbarEl.style.opacity = e.clientY < 40 ? '1' : '0'; this.toolbarEl.style.pointerEvents = e.clientY < 40 ? 'auto' : 'none'; }; document.addEventListener('mousemove', this._zenMouseHandler); } this.toolbarEl.style.transition = 'opacity 0.2s'; this.toolbarEl.style.opacity = '0'; this.toolbarEl.style.pointerEvents = 'none'; if (this.statusEl) this.statusEl.style.display = 'none'; } else { if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; } if (this.toolbarEl) { this.toolbarEl.style.opacity = ''; this.toolbarEl.style.pointerEvents = ''; this.toolbarEl.style.transition = ''; } if (this.statusEl) this.statusEl.style.display = ''; } this._emit('zenChange', this._zenMode); } isZen() { return this._zenMode; } _applyZenWidth() { if (!this.el) return; const width = this.config.zenMaxWidth; if (width == null || width === false || width === '') { this.el.style.removeProperty('--md-zen-max-width'); } else { this.el.style.setProperty('--md-zen-max-width', typeof width === 'number' ? `${width}px` : String(width)); } } setZenMaxWidth(width) { this.config.zenMaxWidth = width; this._applyZenWidth(); return this; } getZenMaxWidth() { return this.config.zenMaxWidth ?? 960; } toggleWordWrap() { this._wordWrap = !this._wordWrap; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; } setWordWrap(on) { this._wordWrap = !!on; if (this.textarea) this.textarea.style.whiteSpace = this._wordWrap ? 'pre-wrap' : 'pre'; return this; } isWordWrap() { return this._wordWrap; } _bindToolbarKeyboard() { if (!this.toolbarEl) return; const onKeydown = (e) => { const btns = [...this.toolbarEl.querySelectorAll('.me-btn:not(:disabled)')]; if (!btns.length) return; const idx = btns.indexOf(document.activeElement); if (idx === -1) return; if (e.key === 'ArrowRight') { e.preventDefault(); btns[(idx + 1) % btns.length].focus(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); btns[(idx - 1 + btns.length) % btns.length].focus(); } else if (e.key === 'Home') { e.preventDefault(); btns[0].focus(); } else if (e.key === 'End') { e.preventDefault(); btns[btns.length - 1].focus(); } }; this.toolbarEl.addEventListener('keydown', onKeydown); this._cleanups.push(() => this.toolbarEl.removeEventListener('keydown', onKeydown)); } _initAriaLive() { if (!this.el) return; const live = document.createElement('div'); live.className = 'me-sr-only'; live.setAttribute('aria-live', 'polite'); live.setAttribute('aria-atomic', 'true'); this.el.appendChild(live); this._ariaLive = live; } _announce(msg) { if (this._ariaLive) { this._ariaLive.textContent = ''; requestAnimationFrame(() => { this._ariaLive.textContent = msg; }); } } _syncScroll() { if (this._syncing) return; this._syncing = true; const ta = this.textarea; if (!ta) { this._syncing = false; return; } const tmax = ta.scrollHeight - ta.clientHeight; if (tmax <= 0) { this._syncing = false; return; } const ratio = ta.scrollTop / tmax; if (this.gutter) { const gmax = this.gutter.scrollHeight - this.gutter.clientHeight; this.gutter.scrollTop = gmax > 0 ? ratio * gmax : ta.scrollTop; } if (this.config.syncScroll && this._mode === 'split' && this.previewPane) { const pmax = this.previewPane.scrollHeight - this.previewPane.clientHeight; if (pmax > 0) this.previewPane.scrollTop = ratio * pmax; } requestAnimationFrame(() => { this._syncing = false; }); } _updateWordCount() { if (!this.config.wordCount || !this.statusEl) return; const stats = this.getStats(); this.statusEl.textContent = [`${this.t('characters')}: ${stats.characters}`, `${this.t('words')}: ${stats.words}`, `${this.t('lines')}: ${stats.lines}`, `${this.t('readingTime')}: ${stats.readingTime} ${this.t('minutes')}`].join(' · '); } getStats() { const text = this._value || ''; if (this._statsCache && this._statsCache.value === text) return this._statsCache.stats; const stats = this._computeStats(text); this._statsCache = { value: text, stats }; return stats; } /** * 增量统计:基于上一次统计 + 差异区间(共同前缀/后缀之间的片段)计算, * 避免每次击键对全文做 O(n) 正则。差异区间左右扩展至单词边界, * 保证英文单词 / 中文串不会被边界切断,diff 结果与全量计算一致。 */ _computeStats(text) { const prev = this._statsCache; if (!prev) { const cnChars = (text.match(/[\u4e00-\u9fa5]/g) || []).length; const enWords = (text.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length; const words = cnChars + enWords; const lines = text ? text.split('\n').length : 0; return { characters: text.length, words, chineseChars: cnChars, englishWords: enWords, lines, readingTime: Math.max(1, Math.ceil(words / 300)) }; } const oldText = prev.value; const oldStats = prev.stats; const isWordChar = (c) => !!c && /[\w\u4e00-\u9fff]/.test(c); let p = 0; const maxP = Math.min(oldText.length, text.length); while (p < maxP && oldText[p] === text[p]) p++; let s = 0; const maxS = maxP - p; while (s < maxS && oldText[oldText.length - 1 - s] === text[text.length - 1 - s]) s++; if (p === oldText.length && p === text.length) return oldStats; let chunkStart = p; while (chunkStart > 0 && (isWordChar(oldText[chunkStart - 1]) || isWordChar(text[chunkStart - 1]))) chunkStart--; let chunkEndOld = oldText.length - s; let chunkEndNew = text.length - s; while (chunkEndOld < oldText.length && isWordChar(oldText[chunkEndOld])) chunkEndOld++; while (chunkEndNew < text.length && isWordChar(text[chunkEndNew])) chunkEndNew++; const oldChunk = oldText.slice(chunkStart, chunkEndOld); const newChunk = text.slice(chunkStart, chunkEndNew); const cnDelta = (newChunk.match(/[\u4e00-\u9fa5]/g) || []).length - (oldChunk.match(/[\u4e00-\u9fa5]/g) || []).length; const enDelta = (newChunk.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length - (oldChunk.replace(/[\u4e00-\u9fa5]/g, ' ').match(/[a-zA-Z0-9]+/g) || []).length; const lineDelta = (newChunk.match(/\n/g) || []).length - (oldChunk.match(/\n/g) || []).length; const chineseChars = oldStats.chineseChars + cnDelta; const englishWords = Math.max(0, oldStats.englishWords + enDelta); // 行数 = 基础行(空文本 0 / 非空 1) + \n 计数;\n 计数增量更新 const oldBase = oldText ? 1 : 0; const lines = (text ? 1 : 0) + (oldStats.lines - oldBase) + lineDelta; const words = chineseChars + englishWords; return { characters: text.length, words, chineseChars, englishWords, lines, readingTime: Math.max(1, Math.ceil(words / 300)) }; } getSelectedText() { if (!this.textarea) return ''; return this._value.slice(this.textarea.selectionStart, this.textarea.selectionEnd); } getCursorPosition() { if (!this.textarea) return { line: 1, column: 0 }; const pos = this.textarea.selectionStart; const before = this._value.substring(0, pos); const line = before.split('\n').length; const column = pos - before.lastIndexOf('\n') - 1; return { line, column: column < 0 ? 0 : column }; } setCursorPosition(line, column = 0) { if (!this.textarea) return this; const lines = this._value.split('\n'); const clampedLine = Math.max(1, Math.min(line, lines.length)); let pos = 0; for (let i = 0; i < clampedLine - 1; i++) pos += lines[i].length + 1; pos += Math.min(column, lines[clampedLine - 1]?.length || 0); this.textarea.focus(); this.textarea.setSelectionRange(pos, pos); return this; } scrollToLine(line) { if (!this.textarea) return this; const lines = this._value.split('\n'); const clampedLine = Math.max(1, Math.min(line, lines.length)); const taStyle = getComputedStyle(this.textarea); const lineHeight = parseFloat(taStyle.lineHeight) || 22; this.textarea.scrollTop = (clampedLine - 1) * lineHeight; if (this.gutter) this.gutter.scrollTop = this.textarea.scrollTop; return this; } selectLine(line) { if (!this.textarea) return this; const lines = this._value.split('\n'); const clampedLine = Math.max(1, Math.min(line, lines.length)); let startPos = 0; for (let i = 0; i < clampedLine - 1; i++) startPos += lines[i].length + 1; const endPos = startPos + lines[clampedLine - 1].length; this.textarea.focus(); this.textarea.setSelectionRange(startPos, endPos); return this; } selectAll() { if (this.textarea) { this.textarea.focus(); this.textarea.select(); } return this; } replaceAll(search, replace, caseSensitive = true) { if (!search) return 0; const flags = caseSensitive ? 'g' : 'gi'; const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const re = new RegExp(escaped, flags); const matches = this._value.match(re); if (!matches) return 0; // 函数式替换:替换文本按字面处理,$ 无特殊语义 this._value = this._limitLength(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._limitLength(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]; } _limitLength(value) { const max = this.config.maxLength; return max && max > 0 ? value.slice(0, max) : value; } getValue() { return this._destroyed ? '' : this._value; } setValue(md, opts = {}) { if (this._destroyed) return this; md = this._limitLength(md || ''); MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', this._value, md); this._value = md || ''; this.textarea.value = this._value; if (!opts.silent) this._pushHistory(); this._render(); this._renderGutter(); this._updateWordCount(); this._updateOutline(); if (this.gutter) this.gutter.scrollTop = 0; this.textarea.scrollTop = 0; if (!opts.silent) { this._emit('change', this._value); if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } } } this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this); return this; } getHTML() { MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this); let html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: this.getLocale() }); if (typeof this.config.sanitize === 'function') { try { html = this.config.sanitize(html); } catch (e) { console.error(e); } } MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this); return html; } refresh() { this._lastRenderedValue = null; this._render(); return this; } copyAsMarkdown() { if (typeof navigator !== 'undefined' && navigator.clipboard) { navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'markdown' })).catch(() => { }); } return this; } copyAsHTML() { const html = this.getHTML(); if (typeof navigator !== 'undefined' && navigator.clipboard) { 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; const inserted = this._limitLength(ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start)); ta.value = inserted; ta.focus(); ta.selectionStart = ta.selectionEnd = Math.min(start + text.length, inserted.length); 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 (this._plugins.some((x) => x.name === merged.name)) { console.warn(`MeEditor: plugin "${merged.name}" already installed, skipped`); return this; } if (typeof merged.install === 'function') { try { const result = merged.install(this, options); if (result && typeof result.then === 'function') { result.catch((e) => console.error(`MeEditor: async plugin "${merged.name}" error:`, e)); } } catch (e) { console.error(`MeEditor: plugin "${merged.name}" install error:`, e); } } this._plugins.push(merged); return this; } unuse(name) { const idx = this._plugins.findIndex((p) => p.name === name); if (idx === -1) { console.warn(`MeEditor: plugin "${name}" not found`); return this; } const plugin = this._plugins[idx]; if (typeof plugin.destroy === 'function') { try { plugin.destroy(this); } catch (e) { console.error(`MeEditor: plugin "${name}" destroy error:`, e); } } this._plugins.splice(idx, 1); return this; } getPlugins() { return [...this._plugins]; } addToolbarButton(config) { if (!config || !config.action) return this; const btn = document.createElement('button'); btn.type = 'button'; btn.className = `me-btn me-btn-custom-${config.action}`; btn.title = config.title || config.action; btn.setAttribute('aria-label', config.title || config.action); btn.innerHTML = config.icon || `${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.toLowerCase() !== combo.toLowerCase()); return this; } getShortcuts() { return [...this._shortcuts]; } configureToolbar(tools) { if (!this.toolbarEl) return this; this.config.toolbar = tools; this.toolbarEl.innerHTML = ''; this._buildToolbar(); return this; } removeToolbarButton(action) { if (!this.toolbarEl) return this; const btn = this.toolbarEl.querySelector(`.me-btn[data-action="${action}"], .me-btn[data-mode="${action}"]`); if (btn) btn.remove(); return this; } toast(message, opts = {}) { if (!this.el || typeof document === 'undefined') return this; const { type = 'info', duration = 3000, animation = 'fade' } = opts; const anim = ANIMATIONS[animation] || ANIMATIONS.fade; const toast = document.createElement('div'); toast.className = `me-toast me-toast-${type} me-toast-${animation}`; toast.innerHTML = `${TOAST_ICONS[type] || ''}${escapeHTML(String(message))}`; toast.style.transition = `opacity ${anim.duration}ms ${anim.easing}, transform ${anim.duration}ms ${anim.easing}, filter ${anim.duration}ms ${anim.easing}`; for (const [k, v] of Object.entries(anim.enter)) toast.style[k] = v; this.el.appendChild(toast); requestAnimationFrame(() => { requestAnimationFrame(() => { for (const k of Object.keys(anim.enter)) toast.style[k] = ''; }); }); const remove = () => { for (const [k, v] of Object.entries(anim.leave)) toast.style[k] = v; setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, anim.duration); }; if (duration > 0) setTimeout(remove, duration); toast.addEventListener('click', remove); return this; } setLocale(locale) { if (this._i18nCtx) this._i18nCtx.set(locale); return this; } getLocale() { if (this._i18nCtx) return this._i18nCtx.get(); return getCurrentLocale(); } t(key, params) { if (this._i18nCtx) return this._i18nCtx.t(key, params); return t(key, params); } setTheme(theme) { if (!theme) return this; this.config.theme = theme; if (this._themeCtx) this._themeCtx.set(theme); return this; } getTheme() { if (this._themeCtx) return this._themeCtx.get(); return this.config.theme || 'auto'; } getThemeContext() { return this._themeCtx || null; } getStatus() { return { id: this.id, mode: this._mode, theme: this.getTheme(), locale: this.getLocale(), fullscreen: this._fullscreen, readOnly: this.config.readOnly || false, disabled: this.textarea ? this.textarea.disabled : false, destroyed: this._destroyed, plugins: this._plugins.map((p) => p.name || 'custom'), }; } destroy() { if (this._destroyed) return; MarkdownEditor.trigger('beforeDestroy', this); if (this._renderRaf) cancelAnimationFrame(this._renderRaf); if (this._historyTimer) clearTimeout(this._historyTimer); if (this._outlineTimer) clearTimeout(this._outlineTimer); this._cleanups.forEach((fn) => { try { fn(); } catch (_) { } }); this._cleanups = []; if (this._zenMouseHandler) { document.removeEventListener('mousemove', this._zenMouseHandler); this._zenMouseHandler = null; } if (this._themeCtx && typeof this._themeCtx.dispose === 'function') { try { this._themeCtx.dispose(); } catch (_) { } } this._shortcuts = []; this._contextMenuItems = []; this._customActions = {}; if (this._floatingToolbar && this._floatingToolbar.parentNode) this._floatingToolbar.parentNode.removeChild(this._floatingToolbar); this._floatingToolbar = null; if (this.el) this.el.querySelectorAll('.me-toast').forEach((t) => t.remove()); this._plugins.forEach((p) => { if (typeof p.destroy === 'function') { try { p.destroy(this); } catch (_) { } } }); this._plugins = []; if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el); this.el = null; this.textarea = null; this.previewEl = null; this._destroyed = true; if (typeof this.config.onDestroy === 'function') { try { this.config.onDestroy(this); } catch (e) { console.error(e); } } this._emit('destroy'); this._listeners = {}; MarkdownEditor.trigger('afterDestroy', this); } isDestroyed() { return this._destroyed; } } MarkdownEditor._hooks = new Map(); // Install split-out modules onto the prototype (keeps `this` binding and // preserves the existing instance API surface for consumers and tests) installCommands(MarkdownEditor.prototype); installFloatingToolbar(MarkdownEditor.prototype); installContextMenu(MarkdownEditor.prototype); installOutline(MarkdownEditor.prototype); /** * MetonaEditor Animations — animation metadata * @module animations * @version 0.2.0 */ const animationMap = new Map(); // Register built-in animations for (const [name, config] of Object.entries(ANIMATIONS)) { animationMap.set(name, config); } const animationUtils = { register(name, config) { animationMap.set(name, { name, enter: config.enter || {}, leave: config.leave || {}, duration: config.duration || 300, easing: config.easing || 'cubic-bezier(0.4, 0, 0.2, 1)', }); }, unregister(name) { animationMap.delete(name); }, get(name) { return animationMap.get(name) || ANIMATIONS[name] || null; }, getAnimationNames() { return Array.from(animationMap.keys()); }, getActiveCount() { return 0; }, cancelAll() { // CSS animations managed by browser natively }, reset() { animationMap.clear(); for (const [name, config] of Object.entries(ANIMATIONS)) { animationMap.set(name, config); } }, destroy() { animationMap.clear(); }, }; /** * MetonaEditor Highlight — built-in lightweight syntax highlighter * @module highlight * @version 0.4.0 * * Zero-dependency tokenizer for common languages. Safe by construction: * input is HTML-escaped first, then annotated with classes on the * escaped text (no raw HTML can leak through). */ /** * Environment-agnostic escaping for tokenizer input. * Unlike the `escapeHTML` util (whose quote handling differs between browser * DOM and Node), we only escape `& < >` here so string/comment token rules * behave identically in both environments. Quote characters are safe inside * text nodes and do not need escaping. */ const escapeText = (s) => { return s.replace(/&/g, '&').replace(//g, '>'); }; const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const buildRules = (lang) => { const rules = []; // Comments (highest priority; protects keywords/strings inside) if (lang.hasHashComments) { rules.push({ cls: 'me-hl-comment', re: /#[^\n]*/y }); } else { rules.push({ cls: 'me-hl-comment', re: /\/\/[^\n]*/y }); } if (lang.hasBlocks) rules.push({ cls: 'me-hl-comment', re: /\/\*[\s\S]*?\*\//y }); // Strings (double + single quotes with backslash escapes) const strRe = lang.singleQuoteStrings === false ? /"(?:\\.|[^"\\\n])*"/y : /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/y; rules.push({ cls: 'me-hl-string', re: strRe }); if (lang.hasTemplateStrings) rules.push({ cls: 'me-hl-string', re: /`(?:\\.|[^`\\])*`/y }); // Numbers rules.push({ cls: 'me-hl-number', re: /\b0x[\da-fA-F_]+\b|\b\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?\b/y }); // Keywords if (lang.keywords.length) { rules.push({ cls: 'me-hl-keyword', re: new RegExp(`\\b(?:${lang.keywords.map(escapeRe).join('|')})\\b`, 'y') }); } // Builtins / globals if (lang.builtins.length) { rules.push({ cls: 'me-hl-builtin', re: new RegExp(`\\b(?:${lang.builtins.map(escapeRe).join('|')})\\b`, 'y') }); } // Function calls: identifier immediately followed by ( rules.push({ cls: 'me-hl-function', re: /[A-Za-z_$][\w$]*(?=\s*\()/y }); return rules; }; const tokenize = (text, rules) => { let out = ''; let pos = 0; const len = text.length; while (pos < len) { let matched = false; for (const rule of rules) { rule.re.lastIndex = pos; const m = rule.re.exec(text); if (m && m.index === pos && m[0].length > 0) { out += `${m[0]}`; pos += m[0].length; matched = true; break; } } if (!matched) { out += text[pos]; pos++; } } return out; }; // ============ Rule cache ============ // 规则正则与语言定义绑定且只读(tokenize 同步执行,每次先重置 lastIndex, // 共享正则实例在单线程环境安全)。避免每次 highlight 调用重建正则。 const ruleCache = new Map(); const getRules = (lang) => { let rules = ruleCache.get(lang); if (!rules) { const def = LANGUAGES[lang]; rules = def ? buildRules(def) : []; ruleCache.set(lang, rules); } return rules; }; // ============ Language definitions ============ const LANGUAGES = { javascript: { keywords: ['async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'from', 'function', 'get', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'of', 'return', 'set', 'static', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield'], builtins: ['console', 'document', 'window', 'globalThis', 'Math', 'JSON', 'Promise', 'Object', 'Array', 'String', 'Number', 'Boolean', 'Map', 'Set', 'Symbol', 'RegExp', 'Date', 'Error', 'parseInt', 'parseFloat', 'isNaN', 'setTimeout', 'setInterval', 'fetch', 'require', 'module', 'exports', 'process', 'Buffer'], hasBlocks: true, hasTemplateStrings: true, }, typescript: { keywords: ['abstract', 'any', 'as', 'async', 'await', 'boolean', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'declare', 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'finally', 'for', 'from', 'function', 'get', 'if', 'implements', 'import', 'in', 'infer', 'instanceof', 'interface', 'is', 'keyof', 'let', 'namespace', 'never', 'new', 'of', 'override', 'private', 'protected', 'public', 'readonly', 'return', 'satisfies', 'set', 'static', 'string', 'super', 'switch', 'symbol', 'this', 'throw', 'try', 'type', 'typeof', 'undefined', 'unique', 'unknown', 'var', 'void', 'while', 'with', 'yield'], builtins: ['console', 'document', 'window', 'globalThis', 'Math', 'JSON', 'Promise', 'Object', 'Array', 'String', 'Number', 'Boolean', 'Map', 'Set', 'Symbol', 'RegExp', 'Date', 'Error', 'parseInt', 'parseFloat', 'setTimeout', 'setInterval', 'fetch', 'require', 'module', 'exports', 'process', 'Buffer'], hasBlocks: true, hasTemplateStrings: true, }, jsx: { keywords: ['async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', 'do', 'else', 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'import', 'in', 'instanceof', 'let', 'new', 'of', 'return', 'static', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', 'useState', 'useEffect', 'useRef', 'useMemo', 'useCallback'], builtins: ['console', 'document', 'window', 'Math', 'JSON', 'Promise', 'Object', 'Array', 'String', 'Number', 'Map', 'Set', 'React'], hasBlocks: true, hasTemplateStrings: true, }, tsx: { keywords: ['abstract', 'any', 'as', 'async', 'await', 'boolean', 'break', 'case', 'catch', 'class', 'const', 'continue', 'default', 'declare', 'do', 'else', 'enum', 'export', 'extends', 'finally', 'for', 'from', 'function', 'if', 'implements', 'import', 'in', 'infer', 'instanceof', 'interface', 'is', 'keyof', 'let', 'namespace', 'never', 'new', 'of', 'override', 'private', 'protected', 'public', 'readonly', 'return', 'static', 'string', 'super', 'switch', 'symbol', 'this', 'throw', 'try', 'type', 'typeof', 'undefined', 'unknown', 'var', 'void', 'while', 'with', 'yield', 'useState', 'useEffect', 'useRef'], builtins: ['console', 'document', 'window', 'Math', 'JSON', 'Promise', 'Object', 'Array', 'String', 'Number', 'Map', 'Set', 'React'], hasBlocks: true, hasTemplateStrings: true, }, python: { keywords: ['and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'False', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'None', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'True', 'try', 'while', 'with', 'yield'], builtins: ['print', 'len', 'range', 'type', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', 'bool', 'enumerate', 'zip', 'map', 'filter', 'sum', 'min', 'max', 'abs', 'round', 'open', 'input', 'repr', 'sorted', 'object', 'super', 'self'], hasHashComments: true, }, bash: { keywords: ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'until', 'do', 'done', 'case', 'esac', 'function', 'in', 'return', 'break', 'continue', 'export', 'local', 'readonly', 'set', 'unset', 'shift', 'source', 'alias', 'declare', 'echo', 'exit'], builtins: ['echo', 'printf', 'cd', 'pwd', 'ls', 'mkdir', 'rm', 'cp', 'mv', 'cat', 'grep', 'sed', 'awk', 'curl', 'wget', 'npm', 'node', 'git', 'docker', 'sudo', 'find', 'xargs', 'tar', 'zip', 'unzip', 'chmod', 'chown', 'touch', 'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tee', 'ps', 'kill', 'top', 'clear', 'history', 'man', 'which'], hasHashComments: true, singleQuoteStrings: false, }, shell: { hasHashComments: true, keywords: ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'in', 'function', 'return', 'break', 'continue', 'export', 'local', 'echo', 'exit'], builtins: ['echo', 'cd', 'pwd', 'ls', 'rm', 'cp', 'mv', 'cat', 'grep', 'sed', 'awk', 'curl', 'wget', 'git', 'sudo', 'find', 'chmod', 'chown', 'touch', 'head', 'tail', 'wc', 'sort', 'tee', 'ps', 'kill', 'clear', 'man', 'which'], singleQuoteStrings: false }, css: { keywords: ['important', 'inherit', 'initial', 'unset', 'none', 'auto', 'absolute', 'relative', 'fixed', 'static', 'sticky', 'flex', 'grid', 'block', 'inline', 'inline-block', 'hidden', 'visible', 'bold', 'normal', 'italic', 'solid', 'dashed', 'dotted', 'transparent', 'center', 'left', 'right', 'top', 'bottom', 'wrap', 'nowrap', 'repeat', 'cover', 'contain', 'pointer', 'auto'], builtins: ['var', 'calc', 'min', 'max', 'clamp', 'rgb', 'rgba', 'hsl', 'hsla', 'url', 'linear-gradient', 'radial-gradient', 'translate', 'scale', 'rotate', 'repeat', 'minmax', 'fit-content', 'attr', 'counter'], hasBlocks: true, singleQuoteStrings: false, }, html: { keywords: ['html', 'head', 'body', 'div', 'span', 'p', 'a', 'img', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'section', 'article', 'nav', 'header', 'footer', 'main', 'aside', 'form', 'input', 'button', 'select', 'option', 'textarea', 'label', 'script', 'style', 'link', 'meta', 'title', 'iframe', 'video', 'audio', 'canvas', 'svg', 'strong', 'em', 'code', 'pre', 'blockquote', 'br', 'hr', 'template'], builtins: [], singleQuoteStrings: false, }, json: { keywords: ['true', 'false', 'null'], builtins: [], singleQuoteStrings: false, }, yaml: { keywords: ['true', 'false', 'null', 'yes', 'no', 'on', 'off'], builtins: [], hasHashComments: true, singleQuoteStrings: false, }, markdown: { keywords: [], builtins: [], hasHashComments: true, singleQuoteStrings: false, }, md: { keywords: [], builtins: [], hasHashComments: true, singleQuoteStrings: false }, java: { keywords: ['abstract', 'boolean', 'break', 'byte', 'case', 'catch', 'char', 'class', 'const', 'continue', 'default', 'do', 'double', 'else', 'enum', 'extends', 'final', 'finally', 'float', 'for', 'goto', 'if', 'implements', 'import', 'instanceof', 'int', 'interface', 'long', 'native', 'new', 'package', 'private', 'protected', 'public', 'return', 'short', 'static', 'strictfp', 'super', 'switch', 'synchronized', 'this', 'throw', 'throws', 'transient', 'try', 'void', 'volatile', 'while', 'var'], builtins: ['System', 'String', 'Integer', 'Double', 'Math', 'Object', 'Class', 'Exception', 'Thread', 'Arrays', 'List', 'ArrayList', 'Map', 'HashMap', 'Set', 'HashSet', 'Optional', 'Stream', 'Collectors', 'Objects'], hasBlocks: true, }, go: { keywords: ['break', 'case', 'chan', 'const', 'continue', 'default', 'defer', 'else', 'fallthrough', 'for', 'func', 'go', 'goto', 'if', 'import', 'interface', 'map', 'package', 'range', 'return', 'select', 'struct', 'switch', 'type', 'var'], builtins: ['fmt', 'len', 'cap', 'make', 'new', 'append', 'copy', 'panic', 'recover', 'error', 'string', 'int', 'int64', 'uint', 'bool', 'byte', 'rune', 'float64', 'nil', 'print', 'println', 'Close', 'Error'], hasBlocks: true, singleQuoteStrings: false, }, rust: { keywords: ['as', 'async', 'await', 'break', 'const', 'continue', 'crate', 'dyn', 'else', 'enum', 'extern', 'false', 'fn', 'for', 'if', 'impl', 'in', 'let', 'loop', 'match', 'mod', 'move', 'mut', 'pub', 'ref', 'return', 'self', 'Self', 'static', 'struct', 'super', 'trait', 'true', 'type', 'unsafe', 'use', 'where', 'while'], builtins: ['println', 'print', 'eprintln', 'format', 'vec', 'String', 'str', 'Option', 'Result', 'Some', 'None', 'Ok', 'Err', 'Vec', 'Box', 'Rc', 'Arc', 'HashMap', 'Iterator', 'match'], hasBlocks: true, singleQuoteStrings: false, }, }; // ============ Public API ============ const aliasMap = { js: 'javascript', ts: 'typescript', tsx: 'tsx', jsx: 'jsx', py: 'python', sh: 'bash', shell: 'shell', zsh: 'bash', yml: 'yaml', mjs: 'javascript', cjs: 'javascript', md: 'markdown', htm: 'html', c: 'go', cpp: 'go', cs: 'java', }; /** Map a code-block language hint to the canonical language name */ const normalizeLanguage = (lang) => { const l = (lang || '').trim().toLowerCase(); if (!l) return ''; return aliasMap[l] || (LANGUAGES[l] ? l : ''); }; /** Highlight code with the built-in tokenizer. Falls back to escaped plain text. */ const highlight = (code, lang) => { const canonical = normalizeLanguage(lang); const def = LANGUAGES[canonical]; const escaped = escapeText(code); if (!def) return escaped; return tokenize(escaped, getRules(canonical)); }; /** Register or override a language definition */ const registerLanguage = (name, def) => { LANGUAGES[name.toLowerCase()] = def; ruleCache.delete(name.toLowerCase()); }; const getSupportedLanguages = () => Object.keys(LANGUAGES); /** * MetonaEditor — Type-safe, lightweight Markdown Editor * @module metona-editor * @version 0.4.1 */ const VERSION = '0.4.1'; const globalPlugins = []; // 全局插件在 afterCreate 安装:此时 DOM 已构建完成,依赖 textarea/el 的插件 // (searchReplace / shortcutHelp / imagePaste)才能正常生效。 const injectGlobalPlugins = (editor) => { if (!editor || editor.isDestroyed()) return; globalPlugins.forEach((p) => { try { editor.use(p); } catch (e) { console.error('MeEditor global plugin install error:', e); } }); }; MarkdownEditor.on('afterCreate', injectGlobalPlugins); function create(container, options = {}) { return new MarkdownEditor(container, options); } function use(plugin, options = {}) { let p = plugin; if (typeof plugin === 'string') { p = presetPlugins[plugin]; if (!p) { console.warn(`MeEditor: preset plugin "${plugin}" not found`); return api; } } if (!p || typeof p !== 'object') { console.warn('MeEditor: invalid plugin'); return api; } const merged = (options && typeof options === 'object' && Object.keys(options).length > 0) ? { ...p, ...options } : p; globalPlugins.push(merged); return api; } function on(name, fn) { return MarkdownEditor.on(name, fn); } function off(name, fn) { MarkdownEditor.off(name, fn); return api; } function setTheme(theme, _options) { if (themeUtils && typeof themeUtils.switchTheme === 'function') themeUtils.switchTheme(theme); return api; } function setLocale(locale) { if (i18nUtils && typeof i18nUtils.setCurrentLocale === 'function') i18nUtils.setCurrentLocale(locale); return api; } function destroy() { if (themeUtils) { try { themeUtils.clearThemeListeners(); } catch (_) { } try { themeUtils.unwatchSystemTheme(); } catch (_) { } } if (i18nUtils) { try { i18nUtils.clearLocaleListeners(); } catch (_) { } } if (animationUtils) { try { animationUtils.cancelAll(); } catch (_) { } } globalPlugins.length = 0; // 清理全局静态钩子(beforeCreate/afterCreate/beforeChange 等),整体复位; // 随后重建内部 afterCreate 插件注入钩子,保证 destroy 后 MeEditor.use() 仍可用。 try { MarkdownEditor._hooks.clear(); } catch (_) { } MarkdownEditor.on('afterCreate', injectGlobalPlugins); } function getStatus() { return { version: VERSION, theme: (themeUtils && typeof themeUtils.getCurrentTheme === 'function') ? themeUtils.getCurrentTheme() : 'auto', locale: (i18nUtils && typeof i18nUtils.getCurrentLocale === 'function') ? i18nUtils.getCurrentLocale() : 'zh-CN', globalPlugins: globalPlugins.map((p) => p.name || 'custom'), presetPlugins: Object.keys(presetPlugins || {}), }; } try { if (typeof themeUtils === 'object' && typeof themeUtils.initTheme === 'function') themeUtils.initTheme(); if (typeof i18nUtils === 'object' && typeof i18nUtils.initI18n === 'function') i18nUtils.initI18n(); } catch (_) { } const api = { VERSION, version: VERSION, MarkdownEditor, Editor: MarkdownEditor, create, use, on, off, setTheme, setLocale, destroy, getStatus, parseMarkdown, parseTokens, renderTokens, safeUrl, slugify, clearRenderCache, registerBlockHandler, highlight, normalizeLanguage, registerLanguage, getSupportedLanguages, themes: themeUtils, i18n: i18nUtils, animations: animationUtils, plugins: pluginUtils, presetPlugins, topologicalSort, validateConfig, createInstanceI18n, loadRemote, exportCSSVars, getCSSVariable, followExternalTheme, adoptFromParent, createInstanceTheme, DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS, }; if (typeof window !== 'undefined') { window.MeEditor = api; } export { DEFAULTS, DEFAULT_TOOLBAR, EDIT_MODES, MarkdownEditor as Editor, ICONS, MarkdownEditor, api as MeEditor, THEMES, TOOLBAR_ACTIONS, VERSION, adoptFromParent, animationUtils, api, clearRenderCache, create, createInstanceI18n, createInstanceTheme, api as default, destroy, exportCSSVars, followExternalTheme, getCSSVariable, getStatus, getSupportedLanguages, highlight, i18nUtils, loadRemote, api as meEditor, normalizeLanguage, off, on, parseMarkdown, parseTokens, pluginUtils, presetPlugins, registerBlockHandler, registerLanguage, renderTokens, safeUrl, setLocale, setTheme, slugify, themeUtils, topologicalSort, use, validateConfig }; //# sourceMappingURL=metona-editor.mjs.map