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:
2026-08-09 08:59:37 +08:00
parent ddc650feeb
commit fae31bba60
12 changed files with 972 additions and 447 deletions
+103
View File
@@ -0,0 +1,103 @@
/**
* MetonaEditor Context Menu — right-click menu
* @module context-menu
* @version 0.2.5
*/
import { t as i18nT } from './i18n';
import type { MarkdownEditor } from './core';
export function registerContextMenu(this: MarkdownEditor, items: any[] = []): void {
this._contextMenuItems = items;
}
export function bindContextMenu(this: MarkdownEditor): 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));
}
export function showContextMenu(this: MarkdownEditor, 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: i18nT('cut') || 'Cut', action: 'cut', shortcut: 'Ctrl+X', disabled: !hasSelection },
{ label: i18nT('copy') || 'Copy', action: 'copy', shortcut: 'Ctrl+C', disabled: !hasSelection },
{ label: i18nT('paste') || 'Paste', action: 'paste', shortcut: 'Ctrl+V', disabled: !!this.config.readOnly },
{ label: i18nT('selectAll') || '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 });
}
export function hideContextMenu(this: MarkdownEditor): void {
const menu = document.querySelector('.me-context-menu');
if (menu) menu.remove();
}
export function execContextAction(this: MarkdownEditor, 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;
}
}
// ============ Prototype installation ============
export const installContextMenu = (proto: any): void => {
proto.registerContextMenu = registerContextMenu;
proto._bindContextMenu = bindContextMenu;
proto._showContextMenu = showContextMenu;
proto._hideContextMenu = hideContextMenu;
proto._execContextAction = execContextAction;
};