diff --git a/.gitignore b/.gitignore index f08e9e0..36fd51b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ node_modules/ -dist/ coverage/ .DS_Store .zcode/ diff --git a/dist/metona-editor.cjs b/dist/metona-editor.cjs new file mode 100644 index 0000000..e79e2b3 --- /dev/null +++ b/dist/metona-editor.cjs @@ -0,0 +1,5188 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +/** + * MetonaEditor Utils — utility functions + * @module utils + * @version 0.2.0 + */ +/** Generate a unique ID */ +const generateId = () => { + return 'me-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8); +}; +/** HTML-escape a string */ +const escapeHTML = (s) => { + const str = String(s == null ? '' : s); + if (typeof document === 'undefined') { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +}; +/** 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
${cachedRenderInline(tok.text, env)}
`; + case 'hr': return '${parseMarkdown(tok.content, env)}`; + case 'code': return renderCode(tok.content, tok.lang, env, tok.attrs); + case 'ul': return `
${escapeHTML(code)}${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 = '| ${cachedRenderInline(hd, env)} | `).join(''); + h += '
|---|
| ${cachedRenderInline(c, env)} | `).join('')}
${escapeHTML(code.content)}`;
+ });
+ s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m);
+ s = s.replace(/ \n/g, '${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 += `${cachedRenderInline(tok.text, env)}
`; + case 'hr': return '${parseMarkdown(tok.content, env)}`; + case 'code': return renderCode(tok.content, tok.lang, env, tok.attrs); + case 'ul': return `
${escapeHTML(code)}${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 = '| ${cachedRenderInline(hd, env)} | `).join(''); + h += '
|---|
| ${cachedRenderInline(c, env)} | `).join('')}
${escapeHTML(code.content)}`;
+ });
+ s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m);
+ s = s.replace(/ \n/g, '${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 += `${G(e.text,n)}
`;case"hr":return"${se(e.content,n)}`;case"code":return ue(e.content,e.lang,n,e.attrs);case"ul":return`
${t(e)}${t}`}catch(e){}return`${a}${t(e)}`},pe=(e,t)=>{const n=e=>{const t=[];let n="",r=!1;for(let i=0;i| ${G(e,t)} | `).join(""),s+="
|---|
| ${G(e,t)} | `).join("")}
${t(i.content)}`:e}),i=i.replace(/:([\w+-]+):/g,(e,t)=>U[t]||e),i=i.replace(/ \n/g,"${this.t("renderError")}: ${t(n.message)}
`}if("function"==typeof this.config.sanitize)try{e=this.config.sanitize(e)}catch(e){}this.previewEl.innerHTML=e,this._renderGutter(),bt.trigger("afterRender",this),this._emit("afterRender",this)}_renderGutter(){if(!this.config.lineNumbers||!this.gutter)return;const e=this.getStats(),t=e.lines>0?e.lines:1,n=this.gutter.children.length;if(n!==t)if(n${cachedRenderInline(tok.text, env)}
`; + case 'hr': return '${parseMarkdown(tok.content, env)}`; + case 'code': return renderCode(tok.content, tok.lang, env, tok.attrs); + case 'ul': return `
${escapeHTML(code)}${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 = '| ${cachedRenderInline(hd, env)} | `).join(''); + h += '
|---|
| ${cachedRenderInline(c, env)} | `).join('')}
${escapeHTML(code.content)}`;
+ });
+ s = s.replace(/:([\w+-]+):/g, (m, name) => EMOJI_MAP[name] || m);
+ s = s.replace(/ \n/g, '${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 += `