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
+1
View File
@@ -14,6 +14,7 @@ export type ToolbarItem = string | '|';
export interface RenderEnv {
highlight?: (code: string, lang: string) => string;
locale?: string;
refs?: Record<string, string>;
}
export interface EditorStyle {
+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;
+5 -2
View File
@@ -131,8 +131,9 @@ export const getLocaleName = (locale: string): string => {
};
export const getLocaleDirection = (locale: string): 'ltr' | 'rtl' => {
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug'];
return rtlLocales.includes(locale) ? 'rtl' : 'ltr';
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug', 'dv', 'ku', 'syr'];
const short = locale.split('-')[0].toLowerCase();
return rtlLocales.includes(short) ? 'rtl' : 'ltr';
};
export const formatNumber = (number: number, options: Intl.NumberFormatOptions = {}): string => {
@@ -262,6 +263,8 @@ export const createI18nManager = () => ({
export const presetLocales = {
'zh-CN': { name: '简体中文', nativeName: '简体中文', direction: 'ltr', translations: LOCALES['zh-CN'] },
'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'] },
};
export const i18nUtils = createI18nManager();
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* MetonaEditor — Type-safe, lightweight Markdown Editor
* @module metona-editor
* @version 0.2.0
* @version 0.2.1
*/
import { MarkdownEditor } from './core';
@@ -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.0';
const VERSION = '0.2.1';
const globalPlugins: any[] = [];
+50
View File
@@ -55,4 +55,54 @@ export const LOCALES: Record<string, Record<string, string>> = {
renderError: 'Render failed',
outline: 'Outline',
},
ja: {
bold: '太字', italic: '斜体', underline: '下線', strikethrough: '打ち消し線',
h1: '見出し1', h2: '見出し2', h3: '見出し3', quote: '引用', code: 'コード',
link: 'リンク', image: '画像', table: '表', ul: '箇条書き', ol: '番号付きリスト',
indent: 'インデント', outdent: 'インデント解除', hr: '水平線', undo: '元に戻す', redo: 'やり直し',
edit: '編集', split: '分割', preview: 'プレビュー', fullscreen: '全画面',
fullscreenExit: '全画面解除', theme: 'テーマ', light: 'ライト', dark: 'ダーク',
auto: '自動', warm: 'ウォーム', wordCount: '文字数', characters: '文字',
words: '単語', lines: '行', readingTime: '読了時間', minutes: '分',
placeholder: 'Markdownを入力...', empty: '内容なし', copied: 'コピー済み',
copyContent: '内容をコピー', copyHTML: 'HTMLをコピー', copySuccess: 'コピー成功',
copyFailed: 'コピー失敗', clearContent: '内容をクリア', clearConfirm: 'すべての内容をクリアしますか?',
linkPlaceholder: 'リンクURLを入力', imagePlaceholder: '画像URLを入力',
altPlaceholder: '代替テキストを入力', tableRows: '行', tableCols: '列',
confirm: '確認', cancel: 'キャンセル', exportMarkdown: 'Markdownエクスポート',
exportHTML: 'HTMLエクスポート', search: '検索', replace: '置換', replaceAll: 'すべて置換',
searchPlaceholder: '検索', replacePlaceholder: '置換後',
findNext: '次を検索', findPrev: '前を検索', matchCase: '大文字小文字',
wholeWord: '単語単位', close: '閉じる', open: '開く', save: '保存',
saved: '保存済み', saving: '保存中...', delete: '削除', confirmDelete: '削除してもよろしいですか?',
unsavedChanges: '未保存の変更があります', error: 'エラー', success: '成功',
warning: '警告', info: '情報', loading: '読み込み中...', retry: '再試行',
renderError: 'レンダリング失敗',
outline: 'アウトライン',
},
ko: {
bold: '굵게', italic: '기울임', underline: '밑줄', strikethrough: '취소선',
h1: '제목1', h2: '제목2', h3: '제목3', quote: '인용', code: '코드',
link: '링크', image: '이미지', table: '표', ul: '순서 없는 목록', ol: '순서 있는 목록',
indent: '들여쓰기', outdent: '내어쓰기', hr: '수평선', undo: '실행 취소', redo: '다시 실행',
edit: '편집', split: '분할', preview: '미리보기', fullscreen: '전체 화면',
fullscreenExit: '전체 화면 종료', theme: '테마', light: '라이트', dark: '다크',
auto: '자동', warm: '웜', wordCount: '글자 수', characters: '글자',
words: '단어', lines: '줄', readingTime: '읽기 시간', minutes: '분',
placeholder: 'Markdown 입력...', empty: '내용 없음', copied: '복사됨',
copyContent: '내용 복사', copyHTML: 'HTML 복사', copySuccess: '복사 성공',
copyFailed: '복사 실패', clearContent: '내용 지우기', clearConfirm: '모든 내용을 지우시겠습니까?',
linkPlaceholder: '링크 URL 입력', imagePlaceholder: '이미지 URL 입력',
altPlaceholder: '대체 텍스트 입력', tableRows: '행', tableCols: '열',
confirm: '확인', cancel: '취소', exportMarkdown: 'Markdown 내보내기',
exportHTML: 'HTML 내보내기', search: '검색', replace: '바꾸기', replaceAll: '모두 바꾸기',
searchPlaceholder: '찾기', replacePlaceholder: '바꿀 내용',
findNext: '다음 찾기', findPrev: '이전 찾기', matchCase: '대소문자 구분',
wholeWord: '단어 단위', close: '닫기', open: '열기', save: '저장',
saved: '저장됨', saving: '저장 중...', delete: '삭제', confirmDelete: '삭제하시겠습니까?',
unsavedChanges: '저장되지 않은 변경 사항이 있습니다', error: '오류', success: '성공',
warning: '경고', info: '정보', loading: '로딩 중...', retry: '재시도',
renderError: '렌더링 실패',
outline: '개요',
},
};
+46 -7
View File
@@ -17,13 +17,14 @@ export interface Token {
export interface ParseResult {
tokens: Token[];
footnotes: Record<string, string>;
refs: Record<string, string>;
}
export interface BlockHandler {
name: string;
priority: number;
test: (line: string, lines: string[], i: number) => any;
parse: (lines: string[], i: number, match: any, tokens: Token[], footnotes: Record<string, string>) => { token: Token | null; newIndex: number };
parse: (lines: string[], i: number, match: any, tokens: Token[], footnotes: Record<string, string>, refs?: Record<string, string>) => { token: Token | null; newIndex: number };
}
export interface ListItem {
@@ -154,6 +155,21 @@ const isBlockStart = (line: string): boolean => {
};
// Register all built-in block handlers
registerBlockHandler({ name: 'refDef', priority: 0.1,
test: (line) => {
// Must not match footnote definitions: [^id]: text
if (/^\[\^/.test(line)) return null;
const m = line.match(/^\[([^\]]+)\]:\s*(?:<(\S+)>|(\S+))(?:\s+['"\(](.*?)['"\)])?\s*$/);
return m ? m : null;
},
parse: (_lines, i, match, _tokens, _footnotes, refs) => {
const refId: string = match[1].toLowerCase();
const url: string = match[2] || match[3] || '';
const title: string = match[4] || '';
if (url && refs) { refs[refId] = JSON.stringify({ url, title }); }
return { token: null, newIndex: i + 1 };
},
});
registerBlockHandler({ name: 'blank', priority: 0,
test: (line) => RE_EMPTY.test(line) ? true : null,
parse: (_lines, i) => ({ token: null, newIndex: i + 1 }),
@@ -295,11 +311,12 @@ registerBlockHandler({ name: 'mathBlock', priority: 13,
// ============ Block parsing ============
const parseTokens = (md: string | null | undefined): ParseResult => {
if (md == null) return { tokens: [], footnotes: {} };
if (md == null) return { tokens: [], footnotes: {}, refs: {} };
const text = String(md).replace(/\r\n?/g, '\n');
const lines = text.split('\n');
const tokens: Token[] = [];
const footnotes: Record<string, string> = {};
const refs: Record<string, string> = {};
let i = 0;
while (i < lines.length) {
@@ -308,7 +325,7 @@ const parseTokens = (md: string | null | undefined): ParseResult => {
for (const handler of blockHandlers) {
const match = handler.test(line, lines, i);
if (match !== null && match !== false) {
const result = handler.parse(lines, i, match, tokens, footnotes);
const result = handler.parse(lines, i, match, tokens, footnotes, refs);
if (result.token) tokens.push(result.token);
i = result.newIndex;
handled = true;
@@ -329,7 +346,7 @@ const parseTokens = (md: string | null | undefined): ParseResult => {
}
if (para.length) tokens.push({ type: 'paragraph', text: para.join('\n') });
}
return { tokens, footnotes };
return { tokens, footnotes, refs };
};
// ============ Token rendering ============
@@ -348,8 +365,8 @@ const renderTokens = (tokens: Token[], env: RenderEnv = {}, footnotes: Record<st
};
const parseMarkdown = (md: string | null | undefined, env: RenderEnv = {}): string => {
const { tokens, footnotes } = parseTokens(md);
return renderTokens(tokens, env, footnotes);
const { tokens, footnotes, refs } = parseTokens(md);
return renderTokens(tokens, { ...env, refs }, footnotes);
};
// ============ List parsing ============
@@ -623,6 +640,15 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
if (match.startsWith('![') && match.includes('][')) {
const m = match.match(/!\[([^\]]*)\]\[([^\]]*)\]/);
if (!m) return match;
const refKey = (m[2] || m[1]).toLowerCase();
if (env.refs && env.refs[refKey]) {
try {
const ref = JSON.parse(env.refs[refKey]);
const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
const t = ref.title ? ` title="${ref.title}"` : '';
return `<img src="${u}" alt="${m[1]}"${t} loading="lazy"/>`;
} catch (_) { return `<img src="" alt="${m[1]}" class="me-img-ref"/>`; }
}
return `<img src="" alt="${m[1]}" class="me-img-ref"/>`;
}
if (match.startsWith('[') && match.includes('](')) {
@@ -633,7 +659,20 @@ const scanInline = (s: string, _codes: CodePlaceholder[], env: RenderEnv): strin
const linkText = renderInline(m[1], env);
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
}
if (match.startsWith('[') && match.includes('][')) return match;
if (match.startsWith('[') && match.includes('][')) {
const m = match.match(/\[([^\]]+)\]\[([^\]]*)\]/);
if (!m) return match;
const refKey = (m[2] || m[1]).toLowerCase();
if (env.refs && env.refs[refKey]) {
try {
const ref = JSON.parse(env.refs[refKey]);
const u = safeUrl(ref.url); if (!u) return escapeHTML(match);
const t = ref.title ? ` title="${ref.title}"` : '';
return `<a href="${u}"${t} target="_blank" rel="noopener noreferrer">${m[1]}</a>`;
} catch (_) { return escapeHTML(match); }
}
return escapeHTML(match);
}
if (match.startsWith('&lt;http')) {
const url = match.slice(4, -4);
const u = safeUrl(url);
+136 -24
View File
@@ -109,32 +109,39 @@ export const validateConfig = (schema: PluginSchema = {}, config: Record<string,
const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const autoSavePlugin: Plugin = {
name: 'autoSave', version: '0.1.0', description: 'Auto-save to localStorage', priority: 100,
install(editor) {
name: 'autoSave', version: '0.1.1', description: 'Auto-save to localStorage', priority: 100,
install(editor, options?: Record<string, any>) {
if (!editor || typeof editor.getValue !== 'function') return;
const key = (this as any).key || ('me-draft-' + (editor.id || ''));
const opts = options || (this as any);
const key: string = opts.key || ('me-draft-' + (editor.id || ''));
const delay: number = opts.delay || 1000;
const state = { _timer: null as ReturnType<typeof setTimeout> | null };
const save = () => {
if ((this as any)._timer) { clearTimeout((this as any)._timer); (this as any)._timer = null; }
if (state._timer) { clearTimeout(state._timer); state._timer = null; }
try { localStorage.setItem(key, editor.getValue()); if (typeof editor._emit === 'function') editor._emit('autosave', { key, value: editor.getValue() }); }
catch (e) { console.warn('MeEditor autoSave:', e); }
};
(this as any)._save = save;
(this as any)._onInput = () => { if ((this as any)._timer) clearTimeout((this as any)._timer); (this as any)._timer = setTimeout(save, (this as any).delay || 1000); };
(this as any)._onBlur = save;
(this as any)._onSave = save;
editor.on('change', (this as any)._onInput);
editor.on('blur', (this as any)._onBlur);
editor.on('save', (this as any)._onSave);
const _onInput = () => { if (state._timer) clearTimeout(state._timer); state._timer = setTimeout(save, delay); };
const _onBlur = save;
const _onSave = save;
editor.on('change', _onInput);
editor.on('blur', _onBlur);
editor.on('save', _onSave);
editor.restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null) editor.setValue(v); return v; } catch (_) { return null; } };
editor.clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; };
editor.getDraftKey = () => key;
(editor as any).__autoSaveCleanup = { state, _onInput, _onBlur, _onSave, save };
},
destroy(editor) {
if ((this as any)._timer) { clearTimeout((this as any)._timer); (this as any)._timer = null; }
if (editor && typeof editor.off === 'function') {
if ((this as any)._onInput) editor.off('change', (this as any)._onInput);
if ((this as any)._onBlur) editor.off('blur', (this as any)._onBlur);
if ((this as any)._onSave) editor.off('save', (this as any)._onSave);
const cleanup = (editor as any).__autoSaveCleanup;
if (cleanup) {
if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; }
if (editor && typeof editor.off === 'function') {
if (cleanup._onInput) editor.off('change', cleanup._onInput);
if (cleanup._onBlur) editor.off('blur', cleanup._onBlur);
if (cleanup._onSave) editor.off('save', cleanup._onSave);
}
delete (editor as any).__autoSaveCleanup;
}
},
};
@@ -157,7 +164,42 @@ const exportToolPlugin: Plugin = {
const title = opts.title || 'Document';
const css = opts.css || '';
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
download(filename || `metona-${stamp()}.html`, `<!DOCTYPE html>\n<html lang="${opts.lang||'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css?`<style>${css}</style>`:''}\n</head>\n<body>\n${body}\n</body>\n</html>`, 'text/html');
// Build minimal embedded CSS for the exported HTML
const embedCSS = opts.embedCSS !== false ? `<style>
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;font-size:14px;line-height:1.6;color:#1f2937;max-width:860px;margin:0 auto;padding:20px}
h1,h2,h3,h4,h5,h6{margin:1.4em 0 .6em;font-weight:650;line-height:1.3}
h1{font-size:1.9em;padding-bottom:.3em;border-bottom:1px solid #e5e7eb}
h2{font-size:1.55em;padding-bottom:.3em;border-bottom:1px solid #e5e7eb}
h3{font-size:1.3em}h4{font-size:1.12em}h5{font-size:1em}h6{font-size:.9em;color:#6b7280}
p{margin:.7em 0}a{color:#3b82f6;text-decoration:none}a:hover{text-decoration:underline}
strong{font-weight:650}em{font-style:italic}del{text-decoration:line-through;opacity:.75}
ul,ol{margin:.6em 0;padding-left:1.6em}li{margin:.25em 0}
li.me-task-item{list-style:none;margin-left:-1.4em}
li.me-task-item input{margin-right:.5em;vertical-align:middle}
blockquote{margin:.8em 0;padding:.4em 1em;border-left:3px solid #3b82f6;background:#f3f4f6;border-radius:0 6px 6px 0}
hr{border:0;height:1px;background:#e5e7eb;margin:1.6em 0}
code{font-family:"SF Mono",Consolas,monospace;font-size:.88em;padding:.15em .4em;background:#f3f4f6;border-radius:4px}
pre{margin:.9em 0;padding:14px 16px;background:#f3f4f6;border-radius:8px;overflow-x:auto;border:1px solid #e5e7eb}
pre code{padding:0;background:transparent;font-size:.9em;line-height:1.6;border-radius:0}
img{max-width:100%;height:auto;border-radius:6px}
table{border-collapse:collapse;width:100%;font-size:.93em;display:block;overflow-x:auto}
th,td{border:1px solid #e5e7eb;padding:7px 12px;text-align:left}
th{background:#f3f4f6;font-weight:600}
tr:nth-child(even) td{background:#f9fafb}
mark{background:rgba(250,204,21,.3);color:inherit;padding:.1em .2em;border-radius:3px}
sup{font-size:.75em}sub{font-size:.75em}
.me-math-block{display:block;margin:1.2em 0;padding:12px 16px;background:#f3f4f6;border-radius:8px;overflow-x:auto;font-family:monospace;text-align:center}
.me-math-inline{font-family:monospace}
dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;color:#4b5563}
.me-footnote-ref a{font-size:.75em;vertical-align:super;text-decoration:none;color:#3b82f6}
.me-footnotes{margin-top:2em;border-top:1px solid #e5e7eb;padding-top:.8em;font-size:.9em;color:#6b7280}
.me-footnotes hr{display:none}
.me-footnotes ol{padding-left:1.2em}
.me-footnote-item{margin:.3em 0}
.me-footnote-backref{text-decoration:none;color:#3b82f6;margin-right:.4em}
.me-table-wrap{overflow-x:auto;margin:.9em 0}
</style>` : '';
download(filename || `metona-${stamp()}.html`, `<!DOCTYPE html>\n<html lang="${opts.lang||'zh-CN'}">\n<head>\n<meta charset="utf-8"/>\n<meta name="viewport" content="width=device-width, initial-scale=1"/>\n<title>${title}</title>\n${css?`<style>${css}</style>`:''}${embedCSS}\n</head>\n<body>\n${body}\n</body>\n</html>`, 'text/html');
return editor;
};
},
@@ -197,19 +239,89 @@ const searchReplacePlugin: Plugin = {
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-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;
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 findAll = () => { const q = fi.value; if (!q) { ce.textContent = ''; return []; } const idxs: number[] = []; let from = 0; 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; };
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; let next = lastIdxs.find((i: number) => i >= cur); if (next == null) next = lastIdxs[0]; selectAt(next); };
const findPrev = () => { lastIdxs = findAll(); if (!lastIdxs.length) return; const cur = editor.textarea.selectionStart; 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); };
const selectAt = (idx: number) => { editor.textarea.focus(); editor.textarea.setSelectionRange(idx, idx + 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; if (ta.value.substring(s, e) === 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; const before = ta.value; const after = before.split(q).join(r); if (before === after) return; ta.value = after; 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(); };
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); } });
+19 -1
View File
@@ -113,6 +113,7 @@ const generateCSS = (): string => {
.me-context-menu-item:hover{background:var(--md-code-bg);color:var(--md-accent)}
.me-context-menu-sep{height:1px;background:var(--md-border);margin:4px 0}
.me-context-menu-shortcut{color:var(--md-muted);font-size:11px;margin-left:24px}
.me-context-menu-item.me-disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
.me-outline{position:absolute;top:0;right:0;width:220px;height:100%;overflow-y:auto;background:var(--md-preview-bg);border-left:1px solid var(--md-border);padding:12px 14px;font-size:13px;z-index:15}
.me-outline-title{font-weight:650;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--md-border);color:var(--md-text)}
.me-outline ul{list-style:none;padding:0;margin:0}
@@ -131,7 +132,24 @@ const generateCSS = (): string => {
.me-body.me-mode-split .me-editor-pane{display:none}
.me-body.me-mode-split .me-preview-pane{flex:1 1 100%!important}
.me-preview{padding:0!important;font-size:11pt}
.me-wrapper.me-fullscreen{position:static!important;width:auto!important;height:auto!important}}`;
.me-wrapper.me-fullscreen{position:static!important;width:auto!important;height:auto!important}}
[dir=rtl] .me-gutter{border-right:none;border-left:1px solid var(--md-border);text-align:left}
[dir=rtl] .me-gutter-line{padding-right:0;padding-left:4px}
[dir=rtl] .me-toolbar-group{margin-left:0;margin-right:auto;padding-left:0;padding-right:6px;border-left:none;border-right:1px solid var(--md-border)}
[dir=rtl] .me-outline{right:auto;left:0;border-left:none;border-right:1px solid var(--md-border)}
[dir=rtl] .me-outline-l2 a{padding-left:0;padding-right:12px}
[dir=rtl] .me-outline-l3 a{padding-left:0;padding-right:20px}
[dir=rtl] .me-outline-l4 a{padding-left:0;padding-right:28px}
[dir=rtl] .me-preview blockquote{border-left:none;border-right:3px solid var(--md-accent);border-radius:6px 0 0 6px}
[dir=rtl] .me-preview ul,[dir=rtl] .me-preview ol{padding-left:0;padding-right:1.6em}
[dir=rtl] .me-preview li.me-task-item{margin-left:0;margin-right:-1.4em}
[dir=rtl] .me-preview dd{margin-left:0;margin-right:1.6em}
[dir=rtl] .me-preview .me-footnotes ol{padding-left:0;padding-right:1.2em}
[dir=rtl] .me-preview .me-footnote-backref{margin-right:0;margin-left:.4em}
[dir=rtl] .me-search{right:auto;left:12px}
[dir=rtl] .me-toast{right:auto;left:16px}
[dir=rtl] .me-context-menu-shortcut{margin-left:0;margin-right:24px}
[dir=rtl] .me-statusbar{justify-content:flex-start}`;
};
export const injectStyles = (): void => {