feat: v0.2.1 — reference links, context menu, RTL, ja/ko, regex search, hooks, copy API
CI / test (16.x) (push) Canceled after 0s
CI / test (18.x) (push) Canceled after 0s
CI / test (20.x) (push) Canceled after 0s

## Added
- Reference link/image resolution: [text][ref] + ![alt][ref] with [ref]: url definitions
- Right-click context menu: undo/redo/cut/copy/paste/selectAll + custom items
- RTL CSS layout support for Arabic, Hebrew, Persian etc.
- Japanese (ja) and Korean (ko) locales with 60+ keys each
- Divider position localStorage persistence
- Regex search toggle in search/replace panel
- Export HTML with embedded CSS styles
- beforeChange / afterChange lifecycle hooks (instance + global)
- copyAsMarkdown() / copyAsHTML() clipboard APIs
- CHANGELOG.md, CONTRIBUTING.md, CI workflow (.github/workflows/ci.yml)
- 2 new test suites: index.test.ts, styles.test.ts (684 total tests, +74)

## Changed
- autoSave plugin: closure-based state per instance instead of this context
- Plugin install() now receives options as second argument
- RTL locale detection: now uses language prefix (ar-SA → RTL)
- Rollup dev mode: only builds UMD format
- prepublishOnly now includes typecheck + test
- Version bumped to 0.2.1

