♻️ Refactor context menu
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import {useEditorStore} from '@/stores/editorStore';
|
import { useEditorStore } from '@/stores/editorStore';
|
||||||
import {useDocumentStore} from '@/stores/documentStore';
|
import { useDocumentStore } from '@/stores/documentStore';
|
||||||
import {useConfigStore} from '@/stores/configStore';
|
import { useConfigStore } from '@/stores/configStore';
|
||||||
import Toolbar from '@/components/toolbar/Toolbar.vue';
|
import Toolbar from '@/components/toolbar/Toolbar.vue';
|
||||||
import {useWindowStore} from "@/stores/windowStore";
|
import { useWindowStore } from '@/stores/windowStore';
|
||||||
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
||||||
import {useTabStore} from "@/stores/tabStore";
|
import { useTabStore } from '@/stores/tabStore';
|
||||||
|
import ContextMenu from './contextMenu/ContextMenu.vue';
|
||||||
|
import { contextMenuManager } from './contextMenu/manager';
|
||||||
|
|
||||||
const editorStore = useEditorStore();
|
const editorStore = useEditorStore();
|
||||||
const documentStore = useDocumentStore();
|
const documentStore = useDocumentStore();
|
||||||
@@ -21,30 +23,28 @@ const enableLoadingAnimation = computed(() => configStore.config.general.enableL
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!editorElement.value) return;
|
if (!editorElement.value) return;
|
||||||
|
|
||||||
// 从URL查询参数中获取documentId
|
|
||||||
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
|
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
|
||||||
|
|
||||||
// 初始化文档存储,优先使用URL参数中的文档ID
|
|
||||||
await documentStore.initialize(urlDocumentId);
|
await documentStore.initialize(urlDocumentId);
|
||||||
|
|
||||||
// 设置编辑器容器
|
|
||||||
editorStore.setEditorContainer(editorElement.value);
|
editorStore.setEditorContainer(editorElement.value);
|
||||||
|
|
||||||
await tabStore.initializeTab();
|
await tabStore.initializeTab();
|
||||||
});
|
});
|
||||||
|
|
||||||
// onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
// editorStore.clearAllEditors();
|
contextMenuManager.destroy();
|
||||||
// });
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<transition name="loading-fade">
|
<transition name="loading-fade">
|
||||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT" />
|
||||||
</transition>
|
</transition>
|
||||||
<div ref="editorElement" class="editor"></div>
|
<div ref="editorElement" class="editor"></div>
|
||||||
<Toolbar/>
|
<Toolbar />
|
||||||
|
<ContextMenu :portal-target="editorElement" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -61,6 +61,7 @@ onMounted(async () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,4 +84,3 @@ onMounted(async () => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
180
frontend/src/views/editor/contextMenu/ContextMenu.vue
Normal file
180
frontend/src/views/editor/contextMenu/ContextMenu.vue
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue';
|
||||||
|
import { contextMenuManager } from './manager';
|
||||||
|
import type { RenderMenuItem } from './menuSchema';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
portalTarget?: HTMLElement | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const menuState = contextMenuManager.useState();
|
||||||
|
const menuRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const adjustedPosition = ref({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
const isVisible = computed(() => menuState.value.visible);
|
||||||
|
const items = computed(() => menuState.value.items);
|
||||||
|
const position = computed(() => menuState.value.position);
|
||||||
|
const teleportTarget = computed<HTMLElement | string>(() => props.portalTarget ?? 'body');
|
||||||
|
|
||||||
|
watch(
|
||||||
|
position,
|
||||||
|
(newPosition) => {
|
||||||
|
adjustedPosition.value = { ...newPosition };
|
||||||
|
if (isVisible.value) {
|
||||||
|
nextTick(adjustMenuWithinViewport);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(isVisible, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
nextTick(adjustMenuWithinViewport);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const menuStyle = computed(() => ({
|
||||||
|
left: `${adjustedPosition.value.x}px`,
|
||||||
|
top: `${adjustedPosition.value.y}px`
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function adjustMenuWithinViewport() {
|
||||||
|
await nextTick();
|
||||||
|
const menuEl = menuRef.value;
|
||||||
|
if (!menuEl) return;
|
||||||
|
|
||||||
|
const rect = menuEl.getBoundingClientRect();
|
||||||
|
let nextX = adjustedPosition.value.x;
|
||||||
|
let nextY = adjustedPosition.value.y;
|
||||||
|
|
||||||
|
if (rect.right > window.innerWidth) {
|
||||||
|
nextX = Math.max(0, window.innerWidth - rect.width - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rect.bottom > window.innerHeight) {
|
||||||
|
nextY = Math.max(0, window.innerHeight - rect.height - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustedPosition.value = { x: nextX, y: nextY };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleItemClick(item: RenderMenuItem) {
|
||||||
|
if (item.type !== "action" || item.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contextMenuManager.runCommand(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOverlayMouseDown() {
|
||||||
|
contextMenuManager.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPropagation(event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport :to="teleportTarget">
|
||||||
|
<template v-if="isVisible">
|
||||||
|
<div class="cm-context-overlay" @mousedown="handleOverlayMouseDown" />
|
||||||
|
<div
|
||||||
|
ref="menuRef"
|
||||||
|
class="cm-context-menu show"
|
||||||
|
:style="menuStyle"
|
||||||
|
role="menu"
|
||||||
|
@contextmenu.prevent
|
||||||
|
@mousedown="stopPropagation"
|
||||||
|
>
|
||||||
|
<template v-for="item in items" :key="item.id">
|
||||||
|
<div v-if="item.type === 'separator'" class="cm-context-menu-divider" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="cm-context-menu-item"
|
||||||
|
:class="{ 'is-disabled': item.disabled }"
|
||||||
|
role="menuitem"
|
||||||
|
:aria-disabled="item.disabled ? 'true' : 'false'"
|
||||||
|
@click="handleItemClick(item)"
|
||||||
|
>
|
||||||
|
<div class="cm-context-menu-item-label">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="item.shortcut" class="cm-context-menu-item-shortcut">
|
||||||
|
{{ item.shortcut }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.cm-context-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9000;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
min-width: 180px;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: var(--settings-card-bg, #1c1c1e);
|
||||||
|
color: var(--settings-text, #f6f6f6);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 10000;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96);
|
||||||
|
transform-origin: top left;
|
||||||
|
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.12s ease, color 0.12s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu-item:hover {
|
||||||
|
background-color: var(--toolbar-button-hover);
|
||||||
|
color: var(--toolbar-text, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu-item.is-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu-item-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu-item-shortcut {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-context-menu-divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 0;
|
||||||
|
border: none;
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,585 +0,0 @@
|
|||||||
/**
|
|
||||||
* 上下文菜单视图实现
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { EditorView } from "@codemirror/view";
|
|
||||||
import { MenuItem } from "../contextMenu";
|
|
||||||
import "./contextMenu.css";
|
|
||||||
|
|
||||||
// 为Window对象添加cmSubmenus属性
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
cmSubmenus?: Map<string, HTMLElement>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 菜单项元素池,用于复用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<string, HTMLElement> = 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<HTMLElement> = 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<string, (view: EditorView) => 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);
|
|
||||||
}
|
|
||||||
@@ -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 {
|
function t(key: string): string {
|
||||||
return i18n.global.t(key);
|
return i18n.global.t(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取快捷键显示文本
|
function formatKeyBinding(keyBinding: string): string {
|
||||||
* @param command 命令ID
|
const systemStore = useSystemStore();
|
||||||
* @returns 快捷键显示文本
|
const isMac = systemStore.isMacOS;
|
||||||
*/
|
|
||||||
function getShortcutText(command: KeyBindingCommand): string {
|
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<KeyBindingCommand, string>();
|
||||||
|
|
||||||
|
|
||||||
|
function getShortcutText(command?: KeyBindingCommand): string {
|
||||||
|
if (command === undefined) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = shortcutCache.get(command);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keybindingStore = useKeybindingStore();
|
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) {
|
if (binding?.key) {
|
||||||
// 格式化快捷键显示
|
const formatted = formatKeyBinding(binding.key);
|
||||||
return formatKeyBinding(binding.key);
|
shortcutCache.set(command, formatted);
|
||||||
|
return formatted;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("An error occurred while getting the shortcut:", error);
|
console.warn("An error occurred while getting the shortcut:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
shortcutCache.set(command, "");
|
||||||
return "";
|
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 getBuiltinMenuNodes(): MenuSchemaNode[] {
|
||||||
/**
|
|
||||||
* 创建编辑菜单项
|
|
||||||
*/
|
|
||||||
function createEditItems(): MenuItem[] {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: t("keybindings.commands.blockCopy"),
|
id: "copy",
|
||||||
|
labelKey: "keybindings.commands.blockCopy",
|
||||||
command: copyCommand,
|
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,
|
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,
|
command: pasteCommand,
|
||||||
shortcut: getShortcutText(KeyBindingCommand.BlockPasteCommand)
|
shortcutCommand: KeyBindingCommand.BlockPasteCommand,
|
||||||
}
|
visible: (context) => context.isEditable
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建历史操作菜单项
|
|
||||||
*/
|
|
||||||
function createHistoryItems(): MenuItem[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: t("keybindings.commands.historyUndo"),
|
|
||||||
command: undo,
|
|
||||||
shortcut: getShortcutText(KeyBindingCommand.HistoryUndoCommand)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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,
|
command: redo,
|
||||||
shortcut: getShortcutText(KeyBindingCommand.HistoryRedoCommand)
|
shortcutCommand: KeyBindingCommand.HistoryRedoCommand,
|
||||||
|
visible: (context) => context.isEditable
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let builtinMenuRegistered = false;
|
||||||
|
|
||||||
/**
|
function ensureBuiltinMenuRegistered(): void {
|
||||||
* 创建主菜单项
|
if (builtinMenuRegistered) return;
|
||||||
*/
|
registerMenuNodes(getBuiltinMenuNodes());
|
||||||
function createMainMenuItems(): MenuItem[] {
|
builtinMenuRegistered = true;
|
||||||
// 基本编辑操作放在主菜单
|
|
||||||
const basicItems = createEditItems();
|
|
||||||
|
|
||||||
// 历史操作放在主菜单
|
|
||||||
const historyItems = createHistoryItems();
|
|
||||||
|
|
||||||
// 构建主菜单
|
|
||||||
return [
|
|
||||||
...basicItems,
|
|
||||||
...historyItems
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建编辑器上下文菜单
|
|
||||||
*/
|
|
||||||
export function createEditorContextMenu(): Extension {
|
export function createEditorContextMenu(): Extension {
|
||||||
// 为编辑器添加右键事件处理
|
ensureBuiltinMenuRegistered();
|
||||||
|
|
||||||
return EditorView.domEventHandlers({
|
return EditorView.domEventHandlers({
|
||||||
contextmenu: (event, view) => {
|
contextmenu: (event, view) => {
|
||||||
// 阻止默认右键菜单
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
// 获取菜单项
|
const context = createMenuContext(view, event as MouseEvent);
|
||||||
const menuItems = createMainMenuItems();
|
const menuItems = buildRegisteredMenu(context, {
|
||||||
|
translate: t,
|
||||||
// 显示上下文菜单
|
formatShortcut: getShortcutText
|
||||||
|
});
|
||||||
|
|
||||||
|
if (menuItems.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
showContextMenu(view, event.clientX, event.clientY, menuItems);
|
showContextMenu(view, event.clientX, event.clientY, menuItems);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export default createEditorContextMenu;
|
||||||
* 默认导出
|
|
||||||
*/
|
|
||||||
export default createEditorContextMenu;
|
|
||||||
|
|||||||
88
frontend/src/views/editor/contextMenu/manager.ts
Normal file
88
frontend/src/views/editor/contextMenu/manager.ts
Normal file
@@ -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<ContextMenuState> = 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);
|
||||||
|
}
|
||||||
102
frontend/src/views/editor/contextMenu/menuSchema.ts
Normal file
102
frontend/src/views/editor/contextMenu/menuSchema.ts
Normal file
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package services
|
package helper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -3,12 +3,13 @@ package services
|
|||||||
import (
|
import (
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
"github.com/wailsapp/wails/v3/pkg/services/log"
|
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||||
|
"voidraft/internal/common/helper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DialogService 对话框服务,处理文件选择等对话框操作
|
// DialogService 对话框服务,处理文件选择等对话框操作
|
||||||
type DialogService struct {
|
type DialogService struct {
|
||||||
logger *log.LogService
|
logger *log.LogService
|
||||||
windowHelper *WindowHelper
|
windowHelper *helper.WindowHelper
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDialogService 创建新的对话框服务实例
|
// NewDialogService 创建新的对话框服务实例
|
||||||
@@ -19,7 +20,7 @@ func NewDialogService(logger *log.LogService) *DialogService {
|
|||||||
|
|
||||||
return &DialogService{
|
return &DialogService{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
windowHelper: NewWindowHelper(),
|
windowHelper: helper.NewWindowHelper(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
"voidraft/internal/common/helper"
|
||||||
"voidraft/internal/common/hotkey"
|
"voidraft/internal/common/hotkey"
|
||||||
"voidraft/internal/models"
|
"voidraft/internal/models"
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ import (
|
|||||||
type HotkeyService struct {
|
type HotkeyService struct {
|
||||||
logger *log.LogService
|
logger *log.LogService
|
||||||
configService *ConfigService
|
configService *ConfigService
|
||||||
windowHelper *WindowHelper
|
windowHelper *helper.WindowHelper
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
currentHotkey *models.HotkeyCombo
|
currentHotkey *models.HotkeyCombo
|
||||||
@@ -45,7 +46,7 @@ func NewHotkeyService(configService *ConfigService, logger *log.LogService) *Hot
|
|||||||
return &HotkeyService{
|
return &HotkeyService{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
configService: configService,
|
configService: configService,
|
||||||
windowHelper: NewWindowHelper(),
|
windowHelper: helper.NewWindowHelper(),
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ package services
|
|||||||
import (
|
import (
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
"github.com/wailsapp/wails/v3/pkg/services/log"
|
"github.com/wailsapp/wails/v3/pkg/services/log"
|
||||||
|
"voidraft/internal/common/helper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TrayService 系统托盘服务
|
// TrayService 系统托盘服务
|
||||||
type TrayService struct {
|
type TrayService struct {
|
||||||
logger *log.LogService
|
logger *log.LogService
|
||||||
configService *ConfigService
|
configService *ConfigService
|
||||||
windowHelper *WindowHelper
|
windowHelper *helper.WindowHelper
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTrayService 创建新的系统托盘服务实例
|
// NewTrayService 创建新的系统托盘服务实例
|
||||||
@@ -17,7 +18,7 @@ func NewTrayService(logger *log.LogService, configService *ConfigService) *TrayS
|
|||||||
return &TrayService{
|
return &TrayService{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
configService: configService,
|
configService: configService,
|
||||||
windowHelper: NewWindowHelper(),
|
windowHelper: helper.NewWindowHelper(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"voidraft/internal/common/helper"
|
||||||
"voidraft/internal/models"
|
"voidraft/internal/models"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
@@ -25,7 +26,7 @@ const (
|
|||||||
type WindowSnapService struct {
|
type WindowSnapService struct {
|
||||||
logger *log.LogService
|
logger *log.LogService
|
||||||
configService *ConfigService
|
configService *ConfigService
|
||||||
windowHelper *WindowHelper
|
windowHelper *helper.WindowHelper
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
// 吸附配置
|
// 吸附配置
|
||||||
@@ -75,7 +76,7 @@ func NewWindowSnapService(logger *log.LogService, configService *ConfigService)
|
|||||||
wss := &WindowSnapService{
|
wss := &WindowSnapService{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
configService: configService,
|
configService: configService,
|
||||||
windowHelper: NewWindowHelper(),
|
windowHelper: helper.NewWindowHelper(),
|
||||||
snapEnabled: snapEnabled,
|
snapEnabled: snapEnabled,
|
||||||
baseThresholdRatio: 0.025, // 2.5%的主窗口宽度作为基础阈值
|
baseThresholdRatio: 0.025, // 2.5%的主窗口宽度作为基础阈值
|
||||||
minThreshold: 8, // 最小8像素(小屏幕保底)
|
minThreshold: 8, // 最小8像素(小屏幕保底)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
"voidraft/internal/common/helper"
|
||||||
"voidraft/internal/models"
|
"voidraft/internal/models"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v3/pkg/application"
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
@@ -42,7 +43,7 @@ func createTestService() *WindowSnapService {
|
|||||||
service := &WindowSnapService{
|
service := &WindowSnapService{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
configService: nil, // 测试中不需要实际的配置服务
|
configService: nil, // 测试中不需要实际的配置服务
|
||||||
windowHelper: NewWindowHelper(),
|
windowHelper: helper.NewWindowHelper(),
|
||||||
snapEnabled: true,
|
snapEnabled: true,
|
||||||
baseThresholdRatio: 0.025,
|
baseThresholdRatio: 0.025,
|
||||||
minThreshold: 8,
|
minThreshold: 8,
|
||||||
|
|||||||
Reference in New Issue
Block a user