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,