- Gutter incremental DOM update (no more full innerHTML)
- 694 tests (+10)
## Changed
- All 6 preset plugins refactored to closure-based state (no this pollution)
- shortcutHelp plugin now i18n-aware (shortcut labels follow locale)
- Context menu labels (Cut/Copy/Paste/Select All) i18n-translated
- Warm theme now has progressBg + closeHoverBg fields
---
CHANGELOG.md | 26 +++
package.json | 2 +-
src/constants.ts | 2 +
src/core.ts | 91 +++++++++-
src/i18n.ts | 1 +
src/index.ts | 2 +-
src/locales.ts | 30 ++++
src/parser.ts | 57 ++++++-
src/plugins.ts | 380 ++++++++++++++++++++++++------------------
src/styles.ts | 3 +
tests/core.test.ts | 69 ++++++++
tests/parser.test.ts | 49 +++++-
tests/plugins.test.ts | 10 +-
13 files changed, 535 insertions(+), 187 deletions(-)
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 = ``; 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 ``;
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 = `↑ ↓ .* ×
${t('replace')||'Replace'} ${t('replaceAll')||'All'}
`;
+ 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 = `↑ ↓ .* ×
${t('replace')||'Replace'} ${t('replaceAll')||'All'}
`;
- 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'} × `;
+ 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 = ``;
- 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();
});