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:
+136
-24
@@ -109,32 +109,39 @@ export const validateConfig = (schema: PluginSchema = {}, config: Record<string,
|
||||
const escapeAttr = (s: any): string => String(s == null ? '' : s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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); } });
|
||||
|
||||
Reference in New Issue
Block a user