refactor(core): 拆分 4 模块 + 插件状态 Symbol 化 + eslint type-aware
- core.ts 从 1162 行降至 865 行:命令/浮动工具栏/右键菜单/大纲 拆至 commands.ts / floating-toolbar.ts / context-menu.ts / outline.ts (原型安装,API 与测试完全兼容) - 6 个预设插件状态改用 Symbol 键存储,定义 EditorLike 契约接口, 消灭 (editor as any).__xxx 魔法属性 - 实现 maxLength(textarea maxlength + 程序化入口截断)与 zenMode 初始状态(此前为 dead config) - 搜索面板补齐 matchCase/wholeWord(翻译键已有但未实现, 中文全字边界感知) - 分隔条 localStorage key 加入实例 id 隔离 - eslint 启用 type-aware 规则(consistent-type-imports / no-unnecessary-type-assertion),0 errors
This commit is contained in:
+192
-85
@@ -6,14 +6,51 @@
|
||||
|
||||
import { t } from './i18n';
|
||||
|
||||
// ============ Editor contract ============
|
||||
|
||||
/** The editor surface plugins may rely on. Keeps plugin code type-checked
|
||||
* without importing the full MarkdownEditor class (avoids circular imports). */
|
||||
export interface EditorLike {
|
||||
id?: string;
|
||||
value?: string;
|
||||
_value?: string;
|
||||
el: HTMLElement;
|
||||
textarea: HTMLTextAreaElement;
|
||||
config?: Record<string, any>;
|
||||
on?: (name: string, fn: (...args: any[]) => void) => (() => void) | void;
|
||||
off?: (name: string, fn: (...args: any[]) => void) => unknown;
|
||||
_emit?: (name: string, ...args: any[]) => void;
|
||||
_pushHistory?: () => void;
|
||||
_render?: () => void;
|
||||
_updateWordCount?: () => void;
|
||||
insert?: (text: string, opts?: { replace?: boolean }) => unknown;
|
||||
setValue?: (value: string, opts?: { silent?: boolean }) => unknown;
|
||||
getValue?: () => string;
|
||||
getHTML?: () => string;
|
||||
focus?: () => unknown;
|
||||
toast?: (message: string, opts?: { type?: string; duration?: number; animation?: string }) => unknown;
|
||||
}
|
||||
|
||||
// ============ Plugin state keys (Symbols avoid property-name collisions) ============
|
||||
|
||||
const K_AUTOSAVE = Symbol('me-plugin:autoSave');
|
||||
const K_SEARCH = Symbol('me-plugin:searchReplace');
|
||||
const K_IMAGE_PASTE = Symbol('me-plugin:imagePaste');
|
||||
const K_SHORTCUT = Symbol('me-plugin:shortcutHelp');
|
||||
const K_FILESYSTEM = Symbol('me-plugin:fileSystem');
|
||||
|
||||
const pluginState = <T>(editor: EditorLike, key: symbol): T | undefined => (editor as any)[key] as T | undefined;
|
||||
const setPluginState = <T>(editor: EditorLike, key: symbol, state: T): void => { (editor as any)[key] = state; };
|
||||
const deletePluginState = (editor: EditorLike, key: symbol): void => { delete (editor as any)[key]; };
|
||||
|
||||
export interface Plugin {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
depends?: string[];
|
||||
priority?: number;
|
||||
install?: (editor: any) => void | Promise<void>;
|
||||
destroy?: (editor: any) => void;
|
||||
install?: (editor: EditorLike, options?: any) => void | Promise<void>;
|
||||
destroy?: (editor: EditorLike) => void;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -118,22 +155,24 @@ const autoSavePlugin: Plugin = {
|
||||
const state = { _timer: null as ReturnType<typeof setTimeout> | null };
|
||||
const save = () => {
|
||||
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() }); }
|
||||
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); }
|
||||
};
|
||||
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 };
|
||||
if (typeof editor.on === 'function') {
|
||||
editor.on('change', _onInput);
|
||||
editor.on('blur', _onBlur);
|
||||
editor.on('save', _onSave);
|
||||
}
|
||||
(editor as any).restoreDraft = () => { try { const v = localStorage.getItem(key); if (v != null && typeof editor.setValue === 'function') editor.setValue(v); return v; } catch (_) { return null; } };
|
||||
(editor as any).clearDraft = () => { try { localStorage.removeItem(key); } catch (_) {} return editor; };
|
||||
(editor as any).getDraftKey = () => key;
|
||||
setPluginState(editor, K_AUTOSAVE, { state, _onInput, _onBlur, _onSave, save });
|
||||
},
|
||||
destroy(editor) {
|
||||
const cleanup = (editor as any).__autoSaveCleanup;
|
||||
const cleanup = pluginState<any>(editor, K_AUTOSAVE);
|
||||
if (cleanup) {
|
||||
if (cleanup.state._timer) { clearTimeout(cleanup.state._timer); cleanup.state._timer = null; }
|
||||
if (editor && typeof editor.off === 'function') {
|
||||
@@ -141,7 +180,7 @@ const autoSavePlugin: Plugin = {
|
||||
if (cleanup._onBlur) editor.off('blur', cleanup._onBlur);
|
||||
if (cleanup._onSave) editor.off('save', cleanup._onSave);
|
||||
}
|
||||
delete (editor as any).__autoSaveCleanup;
|
||||
deletePluginState(editor, K_AUTOSAVE);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -159,8 +198,8 @@ const exportToolPlugin: Plugin = {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
const stamp = () => { const d = new Date(); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; };
|
||||
editor.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue(), 'text/markdown'); return editor; };
|
||||
editor.exportHTML = (filename?: string, opts: any = {}) => {
|
||||
const e = editor as any;
|
||||
const buildHTML = (opts: any = {}): string => {
|
||||
const title = opts.title || 'Document';
|
||||
const css = opts.css || '';
|
||||
const body = typeof editor.getHTML === 'function' ? editor.getHTML() : '';
|
||||
@@ -199,7 +238,26 @@ dl{margin:.8em 0}dt{font-weight:650;margin:.6em 0 .2em}dd{margin:0 0 .3em 1.6em;
|
||||
.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 `<!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>`;
|
||||
};
|
||||
e.exportMarkdown = (filename?: string) => { download(filename || `metona-${stamp()}.md`, editor.getValue!(), 'text/markdown'); return editor; };
|
||||
e.exportHTML = (filename?: string, opts: any = {}) => {
|
||||
download(filename || `metona-${stamp()}.html`, buildHTML(opts), 'text/html');
|
||||
return editor;
|
||||
};
|
||||
e.exportPDF = (opts: any = {}) => {
|
||||
if (typeof document === 'undefined' || !editor.el) return editor;
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.position = 'fixed'; iframe.style.right = '0'; iframe.style.bottom = '0';
|
||||
iframe.style.width = '0'; iframe.style.height = '0'; iframe.style.border = '0';
|
||||
document.body.appendChild(iframe);
|
||||
const doc = iframe.contentDocument;
|
||||
if (!doc) { iframe.remove(); return editor; }
|
||||
doc.open(); doc.write(buildHTML(opts)); doc.close();
|
||||
setTimeout(() => {
|
||||
try { iframe.contentWindow?.print(); } catch (_) {}
|
||||
setTimeout(() => { if (iframe.parentNode) iframe.parentNode.removeChild(iframe); }, 1000);
|
||||
}, 50);
|
||||
return editor;
|
||||
};
|
||||
},
|
||||
@@ -207,7 +265,7 @@ 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.2.0', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
|
||||
name: 'searchReplace', version: '0.2.1', description: 'Search & Replace (Ctrl+F/H)', priority: 80,
|
||||
install(editor) {
|
||||
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
||||
// Inject style once globally
|
||||
@@ -220,9 +278,27 @@ const searchReplacePlugin: Plugin = {
|
||||
const state = {
|
||||
_panel: null as HTMLElement | null,
|
||||
_regexMode: false,
|
||||
_matchCase: true,
|
||||
_wholeWord: false,
|
||||
_cleanup: null as (() => void) | null,
|
||||
};
|
||||
|
||||
const WORD_CLASS = '[\\w\\u4e00-\\u9fff]';
|
||||
const escapeRegExpText = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
const buildPattern = (q: string): RegExp | null => {
|
||||
if (!q) return null;
|
||||
let src = state._regexMode ? q : escapeRegExpText(q);
|
||||
if (state._wholeWord) src = `(?<!${WORD_CLASS})(?:${src})(?!${WORD_CLASS})`;
|
||||
const flags = 'g' + (state._matchCase ? '' : 'i');
|
||||
try { return new RegExp(src, flags); } catch (_) { return null; }
|
||||
};
|
||||
|
||||
const hasWordBoundary = (text: string, idx: number, len: number): boolean => {
|
||||
const isWord = (c: string | undefined) => !!c && /[\w\u4e00-\u9fff]/.test(c);
|
||||
return !isWord(text[idx - 1]) && !isWord(text[idx + len]);
|
||||
};
|
||||
|
||||
const _updateReplaceVisible = () => {
|
||||
if (!state._panel) return;
|
||||
const show = state._panel.dataset.replace === '1';
|
||||
@@ -247,32 +323,40 @@ 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-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-regex" title="Regex">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></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-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn 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-btn me-search-prev" title="${t('findPrev')||'Previous'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button><button class="me-search-btn me-search-next" title="${t('findNext')||'Next'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button><span class="me-search-count"></span><button class="me-search-btn me-search-case me-active" title="${t('matchCase')||'Match Case'}">Aa</button><button class="me-search-btn me-search-word" title="${t('wholeWord')||'Whole Word'}">ab</button><button class="me-search-btn me-search-regex" title="Regex">.*</button><button class="me-search-btn me-search-close" title="${t('close')||'Close'}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></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-btn me-search-replace-one">${t('replace')||'Replace'}</button><button class="me-search-btn me-search-replace-all">${t('replaceAll')||'All'}</button></div>`;
|
||||
editor.el.appendChild(panel); state._panel = panel;
|
||||
_updateReplaceVisible();
|
||||
state._regexMode = false;
|
||||
state._regexMode = false; state._matchCase = true; state._wholeWord = 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 caseBtn = panel.querySelector('.me-search-case') as HTMLButtonElement;
|
||||
const wordBtn = panel.querySelector('.me-search-word') as HTMLButtonElement;
|
||||
regexBtn.addEventListener('click', () => {
|
||||
state._regexMode = !state._regexMode;
|
||||
regexBtn.classList.toggle('me-active', state._regexMode);
|
||||
lastIdxs = findAll();
|
||||
});
|
||||
caseBtn.addEventListener('click', () => {
|
||||
state._matchCase = !state._matchCase;
|
||||
caseBtn.classList.toggle('me-active', state._matchCase);
|
||||
lastIdxs = findAll();
|
||||
});
|
||||
wordBtn.addEventListener('click', () => {
|
||||
state._wholeWord = !state._wholeWord;
|
||||
wordBtn.classList.toggle('me-active', state._wholeWord);
|
||||
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; }
|
||||
const idxs: number[] = [];
|
||||
const re = buildPattern(q);
|
||||
if (!re) { ce.textContent = 'err'; return []; }
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(editor.textarea.value)) !== null) {
|
||||
idxs.push(m.index);
|
||||
if (m[0].length === 0) re.lastIndex++;
|
||||
}
|
||||
ce.textContent = idxs.length ? `${idxs.length}` : '0';
|
||||
return idxs;
|
||||
@@ -282,30 +366,48 @@ const searchReplacePlugin: Plugin = {
|
||||
editor.textarea.focus();
|
||||
editor.textarea.setSelectionRange(idx, idx + (len || fi.value.length));
|
||||
};
|
||||
const matchLenAt = (idx: number): number => {
|
||||
const text = editor.textarea.value;
|
||||
const q = fi.value; if (!q) return 0;
|
||||
if (!state._regexMode) {
|
||||
if (state._matchCase && text.slice(idx, idx + q.length) !== q) return 0;
|
||||
if (!state._matchCase && text.slice(idx, idx + q.length).toLowerCase() !== q.toLowerCase()) return 0;
|
||||
if (state._wholeWord && !hasWordBoundary(text, idx, q.length)) return 0;
|
||||
return q.length;
|
||||
}
|
||||
const re = buildPattern(q); if (!re) return 0;
|
||||
re.lastIndex = idx;
|
||||
const m = re.exec(text);
|
||||
return m && m.index === idx ? m[0].length : 0;
|
||||
};
|
||||
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);
|
||||
selectAt(next, matchLenAt(next) || fi.value.length);
|
||||
};
|
||||
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);
|
||||
selectAt(prev, matchLenAt(prev) || 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);
|
||||
let matched = false;
|
||||
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) {
|
||||
const re = buildPattern(q);
|
||||
if (re) { re.lastIndex = s; const m = re.exec(ta.value); matched = !!(m && m.index === s); }
|
||||
} else {
|
||||
const sameCase = state._matchCase ? matchText === q : matchText.toLowerCase() === q.toLowerCase();
|
||||
matched = sameCase && (!state._wholeWord || hasWordBoundary(ta.value, s, q.length));
|
||||
}
|
||||
if (matched) {
|
||||
ta.value = ta.value.substring(0, s) + r + ta.value.substring(e);
|
||||
ta.setSelectionRange(s, s + r.length);
|
||||
}
|
||||
@@ -317,11 +419,9 @@ const searchReplacePlugin: Plugin = {
|
||||
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);
|
||||
}
|
||||
const re = buildPattern(q);
|
||||
if (!re) return;
|
||||
ta.value = ta.value.replace(re, () => r);
|
||||
ta.setSelectionRange(0,0); editor._value = ta.value;
|
||||
if (typeof editor._pushHistory === 'function') editor._pushHistory();
|
||||
if (typeof editor._render === 'function') editor._render();
|
||||
@@ -350,49 +450,55 @@ const searchReplacePlugin: Plugin = {
|
||||
};
|
||||
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;
|
||||
// Expose for tests via Symbol-keyed state (no public-property pollution)
|
||||
setPluginState(editor, K_SEARCH, { state, _open, _close, _onKeydown });
|
||||
},
|
||||
destroy(editor: any) {
|
||||
const state = (editor as any).__srState;
|
||||
if (state) {
|
||||
if (state._cleanup) { state._cleanup(); state._cleanup = null; }
|
||||
state._panel = null;
|
||||
destroy(editor: EditorLike) {
|
||||
const exposed = pluginState<{ state: any; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SEARCH);
|
||||
if (exposed) {
|
||||
const state = exposed.state;
|
||||
if (state) {
|
||||
if (state._cleanup) { state._cleanup(); state._cleanup = null; }
|
||||
state._panel = null;
|
||||
}
|
||||
if (exposed._onKeydown && editor && editor.textarea) {
|
||||
editor.textarea.removeEventListener('keydown', exposed._onKeydown);
|
||||
}
|
||||
deletePluginState(editor, K_SEARCH);
|
||||
}
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
const imagePastePlugin: Plugin = {
|
||||
name: 'imagePaste', version: '0.2.0', description: 'Paste image as base64', priority: 60,
|
||||
install(editor) {
|
||||
name: 'imagePaste', version: '0.2.1', description: 'Paste image as base64', priority: 60,
|
||||
install(editor, options: any = {}) {
|
||||
if (!editor || !editor.textarea || typeof document === 'undefined') return;
|
||||
const maxSizeKB: number = options.maxSizeKB || 500;
|
||||
const _onPaste = (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items; if (!items) return;
|
||||
for (const item of items) {
|
||||
if (item.type?.startsWith('image/')) { e.preventDefault();
|
||||
const reader = new FileReader(); reader.onload = () => { editor.insert(`\n`); };
|
||||
reader.readAsDataURL(item.getAsFile()!); break;
|
||||
if (item.type?.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
const sizeKB = file.size / 1024;
|
||||
if (maxSizeKB > 0 && sizeKB > maxSizeKB) {
|
||||
e.preventDefault();
|
||||
if (typeof editor.toast === 'function') editor.toast(`Image too large (${sizeKB.toFixed(0)}KB > ${maxSizeKB}KB)`, { type: 'warning' });
|
||||
break;
|
||||
}
|
||||
e.preventDefault();
|
||||
const reader = new FileReader(); reader.onload = () => { if (typeof editor.insert === 'function') editor.insert(`\n`); };
|
||||
reader.readAsDataURL(file); break;
|
||||
}
|
||||
}
|
||||
};
|
||||
editor.textarea.addEventListener('paste', _onPaste);
|
||||
(editor as any).__ipOnPaste = _onPaste;
|
||||
setPluginState(editor, K_IMAGE_PASTE, _onPaste);
|
||||
},
|
||||
destroy(editor: any) {
|
||||
const _onPaste = (editor as any).__ipOnPaste;
|
||||
destroy(editor: EditorLike) {
|
||||
const _onPaste = pluginState<(e: ClipboardEvent) => void>(editor, K_IMAGE_PASTE);
|
||||
if (_onPaste && editor?.textarea) editor.textarea.removeEventListener('paste', _onPaste);
|
||||
delete (editor as any).__ipOnPaste;
|
||||
deletePluginState(editor, K_IMAGE_PASTE);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -436,14 +542,14 @@ const shortcutHelpPlugin: Plugin = {
|
||||
};
|
||||
editor.textarea.addEventListener('keydown', _onKeydown);
|
||||
|
||||
(editor as any).__shState = { _panel, _open, _close, _onKeydown };
|
||||
setPluginState(editor, K_SHORTCUT, { _panel, _open, _close, _onKeydown });
|
||||
},
|
||||
destroy(editor: any) {
|
||||
const state = (editor as any).__shState;
|
||||
destroy(editor: EditorLike) {
|
||||
const state = pluginState<{ _panel: HTMLElement | null; _onKeydown: ((e: KeyboardEvent) => void) | null }>(editor, K_SHORTCUT);
|
||||
if (state) { if (state._panel) { state._panel.remove(); } }
|
||||
const _onKeydown = (editor as any).__shState?._onKeydown;
|
||||
const _onKeydown = state?._onKeydown;
|
||||
if (_onKeydown && editor?.textarea) editor.textarea.removeEventListener('keydown', _onKeydown);
|
||||
delete (editor as any).__shState;
|
||||
deletePluginState(editor, K_SHORTCUT);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -454,31 +560,32 @@ const fileSystemPlugin: Plugin = {
|
||||
const hasAPI = typeof window !== 'undefined' && typeof (window as any).showOpenFilePicker === 'function';
|
||||
let _fileHandle: any = null;
|
||||
|
||||
editor.openFile = async (opts: any = {}) => {
|
||||
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return null; }
|
||||
(editor as any).openFile = async (opts: any = {}) => {
|
||||
if (!hasAPI) { if (typeof editor.toast === 'function') 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 });
|
||||
_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 };
|
||||
if (typeof editor.setValue === 'function') 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 = {}) => {
|
||||
(editor as any).saveFile = async (opts: any = {}) => {
|
||||
let handle = _fileHandle;
|
||||
if (!handle || opts.saveAs) {
|
||||
if (!hasAPI) { editor.toast?.('File System Access API not supported', { type: 'warning' }); return false; }
|
||||
if (!hasAPI) { if (typeof editor.toast === 'function') 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' }); _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) { _fileHandle = null; if (!opts.saveAs) return editor.saveFile({ ...opts, saveAs: true }); console.error('Write 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) { _fileHandle = null; if (!opts.saveAs) return (editor as any).saveFile({ ...opts, saveAs: true }); console.error('Write error:', e); return false; }
|
||||
};
|
||||
editor.saveFileAs = (name?: string) => editor.saveFile({ saveAs: true, name });
|
||||
editor.getFileHandle = () => _fileHandle;
|
||||
(editor as any).__fsCleanup = { getHandle: () => _fileHandle };
|
||||
(editor as any).saveFileAs = (name?: string) => (editor as any).saveFile({ saveAs: true, name });
|
||||
(editor as any).getFileHandle = () => _fileHandle;
|
||||
setPluginState(editor, K_FILESYSTEM, { 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: EditorLike) {
|
||||
if (editor) { delete (editor as any).openFile; delete (editor as any).saveFile; delete (editor as any).saveFileAs; delete (editor as any).getFileHandle; }
|
||||
deletePluginState(editor, K_FILESYSTEM);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user