♻️ Refactor context menu
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
||||
import {useEditorStore} from '@/stores/editorStore';
|
||||
import {useDocumentStore} from '@/stores/documentStore';
|
||||
import {useConfigStore} from '@/stores/configStore';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useEditorStore } from '@/stores/editorStore';
|
||||
import { useDocumentStore } from '@/stores/documentStore';
|
||||
import { useConfigStore } from '@/stores/configStore';
|
||||
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 {useTabStore} from "@/stores/tabStore";
|
||||
import { useTabStore } from '@/stores/tabStore';
|
||||
import ContextMenu from './contextMenu/ContextMenu.vue';
|
||||
import { contextMenuManager } from './contextMenu/manager';
|
||||
|
||||
const editorStore = useEditorStore();
|
||||
const documentStore = useDocumentStore();
|
||||
@@ -21,30 +23,28 @@ const enableLoadingAnimation = computed(() => configStore.config.general.enableL
|
||||
onMounted(async () => {
|
||||
if (!editorElement.value) return;
|
||||
|
||||
// 从URL查询参数中获取documentId
|
||||
const urlDocumentId = windowStore.currentDocumentId ? parseInt(windowStore.currentDocumentId) : undefined;
|
||||
|
||||
// 初始化文档存储,优先使用URL参数中的文档ID
|
||||
await documentStore.initialize(urlDocumentId);
|
||||
|
||||
// 设置编辑器容器
|
||||
editorStore.setEditorContainer(editorElement.value);
|
||||
|
||||
await tabStore.initializeTab();
|
||||
});
|
||||
|
||||
// onBeforeUnmount(() => {
|
||||
// editorStore.clearAllEditors();
|
||||
// });
|
||||
onBeforeUnmount(() => {
|
||||
contextMenuManager.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-container">
|
||||
<transition name="loading-fade">
|
||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT" />
|
||||
</transition>
|
||||
<div ref="editorElement" class="editor"></div>
|
||||
<Toolbar/>
|
||||
<Toolbar />
|
||||
<ContextMenu :portal-target="editorElement" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -61,6 +61,7 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,4 +84,3 @@ onMounted(async () => {
|
||||
opacity: 0;
|
||||
}
|
||||
</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 {
|
||||
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<KeyBindingCommand, string>();
|
||||
|
||||
|
||||
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;
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user