README: - badge 测试数 768→780,目录补语法高亮章节 - 特性修正:解析器 99% 行覆盖、六语言、16 个高亮语言标识 - 事件表补 selectionChange / cursorMove - 插件表补 exportPDF / searchReplace 大小写全字 / imagePaste maxSizeKB - XSS 章节补属性级注入防护说明 - 构建章节补 npm run bench - 源码结构补 5 个新模块(commands/floating-toolbar/context-menu/outline/highlight) site: - index.html: 测试数/gzip 体积更正(42KB)、新增语法高亮特性卡、 光标选区 API 速览卡、六语言文案 - demo.html: 启用内置高亮(highlight: MeEditor.highlight)、 新增 python/bash 代码块演示、v0.2.5 真实新特性列表、17 源文件/780 测试 - docs.html: 高亮章节补 16 标识与 CSS 定制示例、exportPDF/maxSizeKB/ 大小写全字说明、XSS 属性注入、静态 API 补 highlight 导入 src: - styles.ts: 新增 me-hl-* 高亮配色(深浅色自适应) - 修复复查引入的 TS 错误(普通函数 this 返回类型非法)
105 lines
4.3 KiB
TypeScript
105 lines
4.3 KiB
TypeScript
/**
|
|
* 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[] = []): MarkdownEditor {
|
|
this._contextMenuItems = items;
|
|
return this;
|
|
}
|
|
|
|
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;
|
|
};
|