feat: v0.2.2 — plugin refactor, i18n, outline tracking, formatTable, code title, fr locale
## 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:
@@ -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 `<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
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -193,6 +193,8 @@ export const THEMES: Record<string, ThemeConfig | string> = {
|
||||
codeText: '#78350f',
|
||||
accent: '#d97706',
|
||||
muted: '#a16207',
|
||||
progressBg: 'rgba(217, 119, 6, 0.15)',
|
||||
closeHoverBg: 'rgba(217, 119, 6, 0.2)',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+84
-7
@@ -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 += `<div class="me-gutter-line">${i}</div>`;
|
||||
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 += `<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 {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1
-1
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
||||
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<string, Record<string, string>> = {
|
||||
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<string, Record<string, string>> = {
|
||||
warning: '警告', info: '情報', loading: '読み込み中...', retry: '再試行',
|
||||
renderError: 'レンダリング失敗',
|
||||
outline: 'アウトライン',
|
||||
cut: '切り取り', copy: 'コピー', paste: '貼り付け', selectAll: 'すべて選択',
|
||||
},
|
||||
ko: {
|
||||
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
|
||||
@@ -104,5 +107,32 @@ export const LOCALES: Record<string, Record<string, string>> = {
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
+48
-9
@@ -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<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[] = [];
|
||||
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<string, strin
|
||||
case 'paragraph': return `<p>${cachedRenderInline(tok.text, env)}</p>`;
|
||||
case 'hr': return '<hr/>';
|
||||
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 'ol': {
|
||||
const sn = tok.start || 1;
|
||||
@@ -489,7 +505,12 @@ const renderToken = (tok: Token, env: RenderEnv, footnotes: Record<string, strin
|
||||
return h + '</dl>';
|
||||
}
|
||||
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>';
|
||||
};
|
||||
|
||||
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>`;
|
||||
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) {
|
||||
try {
|
||||
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); }
|
||||
}
|
||||
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 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]}"` : '';
|
||||
|
||||
+220
-160
@@ -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 = `<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); 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 = `<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;
|
||||
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 += `<tr><td>${c}</td><td>${d}</td></tr>`; });
|
||||
const overlay = document.createElement('div'); overlay.className = 'me-shortcut-overlay';
|
||||
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')) _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 += `<tr><td>${c}</td><td>${d}</td></tr>`; });
|
||||
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.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 ============
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+47
-2
@@ -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('<strong>Important!</strong>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user