diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca1743..2c7ef20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to MetonaEditor will be documented in this file. +## [0.2.2] - 2026-07-25 + +### Added +- **French (fr) locale**: Complete translations for all 60+ UI keys. +- **Outline scroll tracking**: Active heading auto-highlights in outline panel as you scroll. +- **Table auto-format**: `exec('formatTable')` aligns column widths in Markdown tables. +- **Code block title**: ` ```js title=hello.js ` renders a styled title bar above code blocks. +- **Escaped pipe in tables**: `\|` in table cells no longer splits columns. +- **Custom block token fallback render**: Unknown token types now render as `
` instead of empty string. +- **Gutter incremental update**: Line number gutter uses DOM diff instead of full innerHTML replacement. +- **694 tests** (up from 684). + +### Changed +- **All 6 plugins now use closure-based state** — searchReplace, imagePaste, shortcutHelp, fileSystem, autoSave refactored for thread safety. +- **shortcutHelp plugin is now i18n-aware** — shortcut descriptions follow the active locale. +- **Context menu labels (Cut/Copy/Paste/Select All) are now i18n-translated** across all 5 locales. +- **Warm theme now includes `progressBg` and `closeHoverBg`** for completeness. +- **Fenced code block regex improved** to capture optional info string attributes. + +### Fixed +- Plugin `this` context pollution eliminated for all 6 preset plugins. +- Context menu labels no longer hardcoded to English. +- Warm theme missing CSS variable fields restored. + +--- + ## [0.2.1] - 2026-07-25 ### Added diff --git a/package.json b/package.json index 5528e56..7fdc9f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metona-team/metona-editor", - "version": "0.2.1", + "version": "0.2.2", "description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.", "type": "module", "main": "dist/metona-editor.js", diff --git a/src/constants.ts b/src/constants.ts index 8eec7bd..2fc6adf 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -193,6 +193,8 @@ export const THEMES: Record = { codeText: '#78350f', accent: '#d97706', muted: '#a16207', + progressBg: 'rgba(217, 119, 6, 0.15)', + closeHoverBg: 'rgba(217, 119, 6, 0.2)', }, }; diff --git a/src/core.ts b/src/core.ts index 11b5be2..8e5d860 100644 --- a/src/core.ts +++ b/src/core.ts @@ -127,6 +127,7 @@ export class MarkdownEditor { this._initAriaLive(); this._bindEvents(); this._bindContextMenu(); + this._trackOutlineScroll(); this.textarea.value = this._value; this._pushHistory(); @@ -397,9 +398,19 @@ export class MarkdownEditor { _renderGutter(): void { if (!this.config.lineNumbers || !this.gutter) return; const lines = this._value ? this._value.split('\n').length : 1; - if (this.gutter.children.length === lines) return; - let h = ''; for (let i = 1; i <= lines; i++) h += `
${i}
`; - this.gutter.innerHTML = h; + 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(): void { @@ -500,6 +511,33 @@ export class MarkdownEditor { _updateOutline(): void { if (!this.config.outline) return; if (this._outlineTimer) clearTimeout(this._outlineTimer); this._outlineTimer = setTimeout(() => this._buildOutline(), 300); } + _trackOutlineScroll(): void { + if (!this.config.outline || !this.previewPane) return; + let ticking = false; + const onScroll = () => { + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { + ticking = false; + 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 as HTMLElement).offsetTop <= scrollTop) { + activeId = h.id; + } + }); + panel.querySelectorAll('a').forEach((a) => { + a.classList.toggle('me-outline-active', a.getAttribute('href') === '#' + activeId); + }); + }); + }; + this.previewPane.addEventListener('scroll', onScroll, { passive: true }); + this._cleanups.push(() => this.previewPane.removeEventListener('scroll', onScroll)); + } + _scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); } _pushHistory(): void { @@ -538,6 +576,7 @@ export class MarkdownEditor { 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 as Function).apply(this, args); return this; } @@ -585,6 +624,44 @@ export class MarkdownEditor { _insertImage(): void { const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd; const sel = ta.value.slice(start, end) || i18nT('image') || 'image'; const url = 'https://'; const insert = `![${sel}](${url})`; ta.value = ta.value.slice(0, start) + insert + ta.value.slice(end); ta.focus(); ta.selectionStart = start + sel.length + 4; ta.selectionEnd = ta.selectionStart + url.length; this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); } _insertTable(rows = 3, cols = 3): void { const header = Array.from({ length: cols }, (_, i) => `${i18nT('tableCols') || '列'}${i + 1}`).join(' | '); const sep = Array.from({ length: cols }, () => '---').join(' | '); let md = `| ${header} |\n| ${sep} |\n`; for (let r = 1; r < rows; r++) md += `| ${Array.from({ length: cols }, () => ' ').join(' | ')} |\n`; this._insertBlock('\n' + md); } + _formatTable(): void { + const ta = this.textarea; + const start = ta.selectionStart; + // Find table boundaries around cursor + const before = ta.value.substring(0, start); + const after = ta.value.substring(start); + const blockStart = before.lastIndexOf('\n\n'); + const blockEnd = after.indexOf('\n\n'); + const tableStart = blockStart === -1 ? 0 : blockStart + 2; + const tableEnd = blockEnd === -1 ? ta.value.length : start + blockEnd; + const tableText = ta.value.substring(tableStart, tableEnd); + const lines = tableText.split('\n').filter((l) => l.includes('|')); + if (lines.length < 2) return; // need at least header + separator + // Parse columns + const splitRow = (r: string) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/); + const allCells = lines.map(splitRow); + const colCount = Math.max(...allCells.map((c) => c.length)); + // Calculate max width per column + const colWidths: number[] = Array(colCount).fill(3); + allCells.forEach((cells) => { + cells.forEach((cell, ci) => { + colWidths[ci] = Math.max(colWidths[ci], cell.trim().length); + }); + }); + // Rebuild table + const pad = (s: string, w: number) => { const padLen = w - s.length; return s + ' '.repeat(Math.max(0, padLen)); }; + const formatted = allCells.map((cells) => { + const padded = []; + for (let ci = 0; ci < colCount; ci++) { + padded.push(pad((cells[ci] || '').trim(), colWidths[ci])); + } + return '| ' + padded.join(' | ') + ' |'; + }); + // Replace in value + ta.value = ta.value.substring(0, tableStart) + formatted.join('\n') + ta.value.substring(tableEnd); + this._value = ta.value; this._pushHistory(); this._render(); this._emit('change', this._value); + } + setMode(mode: EditMode): this { if (!MODES.includes(mode) || mode === this._mode) return this; const taS = this.textarea.scrollTop; const pvS = this.previewPane.scrollTop; this._mode = mode; this.bodyEl.className = `me-body me-mode-${mode}`; this._updateModeButtons(); if (mode !== 'edit') this._render(); this.textarea.scrollTop = taS; this.previewPane.scrollTop = pvS; this._emit('modeChange', mode); this._announce(`${i18nT(mode) || mode} mode`); if (typeof this.config.onModeChange === 'function') { try { this.config.onModeChange(mode, this); } catch (e) { console.error(e); } } return this; } getMode(): EditMode { return this._mode; } @@ -771,10 +848,10 @@ export class MarkdownEditor { { label: i18nT('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() }, { label: i18nT('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() }, { sep: true }, - { label: 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection }, - { label: 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection }, - { label: 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly }, - { label: 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' }, + { label: i18nT('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection }, + { label: i18nT('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection }, + { label: i18nT('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly }, + { label: i18nT('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' }, ]; const items = [...defaultItems]; if (this._contextMenuItems.length) { diff --git a/src/i18n.ts b/src/i18n.ts index 129b57b..0bb1b19 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -265,6 +265,7 @@ export const presetLocales = { 'en-US': { name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', translations: LOCALES['en-US'] }, ja: { name: '日本語', nativeName: '日本語', direction: 'ltr', translations: (LOCALES as any)['ja'] }, ko: { name: '한국어', nativeName: '한국어', direction: 'ltr', translations: (LOCALES as any)['ko'] }, + fr: { name: 'Français', nativeName: 'Français', direction: 'ltr', translations: (LOCALES as any)['fr'] }, }; export const i18nUtils = createI18nManager(); diff --git a/src/index.ts b/src/index.ts index 2a1532c..1b1afd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ import { animationUtils } from './animations'; import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants'; import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants'; -const VERSION = '0.2.1'; +const VERSION = '0.2.2'; const globalPlugins: any[] = []; diff --git a/src/locales.ts b/src/locales.ts index 3e8bf34..c0d48e1 100644 --- a/src/locales.ts +++ b/src/locales.ts @@ -29,6 +29,7 @@ export const LOCALES: Record> = { warning: '警告', info: '信息', loading: '加载中...', retry: '重试', renderError: '渲染失败', outline: '大纲', + cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选', }, 'en-US': { bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough', @@ -54,6 +55,7 @@ export const LOCALES: Record> = { warning: 'Warning', info: 'Info', loading: 'Loading...', retry: 'Retry', renderError: 'Render failed', outline: 'Outline', + cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All', }, ja: { bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線', @@ -79,6 +81,7 @@ export const LOCALES: Record> = { warning: '警告', info: '情報', loading: '読み込み中...', retry: '再試行', renderError: 'レンダリング失敗', outline: 'アウトライン', + cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択', }, ko: { bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', @@ -104,5 +107,32 @@ export const LOCALES: Record> = { warning: '경고', info: '정보', loading: '로딩 중...', retry: '재시도', renderError: '렌더링 실패', outline: '개요', + cut: '잘라내기', copy: '복사', paste: '붙여넣기', selectAll: '전체 선택', + }, + fr: { + bold: 'Gras', italic: 'Italique', underline: 'Souligné', strikethrough: 'Barré', + h1: 'Titre 1', h2: 'Titre 2', h3: 'Titre 3', quote: 'Citation', code: 'Code', + link: 'Lien', image: 'Image', table: 'Tableau', ul: 'Liste à puces', ol: 'Liste numérotée', + indent: 'Indenter', outdent: 'Désindenter', hr: 'Ligne horizontale', undo: 'Annuler', redo: 'Rétablir', + edit: 'Éditer', split: 'Fractionné', preview: 'Aperçu', fullscreen: 'Plein écran', + fullscreenExit: 'Quitter plein écran', theme: 'Thème', light: 'Clair', dark: 'Sombre', + auto: 'Auto', warm: 'Chaud', wordCount: 'Nombre de mots', characters: 'Caractères', + words: 'Mots', lines: 'Lignes', readingTime: 'Lecture', minutes: 'min', + placeholder: 'Commencez à taper Markdown...', empty: 'Aucun contenu', copied: 'Copié', + copyContent: 'Copier le contenu', copyHTML: 'Copier HTML', copySuccess: 'Copié avec succès', + copyFailed: 'Échec de la copie', clearContent: 'Effacer le contenu', clearConfirm: 'Effacer tout le contenu ?', + linkPlaceholder: 'Entrez l\'URL du lien', imagePlaceholder: 'Entrez l\'URL de l\'image', + altPlaceholder: 'Entrez le texte alternatif', tableRows: 'Lignes', tableCols: 'Colonnes', + confirm: 'Confirmer', cancel: 'Annuler', exportMarkdown: 'Exporter Markdown', + exportHTML: 'Exporter HTML', search: 'Rechercher', replace: 'Remplacer', replaceAll: 'Tout remplacer', + searchPlaceholder: 'Rechercher', replacePlaceholder: 'Remplacer par', + findNext: 'Suivant', findPrev: 'Précédent', matchCase: 'Respecter la casse', + wholeWord: 'Mot entier', close: 'Fermer', open: 'Ouvrir', save: 'Enregistrer', + saved: 'Enregistré', saving: 'Enregistrement...', delete: 'Supprimer', confirmDelete: 'Confirmer la suppression ?', + unsavedChanges: 'Modifications non enregistrées', error: 'Erreur', success: 'Succès', + warning: 'Avertissement', info: 'Info', loading: 'Chargement...', retry: 'Réessayer', + renderError: 'Échec du rendu', + outline: 'Plan', + cut: 'Couper', copy: 'Copier', paste: 'Coller', selectAll: 'Tout sélectionner', }, }; diff --git a/src/parser.ts b/src/parser.ts index 47b2188..eb9b1f6 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -46,7 +46,7 @@ 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*$/; +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/; @@ -175,11 +175,27 @@ registerBlockHandler({ name: 'blank', priority: 0, parse: (_lines, i) => ({ token: null, newIndex: i + 1 }), }); registerBlockHandler({ name: 'fencedCode', priority: 1, - test: (line) => line.match(RE_FENCE_START), + 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: string = match[2][0]; const fenceLen: number = match[2].length; const lang: string = match[3] || ''; + const rest: string = (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: Record = {}; + 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: string[] = []; let j = i + 1; while (j < lines.length) { @@ -188,7 +204,7 @@ registerBlockHandler({ name: 'fencedCode', priority: 1, codeLines.push(lines[j]); j++; } if (j < lines.length) j++; - return { token: { type: 'code', lang, content: codeLines.join('\n') }, newIndex: j }; + return { token: { type: 'code', lang, content: codeLines.join('\n'), attrs }, newIndex: j }; }, }); registerBlockHandler({ name: 'indentedCode', priority: 2, @@ -473,7 +489,7 @@ const renderToken = (tok: Token, env: RenderEnv, footnotes: Record${cachedRenderInline(tok.text, env)}

`; case 'hr': return '
'; case 'quote': return `
${parseMarkdown(tok.content, env)}
`; - case 'code': return renderCode(tok.content, tok.lang, env); + case 'code': return renderCode(tok.content, tok.lang, env, tok.attrs); case 'ul': return `
    ${tok.items.map((it: ListItem) => renderListItem(it, env)).join('')}
