feat: v0.2.2 — plugin refactor, i18n, outline tracking, formatTable, code title, fr locale
CI / test (18.x) (push) Canceled after 0s
CI / test (20.x) (push) Canceled after 0s
CI / test (22.x) (push) Canceled after 0s

## Added
- French (fr) locale with 60+ translations
- Outline panel scroll tracking with active heading highlight
- exec('formatTable') for auto-aligning Markdown table columns
- Code block title rendering via ```js title=hello.js```
- Escaped pipe \| support in table cells
- Custom block token fallback rendering as <div>
- 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
This commit is contained in:
2026-07-25 09:26:54 +08:00
parent aa9f5f220e
commit de545d1da6
13 changed files with 535 additions and 187 deletions
+26
View File
@@ -2,6 +2,32 @@
All notable changes to MetonaEditor will be documented in this file. 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 `<div class="me-block-{type}">` 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 ## [0.2.1] - 2026-07-25
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@metona-team/metona-editor", "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.", "description": "Type-safe, lightweight, zero-dependency Markdown Editor. Desktop-first. React-free. Single-file bundle.",
"type": "module", "type": "module",
"main": "dist/metona-editor.js", "main": "dist/metona-editor.js",
+2
View File
@@ -193,6 +193,8 @@ export const THEMES: Record<string, ThemeConfig | string> = {
codeText: '#78350f', codeText: '#78350f',
accent: '#d97706', accent: '#d97706',
muted: '#a16207', muted: '#a16207',
progressBg: 'rgba(217, 119, 6, 0.15)',
closeHoverBg: 'rgba(217, 119, 6, 0.2)',
}, },
}; };
+84 -7
View File
@@ -127,6 +127,7 @@ export class MarkdownEditor {
this._initAriaLive(); this._initAriaLive();
this._bindEvents(); this._bindEvents();
this._bindContextMenu(); this._bindContextMenu();
this._trackOutlineScroll();
this.textarea.value = this._value; this.textarea.value = this._value;
this._pushHistory(); this._pushHistory();
@@ -397,9 +398,19 @@ export class MarkdownEditor {
_renderGutter(): void { _renderGutter(): void {
if (!this.config.lineNumbers || !this.gutter) return; if (!this.config.lineNumbers || !this.gutter) return;
const lines = this._value ? this._value.split('\n').length : 1; const lines = this._value ? this._value.split('\n').length : 1;
if (this.gutter.children.length === lines) return; const current = this.gutter.children.length;
let h = ''; for (let i = 1; i <= lines; i++) h += `<div class="me-gutter-line">${i}</div>`; if (current === lines) return;
this.gutter.innerHTML = h; if (current < lines) {
// Add new line numbers
let h = '';
for (let i = current + 1; i <= lines; i++) h += `<div class="me-gutter-line">${i}</div>`;
this.gutter.insertAdjacentHTML('beforeend', h);
} else {
// Remove excess line numbers
while (this.gutter.children.length > lines) {
this.gutter.removeChild(this.gutter.lastChild!);
}
}
} }
_updateCurrentLine(): void { _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); } _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); } _scheduleHistory(): void { if (this._historyTimer) clearTimeout(this._historyTimer); this._historyTimer = setTimeout(() => this._pushHistory(), this.config.historyDebounce || 400); }
_pushHistory(): void { _pushHistory(): void {
@@ -538,6 +576,7 @@ export class MarkdownEditor {
edit: () => this.setMode('edit'), split: () => this.setMode('split'), edit: () => this.setMode('edit'), split: () => this.setMode('split'),
preview: () => this.setMode('preview'), fullscreen: () => this.toggleFullscreen(), preview: () => this.setMode('preview'), fullscreen: () => this.toggleFullscreen(),
zen: () => this.toggleZen(), wordwrap: () => this.toggleWordWrap(), zen: () => this.toggleZen(), wordwrap: () => this.toggleWordWrap(),
formatTable: () => this._formatTable(),
}; };
const fn = actions[action]; const fn = actions[action];
if (fn) { (fn as Function).apply(this, args); return this; } 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); } _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); } _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; } 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; } 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('undo') || 'Undo', action: 'undo', shortcut: 'Ctrl+Z', disabled: !this.canUndo() },
{ label: i18nT('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() }, { label: i18nT('redo') || 'Redo', action: 'redo', shortcut: 'Ctrl+Y', disabled: !this.canRedo() },
{ sep: true }, { sep: true },
{ label: 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection }, { label: i18nT('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
{ label: 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection }, { label: i18nT('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
{ label: 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly }, { label: i18nT('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
{ label: 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' }, { label: i18nT('selectAll') || 'Select All', action: 'selectAll', shortcut: 'Ctrl+A' },
]; ];
const items = [...defaultItems]; const items = [...defaultItems];
if (this._contextMenuItems.length) { if (this._contextMenuItems.length) {
+1
View File
@@ -265,6 +265,7 @@ export const presetLocales = {
'en-US': { name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', translations: LOCALES['en-US'] }, 'en-US': { name: 'English (US)', nativeName: 'English (US)', direction: 'ltr', translations: LOCALES['en-US'] },
ja: { name: '日本語', nativeName: '日本語', direction: 'ltr', translations: (LOCALES as any)['ja'] }, ja: { name: '日本語', nativeName: '日本語', direction: 'ltr', translations: (LOCALES as any)['ja'] },
ko: { name: '한국어', nativeName: '한국어', direction: 'ltr', translations: (LOCALES as any)['ko'] }, 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(); export const i18nUtils = createI18nManager();
+1 -1
View File
@@ -13,7 +13,7 @@ import { animationUtils } from './animations';
import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants'; import { DEFAULTS, ICONS, THEMES, EDIT_MODES, DEFAULT_TOOLBAR, TOOLBAR_ACTIONS } from './constants';
import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants'; import type { EditMode, ThemeName, ToolbarItem, EditorOptions } from './constants';
const VERSION = '0.2.1'; const VERSION = '0.2.2';
const globalPlugins: any[] = []; const globalPlugins: any[] = [];
+30
View File
@@ -29,6 +29,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
warning: '警告', info: '信息', loading: '加载中...', retry: '重试', warning: '警告', info: '信息', loading: '加载中...', retry: '重试',
renderError: '渲染失败', renderError: '渲染失败',
outline: '大纲', outline: '大纲',
cut: '剪切', copy: '复制', paste: '粘贴', selectAll: '全选',
}, },
'en-US': { 'en-US': {
bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough', bold: 'Bold', italic: 'Italic', underline: 'Underline', strikethrough: 'Strikethrough',
@@ -54,6 +55,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
warning: 'Warning', info: 'Info', loading: 'Loading...', retry: 'Retry', warning: 'Warning', info: 'Info', loading: 'Loading...', retry: 'Retry',
renderError: 'Render failed', renderError: 'Render failed',
outline: 'Outline', outline: 'Outline',
cut: 'Cut', copy: 'Copy', paste: 'Paste', selectAll: 'Select All',
}, },
ja: { ja: {
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線', bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
@@ -79,6 +81,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
warning: '警告', info: '情報', loading: '読み込み中...', retry: '再試行', warning: '警告', info: '情報', loading: '読み込み中...', retry: '再試行',
renderError: 'レンダリング失敗', renderError: 'レンダリング失敗',
outline: 'アウトライン', outline: 'アウトライン',
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
}, },
ko: { ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선', bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
@@ -104,5 +107,32 @@ export const LOCALES: Record<string, Record<string, string>> = {
warning: '경고', info: '정보', loading: '로딩 중...', retry: '재시도', warning: '경고', info: '정보', loading: '로딩 중...', retry: '재시도',
renderError: '렌더링 실패', renderError: '렌더링 실패',
outline: '개요', 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',
}, },
}; };
+48 -9
View File
@@ -46,7 +46,7 @@ const RE_EMPTY = /^\s*$/;
const RE_ATX = /^(#{1,6})\s+(.+?)(?:\s+#{1,6})?\s*$/; const RE_ATX = /^(#{1,6})\s+(.+?)(?:\s+#{1,6})?\s*$/;
const RE_SETEXT_H1 = /^={3,}\s*$/; const RE_SETEXT_H1 = /^={3,}\s*$/;
const RE_SETEXT_H2 = /^-{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_HR = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/;
const RE_QUOTE = /^\s{0,3}>\s?/; const RE_QUOTE = /^\s{0,3}>\s?/;
const RE_UL = /^(\s*)([-*+])\s/; const RE_UL = /^(\s*)([-*+])\s/;
@@ -175,11 +175,27 @@ registerBlockHandler({ name: 'blank', priority: 0,
parse: (_lines, i) => ({ token: null, newIndex: i + 1 }), parse: (_lines, i) => ({ token: null, newIndex: i + 1 }),
}); });
registerBlockHandler({ name: 'fencedCode', priority: 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) => { parse: (lines, i, match) => {
const fenceChar: string = match[2][0]; const fenceChar: string = match[2][0];
const fenceLen: number = match[2].length; const fenceLen: number = match[2].length;
const lang: string = match[3] || ''; 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<string, string> = {};
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[] = []; const codeLines: string[] = [];
let j = i + 1; let j = i + 1;
while (j < lines.length) { while (j < lines.length) {
@@ -188,7 +204,7 @@ registerBlockHandler({ name: 'fencedCode', priority: 1,
codeLines.push(lines[j]); j++; codeLines.push(lines[j]); j++;
} }
if (j < lines.length) 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, registerBlockHandler({ name: 'indentedCode', priority: 2,
@@ -473,7 +489,7 @@ const renderToken = (tok: Token, env: RenderEnv, footnotes: Record<string, strin
case 'paragraph': return `<p>${cachedRenderInline(tok.text, env)}</p>`; case 'paragraph': return `<p>${cachedRenderInline(tok.text, env)}</p>`;
case 'hr': return '<hr/>'; case 'hr': return '<hr/>';
case 'quote': return `<blockquote>${parseMarkdown(tok.content, env)}</blockquote>`; case 'quote': return `<blockquote>${parseMarkdown(tok.content, env)}</blockquote>`;
case 'code': return renderCode(tok.content, tok.lang, env); case 'code': return renderCode(tok.content, tok.lang, env, tok.attrs);
case 'ul': return `<ul>${tok.items.map((it: ListItem) => renderListItem(it, env)).join('')}</ul>`; case 'ul': return `<ul>${tok.items.map((it: ListItem) => renderListItem(it, env)).join('')}</ul>`;
case 'ol': { case 'ol': {
const sn = tok.start || 1; const sn = tok.start || 1;
@@ -489,7 +505,12 @@ const renderToken = (tok: Token, env: RenderEnv, footnotes: Record<string, strin
return h + '</dl>'; return h + '</dl>';
} }
case 'mathBlock': return `<div class="me-math-block">${escapeHTML(tok.content)}</div>`; case 'mathBlock': return `<div class="me-math-block">${escapeHTML(tok.content)}</div>`;
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 `<div class="${typeClass}">${content}</div>`;
}
} }
}; };
@@ -506,20 +527,38 @@ const renderListItem = (it: ListItem, env: RenderEnv, idx?: number): string => {
return h + '</li>'; return h + '</li>';
}; };
const renderCode = (code: string, lang: string, env: RenderEnv): string => { const renderCode = (code: string, lang: string, env: RenderEnv, attrs?: Record<string, string>): string => {
if (lang === 'mermaid') return `<div class="me-mermaid"><pre class="mermaid">${escapeHTML(code)}</pre></div>`; if (lang === 'mermaid') return `<div class="me-mermaid"><pre class="mermaid">${escapeHTML(code)}</pre></div>`;
const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : ''; const langClass = lang ? ` class="language-${escapeHTML(lang)}"` : '';
const titleHtml = attrs?.title ? `<div class="me-code-title">${escapeHTML(attrs.title)}</div>` : '';
if (env.highlight && typeof env.highlight === 'function' && lang) { if (env.highlight && typeof env.highlight === 'function' && lang) {
try { try {
const highlighted = env.highlight(code, lang); const highlighted = env.highlight(code, lang);
if (typeof highlighted === 'string') return `<pre><code${langClass}>${highlighted}</code></pre>`; if (typeof highlighted === 'string') return `${titleHtml}<pre><code${langClass}>${highlighted}</code></pre>`;
} catch (e) { console.error('MeEditor highlight error:', e); } } catch (e) { console.error('MeEditor highlight error:', e); }
} }
return `<pre><code${langClass}>${escapeHTML(code)}</code></pre>`; return `${titleHtml}<pre><code${langClass}>${escapeHTML(code)}</code></pre>`;
}; };
const renderTable = (tok: Token, env: RenderEnv): string => { 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 headers = splitRow(tok.header);
const align: string[] = tok.align || []; const align: string[] = tok.align || [];
const as = (i: number) => align[i] && align[i] !== 'left' ? ` style="text-align:${align[i]}"` : ''; const as = (i: number) => align[i] && align[i] !== 'left' ? ` style="text-align:${align[i]}"` : '';
+144 -84
View File
@@ -207,32 +207,40 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
}; };
const searchReplacePlugin: Plugin = { 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) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return; if (!editor || !editor.textarea || typeof document === 'undefined') return;
this._injectStyle!(); // Inject style once globally
(this as any)._onKeydown = (e: KeyboardEvent) => { if (!document.getElementById('me-search-style')) {
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); }
};
editor.textarea.addEventListener('keydown', (this as any)._onKeydown);
},
_injectStyle() {
if (document.getElementById('me-search-style')) return;
const style = document.createElement('style'); style.id = '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}`; 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); document.head.appendChild(style);
}, }
_open(editor: any, showReplace?: boolean) {
const self = this as any; const state = {
if (self._panel) { _panel: null as HTMLElement | null,
self._panel.dataset.replace = showReplace ? '1' : '0'; _regexMode: false,
self._updateReplaceVisible(); _cleanup: null as (() => void) | null,
const inp = self._panel.querySelector('.me-search-find') as HTMLInputElement; };
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(); } if (inp) { inp.focus(); inp.select(); }
return; return;
} }
@@ -240,23 +248,22 @@ const searchReplacePlugin: Plugin = {
const panel = document.createElement('div'); panel.className = 'me-search'; const panel = document.createElement('div'); panel.className = 'me-search';
panel.dataset.replace = showReplace ? '1' : '0'; panel.dataset.replace = showReplace ? '1' : '0';
panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-prev">↑</button><button class="me-search-next">↓</button><span class="me-search-count"></span><button class="me-search-regex" title="Regex">.*</button><button class="me-search-close">×</button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-replace-all">${t('replaceAll')||'All'}</button></div>`; panel.innerHTML = `<div class="me-search-row"><input type="text" class="me-search-find" placeholder="${t('searchPlaceholder')||'Find'}" value="${escapeAttr(selected)}"/><button class="me-search-prev">↑</button><button class="me-search-next">↓</button><span class="me-search-count"></span><button class="me-search-regex" title="Regex">.*</button><button class="me-search-close">×</button></div><div class="me-search-row me-search-replace-row"><input type="text" class="me-search-replace" placeholder="${t('replacePlaceholder')||'Replace'}"/><button class="me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
editor.el.appendChild(panel); self._panel = panel; editor.el.appendChild(panel); state._panel = panel;
self._updateReplaceVisible(); _updateReplaceVisible();
self._regexMode = false; state._regexMode = false;
const fi = panel.querySelector('.me-search-find') as HTMLInputElement; const fi = panel.querySelector('.me-search-find') as HTMLInputElement;
const ri = panel.querySelector('.me-search-replace') as HTMLInputElement; const ri = panel.querySelector('.me-search-replace') as HTMLInputElement;
const ce = panel.querySelector('.me-search-count') as HTMLElement; const ce = panel.querySelector('.me-search-count') as HTMLElement;
const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement; const regexBtn = panel.querySelector('.me-search-regex') as HTMLButtonElement;
const toggleRegex = () => { regexBtn.addEventListener('click', () => {
self._regexMode = !self._regexMode; state._regexMode = !state._regexMode;
regexBtn.classList.toggle('me-active', self._regexMode); regexBtn.classList.toggle('me-active', state._regexMode);
lastIdxs = findAll(); lastIdxs = findAll();
}; });
regexBtn.addEventListener('click', toggleRegex);
const findAll = () => { const findAll = () => {
const q = fi.value; if (!q) { ce.textContent = ''; return []; } const q = fi.value; if (!q) { ce.textContent = ''; return []; }
const idxs: number[] = []; let from = 0; const idxs: number[] = []; let from = 0;
if (self._regexMode) { if (state._regexMode) {
try { try {
const re = new RegExp(q, 'g'); let m: RegExpExecArray | null; const re = new RegExp(q, 'g'); let m: RegExpExecArray | null;
while ((m = re.exec(editor.textarea.value)) !== null) { while ((m = re.exec(editor.textarea.value)) !== null) {
@@ -265,17 +272,20 @@ const searchReplacePlugin: Plugin = {
} }
} catch (_) { ce.textContent = 'err'; return []; } } catch (_) { ce.textContent = 'err'; return []; }
} else { } else {
const lower = editor.textarea.value; while (true) { const idx = editor.textarea.value.indexOf(q, from); if (idx === -1) break; idxs.push(idx); from = idx + q.length; }
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'; ce.textContent = idxs.length ? `${idxs.length}` : '0';
return idxs; return idxs;
}; };
let lastIdxs: number[] = []; let lastIdxs: number[] = [];
const selectAt = (idx: number, len?: number) => {
editor.textarea.focus();
editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length));
};
const findNext = () => { const findNext = () => {
lastIdxs = findAll(); if (!lastIdxs.length) return; lastIdxs = findAll(); if (!lastIdxs.length) return;
const cur = editor.textarea.selectionEnd; 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; 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); let next = lastIdxs.find((i: number) => i >= cur);
if (next == null) next = lastIdxs[0]; if (next == null) next = lastIdxs[0];
selectAt(next, qlen); selectAt(next, qlen);
@@ -283,21 +293,17 @@ const searchReplacePlugin: Plugin = {
const findPrev = () => { const findPrev = () => {
lastIdxs = findAll(); if (!lastIdxs.length) return; lastIdxs = findAll(); if (!lastIdxs.length) return;
const cur = editor.textarea.selectionStart; 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; 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; let prev = -1;
for (let i = lastIdxs.length-1; i>=0; i--) { if (lastIdxs[i] < cur) { prev = lastIdxs[i]; break; } } 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]; if (prev === -1) prev = lastIdxs[lastIdxs.length-1];
selectAt(prev, qlen); selectAt(prev, qlen);
}; };
const selectAt = (idx: number, len?: number) => {
editor.textarea.focus();
editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length));
};
const replaceOne = () => { const replaceOne = () => {
const q = fi.value, r = ri.value; if (!q) return; const q = fi.value, r = ri.value; if (!q) return;
const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd; const ta = editor.textarea; const s = ta.selectionStart, e = ta.selectionEnd;
const matchText = ta.value.substring(s, e); const matchText = ta.value.substring(s, e);
if (self._regexMode) { 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 (_) {} 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) { } else if (matchText === q) {
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e); ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
@@ -311,7 +317,7 @@ const searchReplacePlugin: Plugin = {
const replaceAll = () => { const replaceAll = () => {
const q = fi.value, r = ri.value; if (!q) return; const q = fi.value, r = ri.value; if (!q) return;
const ta = editor.textarea; const ta = editor.textarea;
if (self._regexMode) { if (state._regexMode) {
try { ta.value = ta.value.replace(new RegExp(q, 'g'), r); } catch (_) { return; } try { ta.value = ta.value.replace(new RegExp(q, 'g'), r); } catch (_) { return; }
} else { } else {
ta.value = ta.value.split(q).join(r); ta.value = ta.value.split(q).join(r);
@@ -323,32 +329,55 @@ const searchReplacePlugin: Plugin = {
findAll(); findAll();
}; };
fi.addEventListener('input', () => { lastIdxs = 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); } }); 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(); this._close!(editor); } }); 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-next')!.addEventListener('click', findNext);
panel.querySelector('.me-search-prev')!.addEventListener('click', findPrev); panel.querySelector('.me-search-prev')!.addEventListener('click', findPrev);
panel.querySelector('.me-search-close')!.addEventListener('click', () => this._close!(editor)); panel.querySelector('.me-search-close')!.addEventListener('click', () => _close());
panel.querySelector('.me-search-replace-one')!.addEventListener('click', replaceOne); panel.querySelector('.me-search-replace-one')!.addEventListener('click', replaceOne);
panel.querySelector('.me-search-replace-all')!.addEventListener('click', replaceAll); panel.querySelector('.me-search-replace-all')!.addEventListener('click', replaceAll);
fi.focus(); fi.select(); fi.focus(); fi.select();
(this as any)._cleanup = () => { if (panel.parentNode) panel.parentNode.removeChild(panel); }; 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(); _open(false); }
else if (k === 'h') { e.preventDefault(); _open(true); }
else if (k === 'escape' && state._panel) { _close(); }
};
editor.textarea.addEventListener('keydown', _onKeydown);
// Expose for tests
(editor as any).__srState = state;
(editor as any).__srOpen = _open;
(editor as any).__srClose = _close;
(editor as any).__srKeydown = _onKeydown;
}, },
_updateReplaceVisible() { destroy(editor: any) {
const self = this as any; const state = (editor as any).__srState;
if (!self._panel) return; if (state) {
const show = self._panel.dataset.replace === '1'; if (state._cleanup) { state._cleanup(); state._cleanup = null; }
const row = self._panel.querySelector('.me-search-replace-row') as HTMLElement; state._panel = null;
if (row) row.style.display = show ? 'flex' : 'none'; }
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;
}, },
_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 = { 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) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return; 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; const items = e.clipboardData?.items; if (!items) return;
for (const item of items) { for (const item of items) {
if (item.type?.startsWith('image/')) { e.preventDefault(); 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 = { 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) { install(editor) {
if (!editor || !editor.textarea || typeof document === 'undefined') return; if (!editor || !editor.textarea || typeof document === 'undefined') return;
this._injectStyle!(); if (!document.getElementById('me-shortcut-style')) {
(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!();
};
editor.textarea.addEventListener('keydown', (this as any)._onKeydown);
},
_injectStyle() {
if (document.getElementById('me-shortcut-style')) return;
const s = document.createElement('style'); s.id = '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)}`; 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); document.head.appendChild(s);
}, }
_open(editor: any) {
if ((this as any)._panel) { this._close!(); return; } let _panel: HTMLElement | null = null;
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 += `<tr><td>${c}</td><td>${d}</td></tr>`; }); 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 += `<tr><td>${c}</td><td>${d}</td></tr>`; });
const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay'; const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ 快捷键</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`; overlay.innerHTML = `<div class="me-shortcut-panel"><h3>⌨️ ${t('close') || 'Shortcuts'}</h3><button class="me-shortcut-close">×</button><table>${rows}</table></div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) this._close!(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay || (e.target as HTMLElement).classList.contains('me-shortcut-close')) _close(); });
document.body.appendChild(overlay); (this as any)._panel = overlay; document.body.appendChild(overlay); _panel = overlay;
};
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 };
},
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;
}, },
_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 = { 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) { install(editor) {
if (!editor || typeof editor.getValue !== 'function') return; if (!editor || typeof editor.getValue !== 'function') return;
const hasAPI = typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function'; 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 = {}) => { editor.openFile = async (opts: any = {}) => {
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; } if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; }
try { try {
const [handle] = await (window as any).showOpenFilePicker({ types: [{ accept: { 'text/markdown': ['.md','.txt','.markdown'] } }], ...opts }); 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 }; 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; } } catch (e: any) { if (e.name !== 'AbortError') console.error('Open file error:', e); return null; }
}; };
editor.saveFile = async (opts: any = {}) => { editor.saveFile = async (opts: any = {}) => {
let handle = (this as any)._fileHandle; let handle = _fileHandle;
if (!handle || opts.saveAs) { if (!handle || opts.saveAs) {
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; } 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; } 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; } 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.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 ============ // ============ Preset plugins table ============
+3
View File
@@ -69,6 +69,8 @@ const generateCSS = (): string => {
.me-preview hr{border:0;height:1px;background:var(--md-border);margin:1.6em 0} .me-preview hr{border:0;height:1px;background:var(--md-border);margin:1.6em 0}
.me-preview code{font-family:var(--md-mono);font-size:.88em;padding:.15em .4em;background:var(--md-code-bg);color:var(--md-code-text);border-radius:4px} .me-preview 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-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 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-preview img{max-width:100%;height:auto;border-radius:6px;vertical-align:middle}
.me-table-wrap{overflow-x:auto;margin:.9em 0} .me-table-wrap{overflow-x:auto;margin:.9em 0}
@@ -120,6 +122,7 @@ const generateCSS = (): string => {
.me-outline li{margin:2px 0} .me-outline li{margin:2px 0}
.me-outline a{color:var(--md-muted);text-decoration:none;display:block;padding:2px 6px;border-radius:4px;transition:background .1s,color .1s} .me-outline a{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: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-l1 a{font-weight:600;color:var(--md-text)}
.me-outline-l2 a{padding-left:12px} .me-outline-l2 a{padding-left:12px}
.me-outline-l3 a{padding-left:20px;font-size:12px} .me-outline-l3 a{padding-left:20px;font-size:12px}
+69
View File
@@ -1947,3 +1947,72 @@ describe('MarkdownEditor - v0.2.1 分隔条持久化', () => {
ed.destroy(); 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();
});
});
+47 -2
View File
@@ -1022,10 +1022,10 @@ describe('parseMarkdown - 覆盖率:链接引用回退', () => {
}); });
describe('parseMarkdown - 覆盖率:自定义 token default 渲染', () => { describe('parseMarkdown - 覆盖率:自定义 token default 渲染', () => {
test('未识别 token type 返回空字符串', () => { test('未识别 token type 渲染为回退 div', () => {
const tokens = [{ type: 'unknownType', text: 'whatever' }]; const tokens = [{ type: 'unknownType', text: 'whatever' }];
const html = renderTokens(tokens); 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(); 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('<strong>Important!</strong>');
});
});
+3 -7
View File
@@ -820,17 +820,13 @@ describe('零散分支补全', () => {
document.body.appendChild(c); document.body.appendChild(c);
const ed = new MarkdownEditor(c, { value: 'hello world' }); const ed = new MarkdownEditor(c, { value: 'hello world' });
ed.use(presetPlugins.searchReplace); ed.use(presetPlugins.searchReplace);
const plugin = ed.getPlugins().find((p) => p.name === 'searchReplace');
// Ctrl+F 打开面板 // Ctrl+F 打开面板
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true })); ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'f', ctrlKey: true, bubbles: true }));
expect(ed.el.querySelector('.me-search')).not.toBeNull(); expect(ed.el.querySelector('.me-search')).not.toBeNull();
expect(plugin._panel).toBeDefined(); expect((ed as any).__srState._panel).toBeDefined();
// spy _close 验证 textarea 上 Ctrl+Escape 分支被触发 // Ctrl+Escape 关闭面板(带 Ctrl 才会进入分支)
// 注:plugins.js 中 escape 分支在 if (!mod) return 之后,必须带 Ctrl/Meta
const closeSpy = jest.spyOn(plugin, '_close');
ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true })); ed.textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', ctrlKey: true, bubbles: true }));
expect(closeSpy).toHaveBeenCalled(); expect(ed.el.querySelector('.me-search')).toBeNull();
closeSpy.mockRestore();
ed.destroy(); ed.destroy();
}); });