## Fixed
- [text][ref] now correctly renders as link (was raw text)
- ![alt][ref] no longer produces empty src
- autoSave plugin state isolation across multiple editor instances
- Footnote definitions no longer consumed by refDef handler
This commit is contained in:
2026-07-25 08:53:58 +08:00
parent 16464af0ae
commit 1cd5b63174
22 changed files with 1448 additions and 116 deletions
+133 -4
View File
@@ -126,6 +126,7 @@ export class MarkdownEditor {
this._bindToolbarKeyboard();
this._initAriaLive();
this._bindEvents();
this._bindContextMenu();
this.textarea.value = this._value;
this._pushHistory();
@@ -190,6 +191,8 @@ export class MarkdownEditor {
this.editorPane = editorPane; this.editorInner = editorInner; this.gutter = gutter;
this.previewPane = previewPane; this.dividerEl = divider;
this.textarea = textarea; this.previewEl = preview; this.statusEl = statusbar;
// Restore divider position if saved
this._restoreDividerPosition();
}
_buildToolbar(): void {
@@ -223,11 +226,15 @@ export class MarkdownEditor {
_bindEvents(): void {
const ta = this.textarea;
const onInput = () => {
this._value = ta.value; this._scheduleRender(); this._scheduleHistory(); this._updateWordCount();
const oldValue = this._value;
this._value = ta.value;
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', oldValue, this._value);
this._scheduleRender(); this._scheduleHistory(); this._updateWordCount();
this._renderGutter(); this._updateOutline();
this._emit('input', this._value); this._emit('change', this._value);
if (typeof this.config.onInput === 'function') { try { this.config.onInput(this._value, this); } catch (e) { console.error(e); } }
if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } }
this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this);
};
ta.addEventListener('input', onInput);
const onKeydown = (e: KeyboardEvent) => {
@@ -347,10 +354,31 @@ export class MarkdownEditor {
this.editorPane.style.flex = `0 0 ${pct}%`;
this.previewPane.style.flex = `1 1 ${100 - pct}%`;
};
const onUp = () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); };
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
this._saveDividerPosition();
};
window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp);
}
_saveDividerPosition(): void {
try {
const epFlex = this.editorPane.style.flex;
if (epFlex) localStorage.setItem('metona-editor-divider', epFlex);
} catch (_) {}
}
_restoreDividerPosition(): void {
try {
const saved = localStorage.getItem('metona-editor-divider');
if (saved && this._mode === 'split') {
this.editorPane.style.flex = saved;
this.previewPane.style.flex = '1 1 auto';
}
} catch (_) {}
}
_scheduleRender(): void { if (this._renderRaf) return; this._renderRaf = requestAnimationFrame(() => { this._renderRaf = null; this._render(); }); }
_render(): void {
@@ -640,15 +668,35 @@ export class MarkdownEditor {
getValue(): string { return this._destroyed ? '' : this._value; }
setValue(md: string, opts: { silent?: boolean } = {}): this {
if (this._destroyed) return this; this._value = md || ''; this.textarea.value = this._value;
if (this._destroyed) return this;
MarkdownEditor.trigger('beforeChange', this); this._emit('beforeChange', this._value, md);
this._value = md || ''; this.textarea.value = this._value;
if (!opts.silent) this._pushHistory(); this._render(); this._renderGutter(); this._updateWordCount(); this._updateOutline();
if (this.gutter) this.gutter.scrollTop = 0; this.textarea.scrollTop = 0;
if (!opts.silent) { this._emit('change', this._value); if (typeof this.config.onChange === 'function') { try { this.config.onChange(this._value, this); } catch (e) { console.error(e); } } }
this._emit('afterChange', this._value); MarkdownEditor.trigger('afterChange', this);
return this;
}
getHTML(): string { MarkdownEditor.trigger('beforeRender', this); this._emit('beforeRender', this); let html = this._renderFn(this._value, { highlight: this._highlightFn || undefined, locale: getCurrentLocale() }); if (typeof this.config.sanitize === 'function') { try { html = this.config.sanitize(html); } catch (e) { console.error(e); } } MarkdownEditor.trigger('afterRender', this); this._emit('afterRender', this); return html; }
refresh(): this { this._lastRenderedValue = null; this._render(); return this; }
copyAsMarkdown(): this {
if (typeof navigator !== 'undefined' && navigator.clipboard) {
navigator.clipboard.writeText(this._value).then(() => this._emit('copy', { type: 'markdown' })).catch(() => {});
}
return this;
}
copyAsHTML(): this {
const html = this.getHTML();
if (typeof navigator !== 'undefined' && navigator.clipboard) {
const blob = new Blob([html], { type: 'text/html' });
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([this._value], { type: 'text/plain' }) });
navigator.clipboard.write([item]).then(() => this._emit('copy', { type: 'html' })).catch(() => {});
}
return this;
}
insert(text: string, opts: { replace?: boolean } = {}): this {
const ta = this.textarea; const start = ta.selectionStart; const end = ta.selectionEnd;
ta.value = ta.value.slice(0, start) + text + ta.value.slice(opts.replace ? end : start); ta.focus();
@@ -673,7 +721,7 @@ export class MarkdownEditor {
if (!p || typeof p !== 'object') { console.warn('MeEditor: invalid plugin'); return this; }
const merged = { ...p, ...options };
if (typeof merged.install === 'function') {
try { const result = merged.install(this); if (result && typeof result.then === 'function') { result.catch((e: any) => console.error(`MeEditor: async plugin "${merged.name}" error:`, e)); } }
try { const result = merged.install(this, options); if (result && typeof result.then === 'function') { result.catch((e: any) => console.error(`MeEditor: async plugin "${merged.name}" error:`, e)); } }
catch (e) { console.error(`MeEditor: plugin "${merged.name}" install error:`, e); }
}
this._plugins.push(merged); return this;
@@ -704,6 +752,87 @@ export class MarkdownEditor {
removeToolbarButton(action: string): this { if (!this.toolbarEl) return this; const btn = this.toolbarEl.querySelector(`.me-btn[data-action="${action}"], .me-btn[data-mode="${action}"]`); if (btn) btn.remove(); return this; }
registerContextMenu(items: any[] = []): this { this._contextMenuItems = items; return this; }
_bindContextMenu(): void {
if (!this.el) return;
const onContextMenu = (e: MouseEvent) => {
const existing = document.querySelector('.me-context-menu');
if (existing) existing.remove();
this._showContextMenu(e);
};
this.el.addEventListener('contextmenu', onContextMenu);
this._cleanups.push(() => this.el.removeEventListener('contextmenu', onContextMenu));
}
_showContextMenu(e: MouseEvent): void {
e.preventDefault();
const ta = this.textarea;
const hasSelection = ta && ta.selectionStart !== ta.selectionEnd;
const defaultItems: Array<{ label?: string; action?: string; shortcut?: string; sep?: boolean; disabled?: boolean; onClick?: () => void }> = [
{ 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' },
];
const items = [...defaultItems];
if (this._contextMenuItems.length) {
items.push({ sep: true });
this._contextMenuItems.forEach((item) => items.push(item));
}
const menu = document.createElement('div');
menu.className = 'me-context-menu';
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
// Adjust if off-screen
requestAnimationFrame(() => {
const rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) menu.style.left = (e.clientX - rect.width) + 'px';
if (rect.bottom > window.innerHeight) menu.style.top = (e.clientY - rect.height) + 'px';
});
items.forEach((item) => {
if ((item as any).sep) { const sep = document.createElement('div'); sep.className = 'me-context-menu-sep'; menu.appendChild(sep); return; }
const el = document.createElement('div');
el.className = 'me-context-menu-item';
if (item.disabled) el.classList.add('me-disabled');
el.innerHTML = `<span>${item.label}</span>${item.shortcut ? `<span class="me-context-menu-shortcut">${item.shortcut}</span>` : ''}`;
el.addEventListener('click', (ev) => {
ev.stopPropagation();
if (item.disabled) return;
if (item.onClick) { item.onClick(); }
else if (item.action) this._execContextAction(item.action);
this._hideContextMenu();
});
menu.appendChild(el);
});
document.body.appendChild(menu);
const close = (ev: Event) => {
if (!menu.contains(ev.target as Node)) { this._hideContextMenu(); }
};
document.addEventListener('click', close, { once: true });
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') this._hideContextMenu(); }, { once: true });
}
_hideContextMenu(): void {
const menu = document.querySelector('.me-context-menu');
if (menu) menu.remove();
}
_execContextAction(action: string): void {
const ta = this.textarea;
if (!ta) return;
switch (action) {
case 'undo': this.undo(); break;
case 'redo': this.redo(); break;
case 'cut': document.execCommand('cut'); break;
case 'copy': document.execCommand('copy'); break;
case 'paste': document.execCommand('paste'); break;
case 'selectAll': ta.focus(); ta.select(); break;
default: this.exec(action); break;
}
}
toast(message: string, opts: { type?: string; duration?: number; animation?: string } = {}): this {
if (!this.el || typeof document === 'undefined') return this;
const { type = 'info', duration = 3000, animation = 'fade' } = opts;