`; case 'ol': { const sn = tok.start || 1; @@ -489,7 +505,12 @@ const renderToken = (tok: Token, env: RenderEnv, footnotes: Record'; } case 'mathBlock': return `
${escapeHTML(tok.content)}
`; - default: return ''; + 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}
`; + } } }; @@ -506,20 +527,38 @@ const renderListItem = (it: ListItem, env: RenderEnv, idx?: number): string => { return h + ''; }; -const renderCode = (code: string, lang: string, env: RenderEnv): string => { +const renderCode = (code: string, lang: string, env: RenderEnv, attrs?: Record): string => { if (lang === 'mermaid') return `
${escapeHTML(code)}
`; const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : ''; + const titleHtml = attrs?.title ? `
${escapeHTML(attrs.title)}
` : ''; if (env.highlight && typeof env.highlight === 'function' && lang) { try { const highlighted = env.highlight(code, lang); - if (typeof highlighted === 'string') return `
${highlighted}
`; + if (typeof highlighted === 'string') return `${titleHtml}
${highlighted}
`; } catch (e) { console.error('MeEditor highlight error:', e); } } - return `
${escapeHTML(code)}
`; + return `${titleHtml}
${escapeHTML(code)}
`; }; const renderTable = (tok: Token, env: RenderEnv): string => { - const splitRow = (r: string) => r.replace(/^\s*\|?\s*|\s*\|?\s*$/g, '').split(/\s*\|\s*/); + // Split respecting backslash-escaped pipes + const splitRow = (r: string) => { + const cells: string[] = []; + 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: string[] = tok.align || []; const as = (i: number) => align[i] && align[i] !== 'left' ? ` style="text-align:${align[i]}"` : ''; diff --git a/src/plugins.ts b/src/plugins.ts index 5bb1292..c2d9002 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -207,148 +207,177 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em; }; const searchReplacePlugin: Plugin = { - name: 'searchReplace', version: '0.1.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80, + name: 'searchReplace', version: '0.2.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; - this._injectStyle!(); - (this as any)._onKeydown = (e: KeyboardEvent) => { + // Inject style once globally + if (!document.getElementById('me-search-style')) { + const style = document.createElement('style'); style.id = 'me-search-style'; + style.textContent = `.me-search{position:absolute;top:8px;right:12px;z-index:20;display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);font-size:13px;min-width:280px}.me-search-row{display:flex;gap:4px;align-items:center}.me-search input{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));border-radius:4px;background:var(--md-textarea-bg,#fff);color:var(--md-text,#1f2937);font-size:13px}.me-search input:focus{outline:none;border-color:var(--md-accent,#3b82f6)}.me-search button{padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));background:var(--md-bg,#fff);color:var(--md-text,#1f2937);border-radius:4px;cursor:pointer;font-size:12px;line-height:1}.me-search button:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-count{color:var(--md-muted,#6b7280);font-size:12px;min-width:60px;text-align:center}.me-search .me-search-close{padding:2px 6px}`; + document.head.appendChild(style); + } + + const state = { + _panel: null as HTMLElement | null, + _regexMode: false, + _cleanup: null as (() => void) | null, + }; + + const _updateReplaceVisible = () => { + if (!state._panel) return; + const show = state._panel.dataset.replace === '1'; + const row = state._panel.querySelector('.me-search-replace-row') as HTMLElement; + 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?: boolean) => { + if (state._panel) { + state._panel.dataset.replace = showReplace ? '1' : '0'; + _updateReplaceVisible(); + const inp = state._panel.querySelector('.me-search-find') as HTMLInputElement; + if (inp) { inp.focus(); inp.select(); } + return; + } + const selected = editor.textarea.value.substring(editor.textarea.selectionStart, editor.textarea.selectionEnd); + const panel = document.createElement('div'); panel.className = 'me-search'; + panel.dataset.replace = showReplace ? '1' : '0'; + panel.innerHTML = `
`; + editor.el.appendChild(panel); state._panel = panel; + _updateReplaceVisible(); + state._regexMode = false; + const fi = panel.querySelector('.me-search-find') as HTMLInputElement; + const ri = panel.querySelector('.me-search-replace') as HTMLInputElement; + const ce = panel.querySelector('.me-search-count') as HTMLElement; + const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement; + regexBtn.addEventListener('click', () => { + state._regexMode = !state._regexMode; + regexBtn.classList.toggle('me-active', state._regexMode); + lastIdxs = findAll(); + }); + const findAll = () => { + const q = fi.value; if (!q) { ce.textContent = ''; return []; } + const idxs: number[] = []; let from = 0; + if (state._regexMode) { + try { + const re = new RegExp(q, 'g'); let m: RegExpExecArray | null; + while ((m = re.exec(editor.textarea.value)) !== null) { + idxs.push(m.index); + if (m[0].length === 0) re.lastIndex++; + } + } catch (_) { ce.textContent = 'err'; return []; } + } else { + while (true) { const idx = editor.textarea.value.indexOf(q, from); if (idx === -1) break; idxs.push(idx); from = idx + q.length; } + } + ce.textContent = idxs.length ? `${idxs.length}` : '0'; + return idxs; + }; + let lastIdxs: number[] = []; + const selectAt = (idx: number, len?: number) => { + editor.textarea.focus(); + editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length)); + }; + const findNext = () => { + lastIdxs = findAll(); if (!lastIdxs.length) return; + const cur = editor.textarea.selectionEnd; + const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(cur)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length; + let next = lastIdxs.find((i: number) => i >= cur); + if (next == null) next = lastIdxs[0]; + selectAt(next, qlen); + }; + const findPrev = () => { + lastIdxs = findAll(); if (!lastIdxs.length) return; + const cur = editor.textarea.selectionStart; + const qlen = state._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(Math.max(0, cur - 100), cur + 100)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length; + let prev = -1; + for (let i = lastIdxs.length-1; i>=0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } } + if (prev === -1) prev = lastIdxs[lastIdxs.length-1]; + selectAt(prev, qlen); + }; + const replaceOne = () => { + const q = fi.value, r = ri.value; if (!q) return; + const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd; + const matchText = ta.value.substring(s, e); + if (state._regexMode) { + try { if (new RegExp(q).test(matchText)) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); } } catch (_) {} + } else if (matchText === q) { + ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); + ta.setSelectionRange(s, s + r.length); + } + editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory(); + if (typeof editor._render === 'function') editor._render(); + if (typeof editor._emit === 'function') editor._emit('change', editor._value); + findNext(); + }; + const replaceAll = () => { + const q = fi.value, r = ri.value; if (!q) return; + const ta = editor.textarea; + if (state._regexMode) { + try { ta.value = ta.value.replace(new RegExp(q, 'g'), r); } catch (_) { return; } + } else { + ta.value = ta.value.split(q).join(r); + } + ta.setSelectionRange(0,0); editor._value = ta.value; + if (typeof editor._pushHistory === 'function') editor._pushHistory(); + if (typeof editor._render === 'function') editor._render(); + if (typeof editor._emit === 'function') editor._emit('change', editor._value); + findAll(); + }; + fi.addEventListener('input', () => { lastIdxs = findAll(); }); + fi.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); } if (e.key === 'Escape') { e.preventDefault(); _close(); } }); + ri.addEventListener('keydown', (e: KeyboardEvent) => { 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: KeyboardEvent) => { const mod = e.ctrlKey || e.metaKey; if (!mod) return; const k = e.key.toLowerCase(); - if (k === 'f') { e.preventDefault(); this._open!(editor); } - else if (k === 'h') { e.preventDefault(); this._open!(editor, true); } - else if (k === 'escape' && (this as any)._panel) { this._close!(editor); } + 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', (this as any)._onKeydown); + editor.textarea.addEventListener('keydown', _onKeydown); + + // Expose for tests + (editor as any).__srState = state; + (editor as any).__srOpen = _open; + (editor as any).__srClose = _close; + (editor as any).__srKeydown = _onKeydown; }, - _injectStyle() { - if (document.getElementById('me-search-style')) return; - const style = document.createElement('style'); style.id = 'me-search-style'; - style.textContent = `.me-search{position:absolute;top:8px;right:12px;z-index:20;display:flex;flex-direction:column;gap:6px;padding:8px;background:var(--md-toolbar-bg,#f8f9fa);border:1px solid var(--md-border,rgba(0,0,0,0.1));border-radius:8px;box-shadow:0 8px 24px -8px rgba(0,0,0,0.2);font-size:13px;min-width:280px}.me-search-row{display:flex;gap:4px;align-items:center}.me-search input{flex:1;min-width:0;padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));border-radius:4px;background:var(--md-textarea-bg,#fff);color:var(--md-text,#1f2937);font-size:13px}.me-search input:focus{outline:none;border-color:var(--md-accent,#3b82f6)}.me-search button{padding:4px 8px;border:1px solid var(--md-border,rgba(0,0,0,0.15));background:var(--md-bg,#fff);color:var(--md-text,#1f2937);border-radius:4px;cursor:pointer;font-size:12px;line-height:1}.me-search button:hover{background:var(--md-accent,#3b82f6);color:#fff;border-color:var(--md-accent,#3b82f6)}.me-search .me-search-count{color:var(--md-muted,#6b7280);font-size:12px;min-width:60px;text-align:center}.me-search .me-search-close{padding:2px 6px}`; - document.head.appendChild(style); - }, - _open(editor: any, showReplace?: boolean) { - const self = this as any; - if (self._panel) { - self._panel.dataset.replace = showReplace ? '1' : '0'; - self._updateReplaceVisible(); - const inp = self._panel.querySelector('.me-search-find') as HTMLInputElement; - if (inp) { inp.focus(); inp.select(); } - return; + destroy(editor: any) { + const state = (editor as any).__srState; + if (state) { + if (state._cleanup) { state._cleanup(); state._cleanup = null; } + state._panel = null; } - 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); self._panel = panel; - self._updateReplaceVisible(); - self._regexMode = false; - const fi = panel.querySelector('.me-search-find') as HTMLInputElement; - const ri = panel.querySelector('.me-search-replace') as HTMLInputElement; - const ce = panel.querySelector('.me-search-count') as HTMLElement; - const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement; - const toggleRegex = () => { - self._regexMode = !self._regexMode; - regexBtn.classList.toggle('me-active', self._regexMode); - lastIdxs = findAll(); - }; - regexBtn.addEventListener('click', toggleRegex); - const findAll = () => { - const q = fi.value; if (!q) { ce.textContent = ''; return []; } - const idxs: number[] = []; let from = 0; - if (self._regexMode) { - try { - const re = new RegExp(q, 'g'); let m: RegExpExecArray | null; - while ((m = re.exec(editor.textarea.value)) !== null) { - idxs.push(m.index); - if (m[0].length === 0) re.lastIndex++; - } - } catch (_) { ce.textContent = 'err'; return []; } - } else { - const lower = editor.textarea.value; - while (true) { const idx = lower.indexOf(q, from); if (idx === -1) break; idxs.push(idx); from = idx + q.length; } - } - ce.textContent = idxs.length ? `${idxs.length}` : '0'; - return idxs; - }; - let lastIdxs: number[] = []; - const findNext = () => { - lastIdxs = findAll(); if (!lastIdxs.length) return; - const cur = editor.textarea.selectionEnd; - const qlen = self._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(cur)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length; - let next = lastIdxs.find((i: number) => i >= cur); - if (next == null) next = lastIdxs[0]; - selectAt(next, qlen); - }; - const findPrev = () => { - lastIdxs = findAll(); if (!lastIdxs.length) return; - const cur = editor.textarea.selectionStart; - const qlen = self._regexMode ? (() => { try { const m = new RegExp(fi.value).exec(editor.textarea.value.substring(Math.max(0, cur - 100), cur + 100)); return m ? m[0].length : fi.value.length; } catch (_) { return fi.value.length; } })() : fi.value.length; - let prev = -1; - for (let i = lastIdxs.length-1; i>=0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } } - if (prev === -1) prev = lastIdxs[lastIdxs.length-1]; - selectAt(prev, qlen); - }; - const selectAt = (idx: number, len?: number) => { - editor.textarea.focus(); - editor.textarea.setSelectionRange(idx, idx + (len || 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); - if (self._regexMode) { - try { if (new RegExp(q).test(matchText)) { ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.setSelectionRange(s, s + r.length); } } catch (_) {} - } else if (matchText === q) { - ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); - ta.setSelectionRange(s, s + r.length); - } - editor._value = ta.value; if (typeof editor._pushHistory === 'function') editor._pushHistory(); - if (typeof editor._render === 'function') editor._render(); - if (typeof editor._emit === 'function') editor._emit('change', editor._value); - findNext(); - }; - const replaceAll = () => { - const q = fi.value, r = ri.value; if (!q) return; - const ta = editor.textarea; - if (self._regexMode) { - try { ta.value = ta.value.replace(new RegExp(q, 'g'), r); } catch (_) { return; } - } else { - ta.value = ta.value.split(q).join(r); - } - ta.setSelectionRange(0,0); editor._value = ta.value; - if (typeof editor._pushHistory === 'function') editor._pushHistory(); - if (typeof editor._render === 'function') editor._render(); - if (typeof editor._emit === 'function') editor._emit('change', editor._value); - findAll(); - }; - fi.addEventListener('input', () => { lastIdxs = findAll(); }); - fi.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); e.shiftKey ? findPrev() : findNext(); } if (e.key === 'Escape') { e.preventDefault(); this._close!(editor); } }); - ri.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); replaceOne(); } if (e.key === 'Escape') { e.preventDefault(); this._close!(editor); } }); - panel.querySelector('.me-search-next')!.addEventListener('click', findNext); - panel.querySelector('.me-search-prev')!.addEventListener('click', findPrev); - panel.querySelector('.me-search-close')!.addEventListener('click', () => this._close!(editor)); - panel.querySelector('.me-search-replace-one')!.addEventListener('click', replaceOne); - panel.querySelector('.me-search-replace-all')!.addEventListener('click', replaceAll); - fi.focus(); fi.select(); - (this as any)._cleanup = () => { if (panel.parentNode) panel.parentNode.removeChild(panel); }; + const _onKeydown = (editor as any).__srKeydown; + if (_onKeydown && editor && editor.textarea) { + editor.textarea.removeEventListener('keydown', _onKeydown); + } + delete (editor as any).__srState; + delete (editor as any).__srOpen; + delete (editor as any).__srClose; + delete (editor as any).__srKeydown; }, - _updateReplaceVisible() { - const self = this as any; - if (!self._panel) return; - const show = self._panel.dataset.replace === '1'; - const row = self._panel.querySelector('.me-search-replace-row') as HTMLElement; - if (row) row.style.display = show ? 'flex' : 'none'; - }, - _close(editor: any) { if ((this as any)._cleanup) { (this as any)._cleanup(); (this as any)._cleanup = null; } (this as any)._panel = null; if (editor && editor.textarea) editor.textarea.focus(); }, - destroy(editor: any) { this._close!(editor); if ((this as any)._onKeydown && editor && editor.textarea) editor.textarea.removeEventListener('keydown', (this as any)._onKeydown); (this as any)._onKeydown = null; }, }; const imagePastePlugin: Plugin = { - name: 'imagePaste', version: '0.1.0', description: 'Paste image as base64', priority: 60, + name: 'imagePaste', version: '0.2.0', description: 'Paste image as base64', priority: 60, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; - (this as any)._onPaste = (e: ClipboardEvent) => { + const _onPaste = (e: ClipboardEvent) => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (item.type?.startsWith('image/')) { e.preventDefault(); @@ -357,69 +386,100 @@ const imagePastePlugin: Plugin = { } } }; - editor.textarea.addEventListener('paste', (this as any)._onPaste); + editor.textarea.addEventListener('paste', _onPaste); + (editor as any).__ipOnPaste = _onPaste; + }, + destroy(editor: any) { + const _onPaste = (editor as any).__ipOnPaste; + if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste); + delete (editor as any).__ipOnPaste; }, - destroy(editor) { if ((this as any)._onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', (this as any)._onPaste); (this as any)._onPaste = null; }, }; const shortcutHelpPlugin: Plugin = { - name: 'shortcutHelp', version: '0.1.0', description: 'Press ? to show shortcuts', priority: 200, + name: 'shortcutHelp', version: '0.2.0', description: 'Press ? to show shortcuts', priority: 200, install(editor) { if (!editor || !editor.textarea || typeof document === 'undefined') return; - this._injectStyle!(); - (this as any)._onKeydown = (e: KeyboardEvent) => { - if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); this._open!(editor); } - if (e.key === 'Escape' && (this as any)._panel) this._close!(); + 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: HTMLElement | null = null; + + const _close = () => { if (_panel) { _panel.remove(); _panel = null; } }; + + const _open = () => { + if (_panel) { _close(); return; } + // i18n-aware shortcut labels + const builtin: [string, string][] = [ + ['Ctrl+B', t('bold') || 'Bold'], ['Ctrl+I', t('italic') || 'Italic'], + ['Ctrl+U', t('underline') || 'Underline'], ['Ctrl+K', t('link') || 'Link'], + ['Ctrl+E', t('code') || 'Code'], ['Ctrl+1/2/3', t('h1') || 'Heading'], + ['Ctrl+Q', t('quote') || 'Quote'], ['Ctrl+Z', t('undo') || 'Undo'], + ['Ctrl+Y', t('redo') || 'Redo'], ['Ctrl+S', t('save') || 'Save'], + ['Ctrl+F', t('search') || 'Search'], ['Ctrl+H', t('replace') || 'Replace'], + ['Tab', t('indent') || 'Indent'], ['Shift+Tab', t('outdent') || 'Outdent'], + ['?', t('close') || 'Shortcuts'], + ]; + let rows = ''; builtin.forEach(([c, d]) => { rows += `${c}${d}`; }); + const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay'; + overlay.innerHTML = `

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

${rows}
`; + overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); }); + document.body.appendChild(overlay); _panel = overlay; }; - editor.textarea.addEventListener('keydown', (this as any)._onKeydown); + + const _onKeydown = (e: KeyboardEvent) => { + if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); _open(); } + if (e.key === 'Escape' && _panel) _close(); + }; + editor.textarea.addEventListener('keydown', _onKeydown); + + (editor as any).__shState = { _panel, _open, _close, _onKeydown }; }, - _injectStyle() { - if (document.getElementById('me-shortcut-style')) return; - 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); + destroy(editor: any) { + const state = (editor as any).__shState; + if (state) { if (state._panel) { state._panel.remove(); } } + const _onKeydown = (editor as any).__shState?._onKeydown; + if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown); + delete (editor as any).__shState; }, - _open(editor: any) { - if ((this as any)._panel) { this._close!(); return; } - const builtin = [['Ctrl+B','粗体'],['Ctrl+I','斜体'],['Ctrl+U','下划线'],['Ctrl+K','链接'],['Ctrl+E','行内代码'],['Ctrl+1/2/3','标题'],['Ctrl+Q','引用'],['Ctrl+Z','撤销'],['Ctrl+Y','重做'],['Ctrl+S','保存'],['Ctrl+F','查找'],['Ctrl+H','替换'],['Tab','缩进'],['Shift+Tab','反缩进'],['?','快捷键帮助']]; - let rows = ''; builtin.forEach(([c,d]) => { rows += `${c}${d}`; }); - const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay'; - overlay.innerHTML = `

⌨️ 快捷键

${rows}
`; - overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) this._close!(); }); - document.body.appendChild(overlay); (this as any)._panel = overlay; - }, - _close() { if ((this as any)._panel) { (this as any)._panel.remove(); (this as any)._panel = null; } }, - destroy(editor) { this._close!(); if ((this as any)._onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', (this as any)._onKeydown); (this as any)._onKeydown = null; }, }; const fileSystemPlugin: Plugin = { - name: 'fileSystem', version: '0.1.0', description: 'File System Access API', priority: 90, + 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 as any).showOpenFilePicker === 'function'; - (this as any)._fileHandle = null; + let _fileHandle: any = null; + editor.openFile = async (opts: any = {}) => { if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; } try { const [handle] = await (window as any).showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md','.txt','.markdown'] } }], ...opts }); - (this as any)._fileHandle = handle; const file = await handle.getFile(); const content = await file.text(); + _fileHandle = handle; const file = await handle.getFile(); const content = await file.text(); editor.setValue(content); editor._emit?.('fileOpened', { name: file.name, handle }); return { name: file.name, content, handle }; } catch (e: any) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; } }; editor.saveFile = async (opts: any = {}) => { - let handle = (this as any)._fileHandle; + let handle = _fileHandle; if (!handle || opts.saveAs) { if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; } - try { handle = await (window as any).showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' }); (this as any)._fileHandle = handle; } + try { handle = await (window as any).showSaveFilePicker({ types: [{ accept: { 'text/markdown': ['.md'] } }], suggestedName: opts.name || 'document.md' }); _fileHandle = handle; } catch (e: any) { 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) { (this as any)._fileHandle = null; if (!opts.saveAs) return editor.saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; } + catch (e) { _fileHandle = null; if (!opts.saveAs) return editor.saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; } }; editor.saveFileAs = (name?: string) => editor.saveFile({ saveAs: true, name }); - editor.getFileHandle = () => (this as any)._fileHandle; + editor.getFileHandle = () => _fileHandle; + (editor as any).__fsCleanup = { getHandle: () => _fileHandle }; + }, + destroy(editor: any) { + if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; } + delete (editor as any).__fsCleanup; }, - destroy(editor) { (this as any)._fileHandle = null; if (editor) { delete editor.openFile; delete editor.saveFile; delete editor.saveFileAs; delete editor.getFileHandle; } }, }; // ============ Preset plugins table ============ diff --git a/src/styles.ts b/src/styles.ts index 0e50665..d7fc521 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -69,6 +69,8 @@ const generateCSS = (): string => { .me-preview hr{border:0;height:1px;background:var(--md-border);margin:1.6em 0} .me-preview code{font-family:var(--md-mono);font-size:.88em;padding:.15em .4em;background:var(--md-code-bg);color:var(--md-code-text);border-radius:4px} .me-preview pre{margin:.9em 0;padding:14px 16px;background:var(--md-code-bg);border-radius:8px;overflow-x:auto;border:1px solid var(--md-border)} +.me-code-title{font-family:var(--md-mono);font-size:.85em;padding:8px 14px;background:var(--md-border);border-radius:8px 8px 0 0;color:var(--md-text);margin:.9em 0 -0.9em;font-weight:600} +.me-code-title+pre{margin-top:0;border-radius:0 0 8px 8px;border-top:none} .me-preview pre code{padding:0;background:transparent;color:var(--md-code-text);font-size:.9em;line-height:1.6;border-radius:0} .me-preview img{max-width:100%;height:auto;border-radius:6px;vertical-align:middle} .me-table-wrap{overflow-x:auto;margin:.9em 0} @@ -120,6 +122,7 @@ const generateCSS = (): string => { .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} diff --git a/tests/core.test.ts b/tests/core.test.ts index dd60357..62ff0aa 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1947,3 +1947,72 @@ describe('MarkdownEditor - v0.2.1 分隔条持久化', () => { ed.destroy(); }); }); + +// ============ v0.2.2 formatTable 测试 ============ + +describe('MarkdownEditor - v0.2.2 formatTable', () => { + test('exec formatTable 对齐表格列', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: '| a | b |\n| --- | --- |\n| x | yyy |' }); + ed.textarea.setSelectionRange(10, 10); // 光标在表格内 + ed.exec('formatTable'); + const v = ed.getValue(); + // Both columns padded to max width + expect(v).toMatch(/\| a\s+\| b\s+\|/); + expect(v).toMatch(/\| x\s+\| yyy\s+\|/); + ed.destroy(); + }); + + test('exec formatTable 无表格时不变', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: 'just some text' }); + ed.exec('formatTable'); + expect(ed.getValue()).toBe('just some text'); + ed.destroy(); + }); +}); + +// ============ v0.2.2 gutter 增量更新 ============ + +describe('MarkdownEditor - v0.2.2 gutter 增量更新', () => { + test('行数减少时 gutter 正确移除多余行', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: 'a\nb\nc', lineNumbers: true }); + expect(ed.gutter.children.length).toBe(3); + ed.setValue('a'); + expect(ed.gutter.children.length).toBe(1); + ed.destroy(); + }); + + test('行数增加时 gutter 正确追加', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: 'a', lineNumbers: true }); + expect(ed.gutter.children.length).toBe(1); + ed.setValue('a\nb\nc\nd'); + expect(ed.gutter.children.length).toBe(4); + ed.destroy(); + }); +}); + +// ============ v0.2.2 大纲滚动跟踪 ============ + +describe('MarkdownEditor - v0.2.2 大纲滚动跟踪', () => { + test('_trackOutlineScroll 注册滚动监听', () => { + document.body.innerHTML = ''; + const c = document.createElement('div'); + document.body.appendChild(c); + const ed = new MarkdownEditor(c, { value: '# H1\n## H2\n### H3', outline: true, mode: 'split' }); + ed._buildOutline(); + // 滚动 preview 应不抛错 + expect(() => ed.previewPane.dispatchEvent(new Event('scroll'))).not.toThrow(); + ed.destroy(); + }); +}); diff --git a/tests/parser.test.ts b/tests/parser.test.ts index c6b4d14..d607431 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -1022,10 +1022,10 @@ describe('parseMarkdown - 覆盖率:链接引用回退', () => { }); describe('parseMarkdown - 覆盖率:自定义 token default 渲染', () => { - test('未识别 token type 返回空字符串', () => { + test('未识别 token type 渲染为回退 div', () => { const tokens = [{ type: 'unknownType', text: 'whatever' }]; const html = renderTokens(tokens); - expect(html).toBe(''); + expect(html).toContain('me-block-unknownType'); }); }); @@ -1225,3 +1225,48 @@ describe('parseMarkdown - v0.2.1 引用链接容错', () => { expect(() => renderTokens([{ type: 'paragraph', text: '[x][bad]' }], { refs: { bad: 'not-json' } })).not.toThrow(); }); }); + +// ============ v0.2.2 表格转义 ============ + +describe('parseMarkdown - v0.2.2 表格转义竖线', () => { + test('表格单元格内 \\| 不被分割', () => { + const md = '| Name | Value |\n| --- | --- |\n| a\\|b | c |'; + const html = parseMarkdown(md); + // a\|b 应该作为单个单元格内容 + expect(html).toContain('a|b'); + }); +}); + +// ============ v0.2.2 代码块 info string ============ + +describe('parseMarkdown - v0.2.2 代码块属性', () => { + test('代码块 title 属性被提取', () => { + const { tokens } = parseTokens('```js title=hello.js\nconsole.log(1)\n```'); + const codeToken = tokens.find((t) => t.type === 'code'); + expect(codeToken).toBeDefined(); + expect(codeToken.lang).toBe('js'); + expect(codeToken.attrs).toBeDefined(); + expect(codeToken.attrs.title).toBe('hello.js'); + }); + + test('代码块 title 渲染为标题栏', () => { + const html = parseMarkdown('```js title=demo.js\nvar x = 1;\n```'); + expect(html).toContain('me-code-title'); + expect(html).toContain('demo.js'); + }); + + test('无 title 时不渲染标题栏', () => { + const html = parseMarkdown('```js\nvar x = 1;\n```'); + expect(html).not.toContain('me-code-title'); + }); +}); + +// ============ v0.2.2 自定义 token 回退渲染 ============ + +describe('parseMarkdown - v0.2.2 自定义 token 渲染', () => { + test('自定义 token 包含 content 时渲染内容', () => { + const html = renderTokens([{ type: 'customAlert', content: '**Important!**' }]); + expect(html).toContain('me-block-customAlert'); + expect(html).toContain('Important!'); + }); +}); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts index f4572a7..b2207d2 100644 --- a/tests/plugins.test.ts +++ b/tests/plugins.test.ts @@ -820,17 +820,13 @@ describe('零散分支补全', () => { document.body.appendChild(c); const ed = new MarkdownEditor(c, { value: 'hello world' }); ed.use(presetPlugins.searchReplace); - const plugin = ed.getPlugins().find((p) => p.name === 'searchReplace'); // Ctrl+F 打开面板 ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true })); expect(ed.el.querySelector('.me-search')).not.toBeNull(); - expect(plugin._panel).toBeDefined(); - // spy _close 验证 textarea 上 Ctrl+Escape 分支被触发 - // 注:plugins.js 中 escape 分支在 if (!mod) return 之后,必须带 Ctrl/Meta - const closeSpy = jest.spyOn(plugin, '_close'); + expect((ed as any).__srState._panel).toBeDefined(); + // Ctrl+Escape 关闭面板(带 Ctrl 才会进入分支) ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true })); - expect(closeSpy).toHaveBeenCalled(); - closeSpy.mockRestore(); + expect(ed.el.querySelector('.me-search')).toBeNull(); ed.destroy(); });