diff --git a/frontend/src/views/editor/Editor.vue b/frontend/src/views/editor/Editor.vue
index 8cc94e2..96ce060 100644
--- a/frontend/src/views/editor/Editor.vue
+++ b/frontend/src/views/editor/Editor.vue
@@ -1,12 +1,14 @@
@@ -61,6 +61,7 @@ onMounted(async () => {
width: 100%;
flex: 1;
overflow: hidden;
+ position: relative;
}
}
@@ -83,4 +84,3 @@ onMounted(async () => {
opacity: 0;
}
-
diff --git a/frontend/src/views/editor/contextMenu/ContextMenu.vue b/frontend/src/views/editor/contextMenu/ContextMenu.vue
new file mode 100644
index 0000000..be8371b
--- /dev/null
+++ b/frontend/src/views/editor/contextMenu/ContextMenu.vue
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/views/editor/contextMenu/contextMenu.css b/frontend/src/views/editor/contextMenu/contextMenu.css
deleted file mode 100644
index d330162..0000000
--- a/frontend/src/views/editor/contextMenu/contextMenu.css
+++ /dev/null
@@ -1,156 +0,0 @@
-/**
- * 编辑器上下文菜单样式
- * 支持系统主题自动适配
- */
-
-.cm-context-menu {
- position: fixed;
- background-color: var(--settings-card-bg);
- color: var(--settings-text);
- border: 1px solid var(--border-color);
- border-radius: 6px;
- padding: 4px 0;
- /* 优化阴影效果,只在右下角显示自然的阴影 */
- box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
- min-width: 200px;
- max-width: 320px;
- z-index: 9999;
- backdrop-filter: blur(8px);
- -webkit-backdrop-filter: blur(8px);
- opacity: 0;
- transform: scale(0.95);
- transition: opacity 0.15s ease-out, transform 0.15s ease-out;
- overflow: visible; /* 确保子菜单可以显示在外部 */
-}
-
-.cm-context-menu-item {
- padding: 8px 12px;
- cursor: pointer;
- display: flex;
- justify-content: space-between;
- align-items: center;
- font-size: 14px;
- transition: all 0.1s ease;
- position: relative;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.cm-context-menu-item:hover {
- background-color: var(--toolbar-button-hover);
- color: var(--toolbar-text);
-}
-
-.cm-context-menu-item-label {
- display: flex;
- align-items: center;
- gap: 8px;
-}
-
-.cm-context-menu-item-shortcut {
- opacity: 0.7;
- font-size: 12px;
- padding: 2px 4px;
- border-radius: 4px;
- background-color: var(--settings-input-bg);
- color: var(--settings-text-secondary);
- margin-left: 16px;
-}
-
-.cm-context-menu-item-ripple {
- position: absolute;
- border-radius: 50%;
- background-color: var(--selection-bg);
- width: 100px;
- height: 100px;
- opacity: 0.5;
- transform: scale(0);
- transition: transform 0.3s ease-out, opacity 0.3s ease-out;
-}
-
-/* 菜单分组标题样式 */
-.cm-context-menu-group-title {
- padding: 6px 12px;
- font-size: 12px;
- color: var(--text-secondary);
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- user-select: none;
-}
-
-/* 菜单分隔线样式 */
-.cm-context-menu-divider {
- height: 1px;
- background-color: var(--border-color);
- margin: 4px 0;
-}
-
-/* 子菜单样式 */
-.cm-context-submenu-container {
- position: relative;
-}
-
-.cm-context-menu-item-with-submenu {
- position: relative;
-}
-
-.cm-context-menu-item-with-submenu::after {
- content: "›";
- position: absolute;
- right: 12px;
- font-size: 16px;
- opacity: 0.7;
-}
-
-.cm-context-submenu {
- position: fixed; /* 改为fixed定位,避免受父元素影响 */
- min-width: 180px;
- opacity: 0;
- pointer-events: none;
- transform: translateX(10px);
- transition: opacity 0.2s ease, transform 0.2s ease;
- z-index: 10000;
- border-radius: 6px;
- background-color: var(--settings-card-bg);
- color: var(--settings-text);
- border: 1px solid var(--border-color);
- padding: 4px 0;
- /* 子菜单也使用相同的阴影效果 */
- box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.12);
- backdrop-filter: blur(8px);
- -webkit-backdrop-filter: blur(8px);
-}
-
-.cm-context-menu-item-with-submenu:hover .cm-context-submenu {
- opacity: 1;
- pointer-events: auto;
- transform: translateX(0);
-}
-
-/* 深色主题下的特殊样式 */
-:root[data-theme="dark"] .cm-context-menu {
- /* 深色主题下阴影更深,但仍然只在右下角 */
- box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
-}
-
-:root[data-theme="dark"] .cm-context-submenu {
- /* 深色主题下子菜单阴影 */
- box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
-}
-
-:root[data-theme="dark"] .cm-context-menu-divider {
- background-color: var(--dark-border-color);
- opacity: 0.6;
-}
-
-/* 动画相关类 */
-.cm-context-menu.show {
- opacity: 1;
- transform: scale(1);
-}
-
-.cm-context-menu.hide {
- opacity: 0;
-}
\ No newline at end of file
diff --git a/frontend/src/views/editor/contextMenu/contextMenuView.ts b/frontend/src/views/editor/contextMenu/contextMenuView.ts
deleted file mode 100644
index dbe9356..0000000
--- a/frontend/src/views/editor/contextMenu/contextMenuView.ts
+++ /dev/null
@@ -1,585 +0,0 @@
-/**
- * 上下文菜单视图实现
- */
-
-import { EditorView } from "@codemirror/view";
-import { MenuItem } from "../contextMenu";
-import "./contextMenu.css";
-
-// 为Window对象添加cmSubmenus属性
-declare global {
- interface Window {
- cmSubmenus?: Map;
- }
-}
-
-/**
- * 菜单项元素池,用于复用DOM元素
- */
-class MenuItemPool {
- private pool: HTMLElement[] = [];
- private maxPoolSize = 50; // 最大池大小
-
- /**
- * 获取或创建菜单项元素
- */
- get(): HTMLElement {
- if (this.pool.length > 0) {
- return this.pool.pop()!;
- }
-
- const menuItem = document.createElement("div");
- menuItem.className = "cm-context-menu-item";
- return menuItem;
- }
-
- /**
- * 回收菜单项元素
- */
- release(element: HTMLElement): void {
- if (this.pool.length < this.maxPoolSize) {
- // 清理元素状态
- element.className = "cm-context-menu-item";
- element.innerHTML = "";
- element.style.cssText = "";
-
- // 移除所有事件监听器(通过克隆节点)
- const cleanElement = element.cloneNode(false) as HTMLElement;
- this.pool.push(cleanElement);
- }
- }
-
- /**
- * 清空池
- */
- clear(): void {
- this.pool.length = 0;
- }
-}
-
-/**
- * 上下文菜单管理器
- */
-class ContextMenuManager {
- private static instance: ContextMenuManager;
-
- private menuElement: HTMLElement | null = null;
- private submenuPool: Map = new Map();
- private menuItemPool = new MenuItemPool();
- private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
- private keyDownHandler: ((e: KeyboardEvent) => void) | null = null;
- private currentView: EditorView | null = null;
- private activeSubmenus: Set = new Set();
- private ripplePool: HTMLElement[] = [];
-
- // 事件委托处理器
- private menuClickHandler: ((e: MouseEvent) => void) | null = null;
- private menuMouseHandler: ((e: MouseEvent) => void) | null = null;
-
- private constructor() {
- this.initializeEventHandlers();
- }
-
- /**
- * 获取单例实例
- */
- static getInstance(): ContextMenuManager {
- if (!ContextMenuManager.instance) {
- ContextMenuManager.instance = new ContextMenuManager();
- }
- return ContextMenuManager.instance;
- }
-
- /**
- * 初始化事件处理器
- */
- private initializeEventHandlers(): void {
- // 点击事件委托
- this.menuClickHandler = (e: MouseEvent) => {
- const target = e.target as HTMLElement;
- const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
-
- if (menuItem && menuItem.dataset.command) {
- e.preventDefault();
- e.stopPropagation();
-
- // 添加点击动画
- this.addRippleEffect(menuItem, e);
-
- // 执行命令
- const commandName = menuItem.dataset.command;
- const command = this.getCommandByName(commandName);
- if (command && this.currentView) {
- command(this.currentView);
- }
-
- // 隐藏菜单
- this.hide();
- }
- };
-
- // 鼠标事件委托
- this.menuMouseHandler = (e: MouseEvent) => {
- const target = e.target as HTMLElement;
- const menuItem = target.closest('.cm-context-menu-item') as HTMLElement;
-
- if (!menuItem) return;
-
- if (e.type === 'mouseenter') {
- this.handleMenuItemMouseEnter(menuItem);
- } else if (e.type === 'mouseleave') {
- this.handleMenuItemMouseLeave(menuItem, e);
- }
- };
-
- // 键盘事件处理器
- this.keyDownHandler = (e: KeyboardEvent) => {
- if (e.key === "Escape") {
- this.hide();
- }
- };
-
- // 点击外部关闭处理器
- this.clickOutsideHandler = (e: MouseEvent) => {
- if (this.menuElement && !this.isClickInsideMenu(e.target as Node)) {
- this.hide();
- }
- };
- }
-
- /**
- * 获取或创建主菜单元素
- */
- private getOrCreateMenuElement(): HTMLElement {
- if (!this.menuElement) {
- this.menuElement = document.createElement("div");
- this.menuElement.className = "cm-context-menu";
- this.menuElement.style.display = "none";
- document.body.appendChild(this.menuElement);
-
- // 阻止菜单内右键点击冒泡
- this.menuElement.addEventListener('contextmenu', (e) => {
- e.preventDefault();
- e.stopPropagation();
- return false;
- });
-
- // 添加事件委托
- this.menuElement.addEventListener('click', this.menuClickHandler!);
- this.menuElement.addEventListener('mouseenter', this.menuMouseHandler!, true);
- this.menuElement.addEventListener('mouseleave', this.menuMouseHandler!, true);
- }
- return this.menuElement;
- }
-
- /**
- * 创建或获取子菜单元素
- */
- private getOrCreateSubmenu(id: string): HTMLElement {
- if (!this.submenuPool.has(id)) {
- const submenu = document.createElement("div");
- submenu.className = "cm-context-menu cm-context-submenu";
- submenu.style.display = "none";
- document.body.appendChild(submenu);
- this.submenuPool.set(id, submenu);
-
- // 阻止子菜单点击事件冒泡
- submenu.addEventListener('click', (e) => {
- e.stopPropagation();
- });
-
- // 添加事件委托
- submenu.addEventListener('click', this.menuClickHandler!);
- submenu.addEventListener('mouseenter', this.menuMouseHandler!, true);
- submenu.addEventListener('mouseleave', this.menuMouseHandler!, true);
- }
- return this.submenuPool.get(id)!;
- }
-
- /**
- * 创建菜单项DOM元素
- */
- private createMenuItemElement(item: MenuItem): HTMLElement {
- const menuItem = this.menuItemPool.get();
-
- // 如果有子菜单,添加相应类
- if (item.submenu && item.submenu.length > 0) {
- menuItem.classList.add("cm-context-menu-item-with-submenu");
- }
-
- // 创建内容容器
- const contentContainer = document.createElement("div");
- contentContainer.className = "cm-context-menu-item-label";
-
- // 标签文本
- const label = document.createElement("span");
- label.textContent = item.label;
- contentContainer.appendChild(label);
- menuItem.appendChild(contentContainer);
-
- // 快捷键提示(如果有)
- if (item.shortcut) {
- const shortcut = document.createElement("span");
- shortcut.className = "cm-context-menu-item-shortcut";
- shortcut.textContent = item.shortcut;
- menuItem.appendChild(shortcut);
- }
-
- // 存储命令信息用于事件委托
- if (item.command) {
- menuItem.dataset.command = this.registerCommand(item.command);
- }
-
- // 处理子菜单
- if (item.submenu && item.submenu.length > 0) {
- const submenuId = `submenu-${item.label.replace(/\s+/g, '-').toLowerCase()}`;
- menuItem.dataset.submenuId = submenuId;
-
- const submenu = this.getOrCreateSubmenu(submenuId);
- this.populateSubmenu(submenu, item.submenu);
-
- // 记录子菜单
- if (!window.cmSubmenus) {
- window.cmSubmenus = new Map();
- }
- window.cmSubmenus.set(submenuId, submenu);
- }
-
- return menuItem;
- }
-
- /**
- * 填充子菜单内容
- */
- private populateSubmenu(submenu: HTMLElement, items: MenuItem[]): void {
- // 清空现有内容
- while (submenu.firstChild) {
- submenu.removeChild(submenu.firstChild);
- }
-
- // 添加子菜单项
- items.forEach(item => {
- const subMenuItemElement = this.createMenuItemElement(item);
- submenu.appendChild(subMenuItemElement);
- });
-
- // 初始状态设置为隐藏
- submenu.style.opacity = '0';
- submenu.style.pointerEvents = 'none';
- submenu.style.visibility = 'hidden';
- submenu.style.display = 'block';
- }
-
- /**
- * 命令注册和管理
- */
- private commands: Map void> = new Map();
- private commandCounter = 0;
-
- private registerCommand(command: (view: EditorView) => void): string {
- const commandId = `cmd_${this.commandCounter++}`;
- this.commands.set(commandId, command);
- return commandId;
- }
-
- private getCommandByName(commandId: string): ((view: EditorView) => void) | undefined {
- return this.commands.get(commandId);
- }
-
- /**
- * 处理菜单项鼠标进入事件
- */
- private handleMenuItemMouseEnter(menuItem: HTMLElement): void {
- const submenuId = menuItem.dataset.submenuId;
- if (!submenuId) return;
-
- const submenu = this.submenuPool.get(submenuId);
- if (!submenu) return;
-
- const rect = menuItem.getBoundingClientRect();
-
- // 计算子菜单位置
- submenu.style.left = `${rect.right}px`;
- submenu.style.top = `${rect.top}px`;
-
- // 检查子菜单是否会超出屏幕
- requestAnimationFrame(() => {
- const submenuRect = submenu.getBoundingClientRect();
- if (submenuRect.right > window.innerWidth) {
- submenu.style.left = `${rect.left - submenuRect.width}px`;
- }
-
- if (submenuRect.bottom > window.innerHeight) {
- const newTop = rect.top - (submenuRect.bottom - window.innerHeight);
- submenu.style.top = `${Math.max(0, newTop)}px`;
- }
- });
-
- // 显示子菜单
- submenu.style.opacity = '1';
- submenu.style.pointerEvents = 'auto';
- submenu.style.visibility = 'visible';
- submenu.style.transform = 'translateX(0)';
-
- this.activeSubmenus.add(submenu);
- }
-
- /**
- * 处理菜单项鼠标离开事件
- */
- private handleMenuItemMouseLeave(menuItem: HTMLElement, e: MouseEvent): void {
- const submenuId = menuItem.dataset.submenuId;
- if (!submenuId) return;
-
- const submenu = this.submenuPool.get(submenuId);
- if (!submenu) return;
-
- // 检查是否移动到子菜单上
- const toElement = e.relatedTarget as HTMLElement;
- if (submenu.contains(toElement)) {
- return;
- }
-
- this.hideSubmenu(submenu);
- }
-
- /**
- * 隐藏子菜单
- */
- private hideSubmenu(submenu: HTMLElement): void {
- submenu.style.opacity = '0';
- submenu.style.pointerEvents = 'none';
- submenu.style.transform = 'translateX(10px)';
-
- setTimeout(() => {
- if (submenu.style.opacity === '0') {
- submenu.style.visibility = 'hidden';
- }
- }, 200);
-
- this.activeSubmenus.delete(submenu);
- }
-
- /**
- * 添加点击波纹效果
- */
- private addRippleEffect(menuItem: HTMLElement, e: MouseEvent): void {
- let ripple: HTMLElement;
-
- if (this.ripplePool.length > 0) {
- ripple = this.ripplePool.pop()!;
- } else {
- ripple = document.createElement("div");
- ripple.className = "cm-context-menu-item-ripple";
- }
-
- // 计算相对位置
- const rect = menuItem.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const y = e.clientY - rect.top;
-
- ripple.style.left = (x - 50) + "px";
- ripple.style.top = (y - 50) + "px";
- ripple.style.transform = "scale(0)";
- ripple.style.opacity = "1";
-
- menuItem.appendChild(ripple);
-
- // 执行动画
- requestAnimationFrame(() => {
- ripple.style.transform = "scale(1)";
- ripple.style.opacity = "0";
-
- setTimeout(() => {
- if (ripple.parentNode === menuItem) {
- menuItem.removeChild(ripple);
- this.ripplePool.push(ripple);
- }
- }, 300);
- });
- }
-
- /**
- * 检查点击是否在菜单内
- */
- private isClickInsideMenu(target: Node): boolean {
- if (this.menuElement && this.menuElement.contains(target)) {
- return true;
- }
-
- // 检查是否在子菜单内
- for (const submenu of this.activeSubmenus) {
- if (submenu.contains(target)) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * 定位菜单元素
- */
- private positionMenu(menu: HTMLElement, clientX: number, clientY: number): void {
- const windowWidth = window.innerWidth;
- const windowHeight = window.innerHeight;
-
- let left = clientX;
- let top = clientY;
-
- requestAnimationFrame(() => {
- const menuWidth = menu.offsetWidth;
- const menuHeight = menu.offsetHeight;
-
- if (left + menuWidth > windowWidth) {
- left = windowWidth - menuWidth - 5;
- }
-
- if (top + menuHeight > windowHeight) {
- top = windowHeight - menuHeight - 5;
- }
-
- menu.style.left = `${left}px`;
- menu.style.top = `${top}px`;
- });
- }
-
- /**
- * 显示上下文菜单
- */
- show(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
- this.currentView = view;
-
- // 获取或创建菜单元素
- const menu = this.getOrCreateMenuElement();
-
- // 隐藏所有子菜单
- this.hideAllSubmenus();
-
- // 清空现有菜单项并回收到池中
- while (menu.firstChild) {
- const child = menu.firstChild as HTMLElement;
- if (child.classList.contains('cm-context-menu-item')) {
- this.menuItemPool.release(child);
- }
- menu.removeChild(child);
- }
-
- // 清空命令注册
- this.commands.clear();
- this.commandCounter = 0;
-
- // 添加主菜单项
- items.forEach(item => {
- const menuItemElement = this.createMenuItemElement(item);
- menu.appendChild(menuItemElement);
- });
-
- // 显示菜单
- menu.style.display = "block";
-
- // 定位菜单
- this.positionMenu(menu, clientX, clientY);
-
- // 添加全局事件监听器
- document.addEventListener("click", this.clickOutsideHandler!, true);
- document.addEventListener("keydown", this.keyDownHandler!);
-
- // 触发显示动画
- requestAnimationFrame(() => {
- if (menu) {
- menu.classList.add("show");
- }
- });
- }
-
- /**
- * 隐藏所有子菜单
- */
- private hideAllSubmenus(): void {
- this.activeSubmenus.forEach(submenu => {
- this.hideSubmenu(submenu);
- });
- this.activeSubmenus.clear();
-
- if (window.cmSubmenus) {
- window.cmSubmenus.forEach((submenu) => {
- submenu.style.opacity = '0';
- submenu.style.pointerEvents = 'none';
- submenu.style.visibility = 'hidden';
- submenu.style.transform = 'translateX(10px)';
- });
- }
- }
-
- /**
- * 隐藏上下文菜单
- */
- hide(): void {
- // 隐藏所有子菜单
- this.hideAllSubmenus();
-
- if (this.menuElement) {
- // 添加淡出动画
- this.menuElement.classList.remove("show");
- this.menuElement.classList.add("hide");
-
- // 等待动画完成后隐藏
- setTimeout(() => {
- if (this.menuElement) {
- this.menuElement.style.display = "none";
- this.menuElement.classList.remove("hide");
- }
- }, 150);
- }
-
- // 移除全局事件监听器
- if (this.clickOutsideHandler) {
- document.removeEventListener("click", this.clickOutsideHandler, true);
- }
-
- if (this.keyDownHandler) {
- document.removeEventListener("keydown", this.keyDownHandler);
- }
-
- this.currentView = null;
- }
-
- /**
- * 销毁管理器
- */
- destroy(): void {
- this.hide();
-
- if (this.menuElement) {
- document.body.removeChild(this.menuElement);
- this.menuElement = null;
- }
-
- this.submenuPool.forEach(submenu => {
- if (submenu.parentNode) {
- document.body.removeChild(submenu);
- }
- });
- this.submenuPool.clear();
-
- this.menuItemPool.clear();
- this.commands.clear();
- this.activeSubmenus.clear();
- this.ripplePool.length = 0;
-
- if (window.cmSubmenus) {
- window.cmSubmenus.clear();
- }
- }
-}
-
-// 获取单例实例
-const contextMenuManager = ContextMenuManager.getInstance();
-
-/**
- * 显示上下文菜单
- */
-export function showContextMenu(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
- contextMenuManager.show(view, clientX, clientY, items);
-}
\ No newline at end of file
diff --git a/frontend/src/views/editor/contextMenu/index.ts b/frontend/src/views/editor/contextMenu/index.ts
index d9ca7e8..e94b8e8 100644
--- a/frontend/src/views/editor/contextMenu/index.ts
+++ b/frontend/src/views/editor/contextMenu/index.ts
@@ -1,174 +1,141 @@
-/**
- * 编辑器上下文菜单实现
- * 提供基本的复制、剪切、粘贴等操作,支持动态快捷键显示
- */
+import { EditorView } from '@codemirror/view';
+import { Extension } from '@codemirror/state';
+import { copyCommand, cutCommand, pasteCommand } from '../extensions/codeblock/copyPaste';
+import { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
+import { useKeybindingStore } from '@/stores/keybindingStore';
+import { undo, redo } from '@codemirror/commands';
+import i18n from '@/i18n';
+import { useSystemStore } from '@/stores/systemStore';
+import { showContextMenu } from './manager';
+import {
+ buildRegisteredMenu,
+ createMenuContext,
+ registerMenuNodes
+} from './menuSchema';
+import type { MenuSchemaNode } from './menuSchema';
-import { EditorView } from "@codemirror/view";
-import { Extension } from "@codemirror/state";
-import { copyCommand, cutCommand, pasteCommand } from "../extensions/codeblock/copyPaste";
-import { KeyBindingCommand } from "@/../bindings/voidraft/internal/models/models";
-import { useKeybindingStore } from "@/stores/keybindingStore";
-import {
- undo, redo
-} from "@codemirror/commands";
-import i18n from "@/i18n";
-import {useSystemStore} from "@/stores/systemStore";
-/**
- * 菜单项类型定义
- */
-export interface MenuItem {
- /** 菜单项显示文本 */
- label: string;
-
- /** 点击时执行的命令 (如果有子菜单,可以为null) */
- command?: (view: EditorView) => boolean;
-
- /** 快捷键提示文本 (可选) */
- shortcut?: string;
-
- /** 子菜单项 (可选) */
- submenu?: MenuItem[];
-}
-
-// 导入相关功能
-import { showContextMenu } from "./contextMenuView";
-
-/**
- * 获取翻译文本
- * @param key 翻译键
- * @returns 翻译后的文本
- */
function t(key: string): string {
return i18n.global.t(key);
}
-/**
- * 获取快捷键显示文本
- * @param command 命令ID
- * @returns 快捷键显示文本
- */
-function getShortcutText(command: KeyBindingCommand): string {
+
+function formatKeyBinding(keyBinding: string): string {
+ const systemStore = useSystemStore();
+ const isMac = systemStore.isMacOS;
+
+ return keyBinding
+ .replace("Mod", isMac ? "Cmd" : "Ctrl")
+ .replace("Shift", "Shift")
+ .replace("Alt", isMac ? "Option" : "Alt")
+ .replace("Ctrl", isMac ? "Ctrl" : "Ctrl")
+ .replace(/-/g, " + ");
+}
+
+const shortcutCache = new Map();
+
+
+function getShortcutText(command?: KeyBindingCommand): string {
+ if (command === undefined) {
+ return "";
+ }
+
+ const cached = shortcutCache.get(command);
+ if (cached !== undefined) {
+ return cached;
+ }
+
try {
const keybindingStore = useKeybindingStore();
-
- // 如果找到该命令的快捷键配置
- const binding = keybindingStore.keyBindings.find(kb =>
- kb.command === command && kb.enabled
+ const binding = keybindingStore.keyBindings.find(
+ (kb) => kb.command === command && kb.enabled
);
-
- if (binding && binding.key) {
- // 格式化快捷键显示
- return formatKeyBinding(binding.key);
+
+ if (binding?.key) {
+ const formatted = formatKeyBinding(binding.key);
+ shortcutCache.set(command, formatted);
+ return formatted;
}
} catch (error) {
console.warn("An error occurred while getting the shortcut:", error);
}
-
+
+ shortcutCache.set(command, "");
return "";
}
-/**
- * 格式化快捷键显示
- * @param keyBinding 快捷键字符串
- * @returns 格式化后的显示文本
- */
-function formatKeyBinding(keyBinding: string): string {
- // 获取系统信息
- const systemStore = useSystemStore();
- const isMac = systemStore.isMacOS;
-
- // 替换修饰键名称为更友好的显示
- return keyBinding
- .replace("Mod", isMac ? "⌘" : "Ctrl")
- .replace("Shift", isMac ? "⇧" : "Shift")
- .replace("Alt", isMac ? "⌥" : "Alt")
- .replace("Ctrl", isMac ? "⌃" : "Ctrl")
- .replace(/-/g, " + ");
-}
-
-/**
- * 创建编辑菜单项
- */
-function createEditItems(): MenuItem[] {
+function getBuiltinMenuNodes(): MenuSchemaNode[] {
return [
{
- label: t("keybindings.commands.blockCopy"),
+ id: "copy",
+ labelKey: "keybindings.commands.blockCopy",
command: copyCommand,
- shortcut: getShortcutText(KeyBindingCommand.BlockCopyCommand)
+ shortcutCommand: KeyBindingCommand.BlockCopyCommand,
+ enabled: (context) => context.hasSelection
},
{
- label: t("keybindings.commands.blockCut"),
+ id: "cut",
+ labelKey: "keybindings.commands.blockCut",
command: cutCommand,
- shortcut: getShortcutText(KeyBindingCommand.BlockCutCommand)
+ shortcutCommand: KeyBindingCommand.BlockCutCommand,
+ visible: (context) => context.isEditable,
+ enabled: (context) => context.hasSelection && context.isEditable
},
{
- label: t("keybindings.commands.blockPaste"),
+ id: "paste",
+ labelKey: "keybindings.commands.blockPaste",
command: pasteCommand,
- shortcut: getShortcutText(KeyBindingCommand.BlockPasteCommand)
- }
- ];
-}
-
-/**
- * 创建历史操作菜单项
- */
-function createHistoryItems(): MenuItem[] {
- return [
- {
- label: t("keybindings.commands.historyUndo"),
- command: undo,
- shortcut: getShortcutText(KeyBindingCommand.HistoryUndoCommand)
+ shortcutCommand: KeyBindingCommand.BlockPasteCommand,
+ visible: (context) => context.isEditable
},
{
- label: t("keybindings.commands.historyRedo"),
+ id: "undo",
+ labelKey: "keybindings.commands.historyUndo",
+ command: undo,
+ shortcutCommand: KeyBindingCommand.HistoryUndoCommand,
+ visible: (context) => context.isEditable
+ },
+ {
+ id: "redo",
+ labelKey: "keybindings.commands.historyRedo",
command: redo,
- shortcut: getShortcutText(KeyBindingCommand.HistoryRedoCommand)
+ shortcutCommand: KeyBindingCommand.HistoryRedoCommand,
+ visible: (context) => context.isEditable
}
];
}
+let builtinMenuRegistered = false;
-/**
- * 创建主菜单项
- */
-function createMainMenuItems(): MenuItem[] {
- // 基本编辑操作放在主菜单
- const basicItems = createEditItems();
-
- // 历史操作放在主菜单
- const historyItems = createHistoryItems();
-
- // 构建主菜单
- return [
- ...basicItems,
- ...historyItems
- ];
+function ensureBuiltinMenuRegistered(): void {
+ if (builtinMenuRegistered) return;
+ registerMenuNodes(getBuiltinMenuNodes());
+ builtinMenuRegistered = true;
}
-/**
- * 创建编辑器上下文菜单
- */
+
export function createEditorContextMenu(): Extension {
- // 为编辑器添加右键事件处理
+ ensureBuiltinMenuRegistered();
+
return EditorView.domEventHandlers({
contextmenu: (event, view) => {
- // 阻止默认右键菜单
event.preventDefault();
-
- // 获取菜单项
- const menuItems = createMainMenuItems();
-
- // 显示上下文菜单
+
+ const context = createMenuContext(view, event as MouseEvent);
+ const menuItems = buildRegisteredMenu(context, {
+ translate: t,
+ formatShortcut: getShortcutText
+ });
+
+ if (menuItems.length === 0) {
+ return false;
+ }
+
showContextMenu(view, event.clientX, event.clientY, menuItems);
-
return true;
}
});
}
-/**
- * 默认导出
- */
-export default createEditorContextMenu;
\ No newline at end of file
+export default createEditorContextMenu;
diff --git a/frontend/src/views/editor/contextMenu/manager.ts b/frontend/src/views/editor/contextMenu/manager.ts
new file mode 100644
index 0000000..1f55c23
--- /dev/null
+++ b/frontend/src/views/editor/contextMenu/manager.ts
@@ -0,0 +1,88 @@
+import type { EditorView } from '@codemirror/view';
+import { readonly, shallowRef, type ShallowRef } from 'vue';
+import type { RenderMenuItem } from './menuSchema';
+
+interface MenuPosition {
+ x: number;
+ y: number;
+}
+
+interface ContextMenuState {
+ visible: boolean;
+ position: MenuPosition;
+ items: RenderMenuItem[];
+ view: EditorView | null;
+}
+
+class ContextMenuManager {
+ private state: ShallowRef = shallowRef({
+ visible: false,
+ position: { x: 0, y: 0 },
+ items: [] as RenderMenuItem[],
+ view: null as EditorView | null
+ });
+
+ useState() {
+ return readonly(this.state);
+ }
+
+ show(view: EditorView, clientX: number, clientY: number, items: RenderMenuItem[]): void {
+ this.state.value = {
+ visible: true,
+ position: { x: clientX, y: clientY },
+ items,
+ view
+ };
+ }
+
+ hide(): void {
+ if (!this.state.value.visible) {
+ return;
+ }
+
+ const previousPosition = this.state.value.position;
+ const view = this.state.value.view;
+ this.state.value = {
+ visible: false,
+ position: previousPosition,
+ items: [],
+ view: null
+ };
+
+ if (view) {
+ view.focus();
+ }
+ }
+
+ runCommand(item: RenderMenuItem): void {
+ if (item.type !== "action" || item.disabled) {
+ return;
+ }
+
+ const { view } = this.state.value;
+ if (item.command && view) {
+ item.command(view);
+ }
+ this.hide();
+ }
+
+ destroy(): void {
+ this.state.value = {
+ visible: false,
+ position: { x: 0, y: 0 },
+ items: [],
+ view: null
+ };
+ }
+}
+
+export const contextMenuManager = new ContextMenuManager();
+
+export function showContextMenu(
+ view: EditorView,
+ clientX: number,
+ clientY: number,
+ items: RenderMenuItem[]
+): void {
+ contextMenuManager.show(view, clientX, clientY, items);
+}
diff --git a/frontend/src/views/editor/contextMenu/menuSchema.ts b/frontend/src/views/editor/contextMenu/menuSchema.ts
new file mode 100644
index 0000000..bc9b7a7
--- /dev/null
+++ b/frontend/src/views/editor/contextMenu/menuSchema.ts
@@ -0,0 +1,102 @@
+import type { EditorView } from '@codemirror/view';
+import { EditorState } from '@codemirror/state';
+import type { KeyBindingCommand } from '@/../bindings/voidraft/internal/models/models';
+
+export interface MenuContext {
+ view: EditorView;
+ event: MouseEvent;
+ hasSelection: boolean;
+ selectionText: string;
+ isEditable: boolean;
+}
+
+export type MenuSchemaNode =
+ | {
+ id: string;
+ type?: "action";
+ labelKey: string;
+ command?: (view: EditorView) => boolean;
+ shortcutCommand?: KeyBindingCommand;
+ visible?: (context: MenuContext) => boolean;
+ enabled?: (context: MenuContext) => boolean;
+ }
+ | {
+ id: string;
+ type: "separator";
+ visible?: (context: MenuContext) => boolean;
+ };
+
+export interface RenderMenuItem {
+ id: string;
+ type: "action" | "separator";
+ label?: string;
+ shortcut?: string;
+ disabled?: boolean;
+ command?: (view: EditorView) => boolean;
+}
+
+interface MenuBuildOptions {
+ translate: (key: string) => string;
+ formatShortcut: (command?: KeyBindingCommand) => string;
+}
+
+const menuRegistry: MenuSchemaNode[] = [];
+
+export function createMenuContext(view: EditorView, event: MouseEvent): MenuContext {
+ const { state } = view;
+ const hasSelection = state.selection.ranges.some((range) => !range.empty);
+ const selectionText = hasSelection
+ ? state.sliceDoc(state.selection.main.from, state.selection.main.to)
+ : "";
+ const isEditable = !state.facet(EditorState.readOnly);
+
+ return {
+ view,
+ event,
+ hasSelection,
+ selectionText,
+ isEditable
+ };
+}
+
+export function registerMenuNodes(nodes: MenuSchemaNode[]): void {
+ menuRegistry.push(...nodes);
+}
+
+export function buildRegisteredMenu(
+ context: MenuContext,
+ options: MenuBuildOptions
+): RenderMenuItem[] {
+ return menuRegistry
+ .map((node) => convertNode(node, context, options))
+ .filter((item): item is RenderMenuItem => Boolean(item));
+}
+
+function convertNode(
+ node: MenuSchemaNode,
+ context: MenuContext,
+ options: MenuBuildOptions
+): RenderMenuItem | null {
+ if (node.visible && !node.visible(context)) {
+ return null;
+ }
+
+ if (node.type === "separator") {
+ return {
+ id: node.id,
+ type: "separator"
+ };
+ }
+
+ const disabled = node.enabled ? !node.enabled(context) : false;
+ const shortcut = options.formatShortcut(node.shortcutCommand);
+
+ return {
+ id: node.id,
+ type: "action",
+ label: options.translate(node.labelKey),
+ shortcut: shortcut || undefined,
+ disabled,
+ command: node.command
+ };
+}
diff --git a/internal/services/window_helper.go b/internal/common/helper/window_helper.go
similarity index 99%
rename from internal/services/window_helper.go
rename to internal/common/helper/window_helper.go
index b7ae2b1..783f80d 100644
--- a/internal/services/window_helper.go
+++ b/internal/common/helper/window_helper.go
@@ -1,4 +1,4 @@
-package services
+package helper
import (
"strconv"
diff --git a/internal/services/dialog_service.go b/internal/services/dialog_service.go
index 4c5e45b..a51a817 100644
--- a/internal/services/dialog_service.go
+++ b/internal/services/dialog_service.go
@@ -3,12 +3,13 @@ package services
import (
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/log"
+ "voidraft/internal/common/helper"
)
// DialogService 对话框服务,处理文件选择等对话框操作
type DialogService struct {
logger *log.LogService
- windowHelper *WindowHelper
+ windowHelper *helper.WindowHelper
}
// NewDialogService 创建新的对话框服务实例
@@ -19,7 +20,7 @@ func NewDialogService(logger *log.LogService) *DialogService {
return &DialogService{
logger: logger,
- windowHelper: NewWindowHelper(),
+ windowHelper: helper.NewWindowHelper(),
}
}
diff --git a/internal/services/hotkey_service.go b/internal/services/hotkey_service.go
index 1f0560f..0dd7fc1 100644
--- a/internal/services/hotkey_service.go
+++ b/internal/services/hotkey_service.go
@@ -7,6 +7,7 @@ import (
"sync"
"sync/atomic"
"time"
+ "voidraft/internal/common/helper"
"voidraft/internal/common/hotkey"
"voidraft/internal/models"
@@ -18,7 +19,7 @@ import (
type HotkeyService struct {
logger *log.LogService
configService *ConfigService
- windowHelper *WindowHelper
+ windowHelper *helper.WindowHelper
mu sync.RWMutex
currentHotkey *models.HotkeyCombo
@@ -45,7 +46,7 @@ func NewHotkeyService(configService *ConfigService, logger *log.LogService) *Hot
return &HotkeyService{
logger: logger,
configService: configService,
- windowHelper: NewWindowHelper(),
+ windowHelper: helper.NewWindowHelper(),
ctx: ctx,
cancel: cancel,
}
diff --git a/internal/services/tray_service.go b/internal/services/tray_service.go
index 5cac2a1..e8c95c3 100644
--- a/internal/services/tray_service.go
+++ b/internal/services/tray_service.go
@@ -3,13 +3,14 @@ package services
import (
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/log"
+ "voidraft/internal/common/helper"
)
// TrayService 系统托盘服务
type TrayService struct {
logger *log.LogService
configService *ConfigService
- windowHelper *WindowHelper
+ windowHelper *helper.WindowHelper
}
// NewTrayService 创建新的系统托盘服务实例
@@ -17,7 +18,7 @@ func NewTrayService(logger *log.LogService, configService *ConfigService) *TrayS
return &TrayService{
logger: logger,
configService: configService,
- windowHelper: NewWindowHelper(),
+ windowHelper: helper.NewWindowHelper(),
}
}
diff --git a/internal/services/window_snap_service.go b/internal/services/window_snap_service.go
index d19b402..90561f2 100644
--- a/internal/services/window_snap_service.go
+++ b/internal/services/window_snap_service.go
@@ -4,6 +4,7 @@ import (
"math"
"sync"
"time"
+ "voidraft/internal/common/helper"
"voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/application"
@@ -25,7 +26,7 @@ const (
type WindowSnapService struct {
logger *log.LogService
configService *ConfigService
- windowHelper *WindowHelper
+ windowHelper *helper.WindowHelper
mu sync.RWMutex
// 吸附配置
@@ -75,7 +76,7 @@ func NewWindowSnapService(logger *log.LogService, configService *ConfigService)
wss := &WindowSnapService{
logger: logger,
configService: configService,
- windowHelper: NewWindowHelper(),
+ windowHelper: helper.NewWindowHelper(),
snapEnabled: snapEnabled,
baseThresholdRatio: 0.025, // 2.5%的主窗口宽度作为基础阈值
minThreshold: 8, // 最小8像素(小屏幕保底)
diff --git a/internal/services/window_snap_service_test.go b/internal/services/window_snap_service_test.go
index d0da159..2b84d24 100644
--- a/internal/services/window_snap_service_test.go
+++ b/internal/services/window_snap_service_test.go
@@ -4,6 +4,7 @@ import (
"sync"
"testing"
"time"
+ "voidraft/internal/common/helper"
"voidraft/internal/models"
"github.com/wailsapp/wails/v3/pkg/application"
@@ -42,7 +43,7 @@ func createTestService() *WindowSnapService {
service := &WindowSnapService{
logger: logger,
configService: nil, // 测试中不需要实际的配置服务
- windowHelper: NewWindowHelper(),
+ windowHelper: helper.NewWindowHelper(),
snapEnabled: true,
baseThresholdRatio: 0.025,
minThreshold: 8,