feat: v0.2.1 — reference links, context menu, RTL, ja/ko, regex search, hooks, copy API
## 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:
+133
-4
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user