🎨 Refactor and optimize code
This commit is contained in:
@@ -16,22 +16,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="hasOtherTabs" class="menu-item" @click="handleMenuClick('closeOthers')">
|
<div v-if="hasOtherTabs" class="menu-item" @click="handleMenuClick('closeOthers')">
|
||||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M3 6h18M3 12h18M3 18h18"/>
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
||||||
<path d="M18 6L6 18M6 6l12 12"/>
|
<path d="M9 9l6 6M15 9l-6 6"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="menu-text">{{ t('tabs.contextMenu.closeOthers') }}</span>
|
<span class="menu-text">{{ t('tabs.contextMenu.closeOthers') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="hasTabsToLeft" class="menu-item" @click="handleMenuClick('closeLeft')">
|
<div v-if="hasTabsToLeft" class="menu-item" @click="handleMenuClick('closeLeft')">
|
||||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M15 18l-6-6 6-6"/>
|
<path d="M15 18l-6-6 6-6"/>
|
||||||
<path d="M18 6L6 18M6 6l12 12"/>
|
<path d="M9 18l-6-6 6-6"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="menu-text">{{ t('tabs.contextMenu.closeLeft') }}</span>
|
<span class="menu-text">{{ t('tabs.contextMenu.closeLeft') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="hasTabsToRight" class="menu-item" @click="handleMenuClick('closeRight')">
|
<div v-if="hasTabsToRight" class="menu-item" @click="handleMenuClick('closeRight')">
|
||||||
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="menu-icon" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M9 18l6-6-6-6"/>
|
<path d="M9 18l6-6-6-6"/>
|
||||||
<path d="M18 6L6 18M6 6l12 12"/>
|
<path d="M15 18l6-6-6-6"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="menu-text">{{ t('tabs.contextMenu.closeRight') }}</span>
|
<span class="menu-text">{{ t('tabs.contextMenu.closeRight') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +136,7 @@ onUnmounted(() => {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 140px;
|
min-width: 100px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ onUnmounted(() => {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-primary);
|
color: var(--text-muted);
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ onUnmounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-muted);
|
||||||
transition: color 0.15s ease;
|
transition: color 0.15s ease;
|
||||||
|
|
||||||
.menu-item:hover & {
|
.menu-item:hover & {
|
||||||
@@ -178,51 +178,4 @@ onUnmounted(() => {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 主题适配 */
|
|
||||||
:root[data-theme="dark"] .tab-context-menu {
|
|
||||||
background-color: var(--bg-secondary);
|
|
||||||
border-color: var(--border-color);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
||||||
|
|
||||||
.menu-item {
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--toolbar-button-hover);
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-icon {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
.menu-item:hover & {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="light"] .tab-context-menu {
|
|
||||||
background-color: var(--bg-secondary);
|
|
||||||
border-color: var(--border-color);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
.menu-item {
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--toolbar-button-hover);
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-icon {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
.menu-item:hover & {
|
|
||||||
color: #000000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -236,7 +236,7 @@ const handleContextMenu = (event: MouseEvent) => {
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--error-color);
|
background-color: var(--error-color);
|
||||||
color: white;
|
color: var(--text-muted);
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export const useDocumentStore = defineStore('document', () => {
|
|||||||
// === 错误处理 ===
|
// === 错误处理 ===
|
||||||
const setError = (docId: number, message: string) => {
|
const setError = (docId: number, message: string) => {
|
||||||
selectorError.value = {docId, message};
|
selectorError.value = {docId, message};
|
||||||
|
// 3秒后自动清除错误状态
|
||||||
|
setTimeout(() => {
|
||||||
|
if (selectorError.value?.docId === docId) {
|
||||||
|
selectorError.value = null;
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearError = () => {
|
const clearError = () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {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';
|
||||||
@@ -17,6 +17,8 @@ const tabStore = useTabStore();
|
|||||||
|
|
||||||
const editorElement = ref<HTMLElement | null>(null);
|
const editorElement = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
|
||||||
|
|
||||||
// 创建滚轮缩放处理器
|
// 创建滚轮缩放处理器
|
||||||
const wheelHandler = createWheelZoomHandler(
|
const wheelHandler = createWheelZoomHandler(
|
||||||
configStore.increaseFontSize,
|
configStore.increaseFontSize,
|
||||||
@@ -53,7 +55,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<LoadingScreen v-if="editorStore.isLoading && configStore.config.general?.enableLoadingAnimation" text="VOIDRAFT"/>
|
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
||||||
<div ref="editorElement" class="editor"></div>
|
<div ref="editorElement" class="editor"></div>
|
||||||
<Toolbar/>
|
<Toolbar/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 上下文菜单视图实现
|
* 上下文菜单视图实现
|
||||||
* 处理菜单的创建、定位和事件绑定
|
|
||||||
* 优化为单例模式,避免频繁创建和销毁DOM元素
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EditorView } from "@codemirror/view";
|
import { EditorView } from "@codemirror/view";
|
||||||
@@ -15,97 +13,253 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 菜单DOM元素缓存
|
|
||||||
let menuElement: HTMLElement | null = null;
|
|
||||||
let clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
|
|
||||||
// 子菜单缓存池
|
|
||||||
const submenuPool: Map<string, HTMLElement> = new Map();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取或创建菜单DOM元素
|
* 菜单项元素池,用于复用DOM元素
|
||||||
*/
|
*/
|
||||||
function getOrCreateMenuElement(): HTMLElement {
|
class MenuItemPool {
|
||||||
if (!menuElement) {
|
private pool: HTMLElement[] = [];
|
||||||
menuElement = document.createElement("div");
|
private maxPoolSize = 50; // 最大池大小
|
||||||
menuElement.className = "cm-context-menu";
|
|
||||||
menuElement.style.display = "none";
|
|
||||||
document.body.appendChild(menuElement);
|
|
||||||
|
|
||||||
// 阻止菜单内右键点击冒泡
|
/**
|
||||||
menuElement.addEventListener('contextmenu', (e) => {
|
* 获取或创建菜单项元素
|
||||||
e.preventDefault();
|
*/
|
||||||
e.stopPropagation();
|
get(): HTMLElement {
|
||||||
return false;
|
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;
|
||||||
}
|
}
|
||||||
return menuElement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建或获取子菜单元素
|
* 上下文菜单管理器
|
||||||
* @param id 子菜单唯一标识
|
|
||||||
*/
|
*/
|
||||||
function getOrCreateSubmenu(id: string): HTMLElement {
|
class ContextMenuManager {
|
||||||
if (!submenuPool.has(id)) {
|
private static instance: ContextMenuManager;
|
||||||
const submenu = document.createElement("div");
|
|
||||||
submenu.className = "cm-context-menu cm-context-submenu";
|
|
||||||
submenu.style.display = "none";
|
|
||||||
document.body.appendChild(submenu);
|
|
||||||
submenuPool.set(id, submenu);
|
|
||||||
|
|
||||||
// 阻止子菜单点击事件冒泡
|
private menuElement: HTMLElement | null = null;
|
||||||
submenu.addEventListener('click', (e) => {
|
private submenuPool: Map<string, HTMLElement> = new Map();
|
||||||
e.stopPropagation();
|
private menuItemPool = new MenuItemPool();
|
||||||
});
|
private clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
|
||||||
}
|
private keyDownHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||||
return submenuPool.get(id)!;
|
private currentView: EditorView | null = null;
|
||||||
}
|
private activeSubmenus: Set<HTMLElement> = new Set();
|
||||||
|
private ripplePool: HTMLElement[] = [];
|
||||||
|
|
||||||
/**
|
// 事件委托处理器
|
||||||
* 创建菜单项DOM元素
|
private menuClickHandler: ((e: MouseEvent) => void) | null = null;
|
||||||
*/
|
private menuMouseHandler: ((e: MouseEvent) => void) | null = null;
|
||||||
function createMenuItemElement(item: MenuItem, view: EditorView): HTMLElement {
|
|
||||||
// 创建菜单项容器
|
|
||||||
const menuItem = document.createElement("div");
|
|
||||||
menuItem.className = "cm-context-menu-item";
|
|
||||||
|
|
||||||
// 如果有子菜单,添加相应类
|
private constructor() {
|
||||||
if (item.submenu && item.submenu.length > 0) {
|
this.initializeEventHandlers();
|
||||||
menuItem.classList.add("cm-context-menu-item-with-submenu");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建内容容器
|
/**
|
||||||
const contentContainer = document.createElement("div");
|
* 获取单例实例
|
||||||
contentContainer.className = "cm-context-menu-item-label";
|
*/
|
||||||
|
static getInstance(): ContextMenuManager {
|
||||||
// 标签文本
|
if (!ContextMenuManager.instance) {
|
||||||
const label = document.createElement("span");
|
ContextMenuManager.instance = new ContextMenuManager();
|
||||||
label.textContent = item.label;
|
}
|
||||||
contentContainer.appendChild(label);
|
return ContextMenuManager.instance;
|
||||||
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.submenu && item.submenu.length > 0) {
|
* 初始化事件处理器
|
||||||
// 使用菜单项标签作为子菜单ID
|
*/
|
||||||
const submenuId = `submenu-${item.label.replace(/\s+/g, '-').toLowerCase()}`;
|
private initializeEventHandlers(): void {
|
||||||
const submenu = getOrCreateSubmenu(submenuId);
|
// 点击事件委托
|
||||||
|
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) {
|
while (submenu.firstChild) {
|
||||||
submenu.removeChild(submenu.firstChild);
|
submenu.removeChild(submenu.firstChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加子菜单项
|
// 添加子菜单项
|
||||||
item.submenu.forEach(subItem => {
|
items.forEach(item => {
|
||||||
const subMenuItemElement = createMenuItemElement(subItem, view);
|
const subMenuItemElement = this.createMenuItemElement(item);
|
||||||
submenu.appendChild(subMenuItemElement);
|
submenu.appendChild(subMenuItemElement);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,313 +268,318 @@ function createMenuItemElement(item: MenuItem, view: EditorView): HTMLElement {
|
|||||||
submenu.style.pointerEvents = 'none';
|
submenu.style.pointerEvents = 'none';
|
||||||
submenu.style.visibility = 'hidden';
|
submenu.style.visibility = 'hidden';
|
||||||
submenu.style.display = 'block';
|
submenu.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
// 当鼠标悬停在菜单项上时,显示子菜单
|
/**
|
||||||
menuItem.addEventListener('mouseenter', () => {
|
* 命令注册和管理
|
||||||
const rect = menuItem.getBoundingClientRect();
|
*/
|
||||||
|
private commands: Map<string, (view: EditorView) => void> = new Map();
|
||||||
|
private commandCounter = 0;
|
||||||
|
|
||||||
// 计算子菜单位置
|
private registerCommand(command: (view: EditorView) => void): string {
|
||||||
submenu.style.left = `${rect.right}px`;
|
const commandId = `cmd_${this.commandCounter++}`;
|
||||||
submenu.style.top = `${rect.top}px`;
|
this.commands.set(commandId, command);
|
||||||
|
return commandId;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查子菜单是否会超出屏幕右侧
|
private getCommandByName(commandId: string): ((view: EditorView) => void) | undefined {
|
||||||
setTimeout(() => {
|
return this.commands.get(commandId);
|
||||||
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);
|
private handleMenuItemMouseEnter(menuItem: HTMLElement): void {
|
||||||
submenu.style.top = `${Math.max(0, newTop)}px`;
|
const submenuId = menuItem.dataset.submenuId;
|
||||||
}
|
if (!submenuId) return;
|
||||||
}, 0);
|
|
||||||
|
|
||||||
// 显示子菜单
|
const submenu = this.submenuPool.get(submenuId);
|
||||||
submenu.style.opacity = '1';
|
if (!submenu) return;
|
||||||
submenu.style.pointerEvents = 'auto';
|
|
||||||
submenu.style.visibility = 'visible';
|
|
||||||
submenu.style.transform = 'translateX(0)';
|
|
||||||
});
|
|
||||||
|
|
||||||
// 当鼠标离开菜单项时,隐藏子菜单
|
const rect = menuItem.getBoundingClientRect();
|
||||||
menuItem.addEventListener('mouseleave', (e) => {
|
|
||||||
// 检查是否移动到子菜单上
|
// 计算子菜单位置
|
||||||
const toElement = e.relatedTarget as HTMLElement;
|
submenu.style.left = `${rect.right}px`;
|
||||||
if (submenu.contains(toElement)) {
|
submenu.style.top = `${rect.top}px`;
|
||||||
return; // 如果移动到子菜单上,不隐藏
|
|
||||||
|
// 检查子菜单是否会超出屏幕
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const submenuRect = submenu.getBoundingClientRect();
|
||||||
|
if (submenuRect.right > window.innerWidth) {
|
||||||
|
submenu.style.left = `${rect.left - submenuRect.width}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏子菜单
|
if (submenuRect.bottom > window.innerHeight) {
|
||||||
submenu.style.opacity = '0';
|
const newTop = rect.top - (submenuRect.bottom - window.innerHeight);
|
||||||
submenu.style.pointerEvents = 'none';
|
submenu.style.top = `${Math.max(0, newTop)}px`;
|
||||||
submenu.style.transform = 'translateX(10px)';
|
|
||||||
|
|
||||||
// 延迟设置visibility,以便过渡动画能够完成
|
|
||||||
setTimeout(() => {
|
|
||||||
if (submenu.style.opacity === '0') {
|
|
||||||
submenu.style.visibility = 'hidden';
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 当鼠标离开子菜单时,隐藏它
|
|
||||||
submenu.addEventListener('mouseleave', (e) => {
|
|
||||||
// 检查是否移动回父菜单项
|
|
||||||
const toElement = e.relatedTarget as HTMLElement;
|
|
||||||
if (menuItem.contains(toElement)) {
|
|
||||||
return; // 如果移动回父菜单项,不隐藏
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏子菜单
|
|
||||||
submenu.style.opacity = '0';
|
|
||||||
submenu.style.pointerEvents = 'none';
|
|
||||||
submenu.style.transform = 'translateX(10px)';
|
|
||||||
|
|
||||||
// 延迟设置visibility,以便过渡动画能够完成
|
|
||||||
setTimeout(() => {
|
|
||||||
if (submenu.style.opacity === '0') {
|
|
||||||
submenu.style.visibility = 'hidden';
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 记录子菜单
|
// 显示子菜单
|
||||||
if (!window.cmSubmenus) {
|
submenu.style.opacity = '1';
|
||||||
window.cmSubmenus = new Map();
|
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;
|
||||||
}
|
}
|
||||||
window.cmSubmenus.set(submenuId, submenu);
|
|
||||||
|
this.hideSubmenu(submenu);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点击事件(仅当有command时添加)
|
/**
|
||||||
if (item.command) {
|
* 隐藏子菜单
|
||||||
menuItem.addEventListener("click", (e) => {
|
*/
|
||||||
e.preventDefault();
|
private hideSubmenu(submenu: HTMLElement): void {
|
||||||
e.stopPropagation();
|
submenu.style.opacity = '0';
|
||||||
|
submenu.style.pointerEvents = 'none';
|
||||||
|
submenu.style.transform = 'translateX(10px)';
|
||||||
|
|
||||||
// 添加点击动画效果
|
setTimeout(() => {
|
||||||
const ripple = document.createElement("div");
|
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";
|
ripple.className = "cm-context-menu-item-ripple";
|
||||||
|
}
|
||||||
|
|
||||||
// 计算相对位置
|
// 计算相对位置
|
||||||
const rect = menuItem.getBoundingClientRect();
|
const rect = menuItem.getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
const y = e.clientY - rect.top;
|
const y = e.clientY - rect.top;
|
||||||
|
|
||||||
ripple.style.left = (x - 50) + "px";
|
ripple.style.left = (x - 50) + "px";
|
||||||
ripple.style.top = (y - 50) + "px";
|
ripple.style.top = (y - 50) + "px";
|
||||||
|
ripple.style.transform = "scale(0)";
|
||||||
|
ripple.style.opacity = "1";
|
||||||
|
|
||||||
menuItem.appendChild(ripple);
|
menuItem.appendChild(ripple);
|
||||||
|
|
||||||
|
// 执行动画
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ripple.style.transform = "scale(1)";
|
||||||
|
ripple.style.opacity = "0";
|
||||||
|
|
||||||
// 执行点击动画
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ripple.style.transform = "scale(1)";
|
if (ripple.parentNode === menuItem) {
|
||||||
ripple.style.opacity = "0";
|
menuItem.removeChild(ripple);
|
||||||
|
this.ripplePool.push(ripple);
|
||||||
// 动画完成后移除ripple元素
|
}
|
||||||
setTimeout(() => {
|
}, 300);
|
||||||
if (ripple.parentNode === menuItem) {
|
|
||||||
menuItem.removeChild(ripple);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
}, 10);
|
|
||||||
|
|
||||||
// 执行命令
|
|
||||||
item.command!(view);
|
|
||||||
|
|
||||||
// 隐藏菜单
|
|
||||||
hideContextMenu();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return menuItem;
|
/**
|
||||||
}
|
* 检查点击是否在菜单内
|
||||||
|
*/
|
||||||
|
private isClickInsideMenu(target: Node): boolean {
|
||||||
|
if (this.menuElement && this.menuElement.contains(target)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
// 检查是否在子菜单内
|
||||||
* 创建分隔线
|
for (const submenu of this.activeSubmenus) {
|
||||||
*/
|
if (submenu.contains(target)) {
|
||||||
function createDivider(): HTMLElement {
|
return true;
|
||||||
const divider = document.createElement("div");
|
}
|
||||||
divider.className = "cm-context-menu-divider";
|
}
|
||||||
return divider;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return false;
|
||||||
* 添加菜单组
|
|
||||||
* @param menuElement 菜单元素
|
|
||||||
* @param title 菜单组标题
|
|
||||||
* @param items 菜单项
|
|
||||||
* @param view 编辑器视图
|
|
||||||
*/
|
|
||||||
function addMenuGroup(menuElement: HTMLElement, title: string | null, items: MenuItem[], view: EditorView): void {
|
|
||||||
// 如果有标题,添加组标题
|
|
||||||
if (title) {
|
|
||||||
const groupTitle = document.createElement("div");
|
|
||||||
groupTitle.className = "cm-context-menu-group-title";
|
|
||||||
groupTitle.textContent = title;
|
|
||||||
menuElement.appendChild(groupTitle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加菜单项
|
/**
|
||||||
items.forEach(item => {
|
* 定位菜单元素
|
||||||
const menuItemElement = createMenuItemElement(item, view);
|
*/
|
||||||
menuElement.appendChild(menuItemElement);
|
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 {
|
export function showContextMenu(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
|
||||||
// 获取或创建菜单元素
|
contextMenuManager.show(view, clientX, clientY, items);
|
||||||
const menu = getOrCreateMenuElement();
|
|
||||||
|
|
||||||
// 如果已经有菜单显示,先隐藏所有子菜单
|
|
||||||
hideAllSubmenus();
|
|
||||||
|
|
||||||
// 清空现有菜单项
|
|
||||||
while (menu.firstChild) {
|
|
||||||
menu.removeChild(menu.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加主菜单项
|
|
||||||
items.forEach(item => {
|
|
||||||
const menuItemElement = createMenuItemElement(item, view);
|
|
||||||
menu.appendChild(menuItemElement);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 显示菜单
|
|
||||||
menu.style.display = "block";
|
|
||||||
|
|
||||||
// 定位菜单
|
|
||||||
positionMenu(menu, clientX, clientY);
|
|
||||||
|
|
||||||
|
|
||||||
// 添加点击外部关闭事件
|
|
||||||
if (clickOutsideHandler) {
|
|
||||||
document.removeEventListener("click", clickOutsideHandler, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
clickOutsideHandler = (e: MouseEvent) => {
|
|
||||||
// 检查点击是否在菜单外
|
|
||||||
if (menu && !menu.contains(e.target as Node)) {
|
|
||||||
let isInSubmenu = false;
|
|
||||||
|
|
||||||
// 检查是否点击在子菜单内
|
|
||||||
if (window.cmSubmenus) {
|
|
||||||
window.cmSubmenus.forEach((submenu) => {
|
|
||||||
if (submenu.contains(e.target as Node)) {
|
|
||||||
isInSubmenu = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isInSubmenu) {
|
|
||||||
hideContextMenu();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 使用捕获阶段确保事件被处理
|
|
||||||
document.addEventListener("click", clickOutsideHandler, true);
|
|
||||||
|
|
||||||
// ESC键关闭
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
|
|
||||||
// 触发显示动画
|
|
||||||
setTimeout(() => {
|
|
||||||
if (menu) {
|
|
||||||
menu.classList.add("show");
|
|
||||||
}
|
|
||||||
}, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 隐藏所有子菜单
|
|
||||||
*/
|
|
||||||
function hideAllSubmenus(): void {
|
|
||||||
if (window.cmSubmenus) {
|
|
||||||
window.cmSubmenus.forEach((submenu) => {
|
|
||||||
submenu.style.opacity = '0';
|
|
||||||
submenu.style.pointerEvents = 'none';
|
|
||||||
submenu.style.visibility = 'hidden';
|
|
||||||
submenu.style.transform = 'translateX(10px)';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理键盘事件
|
|
||||||
*/
|
|
||||||
function handleKeyDown(e: KeyboardEvent): void {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
hideContextMenu();
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 隐藏上下文菜单
|
|
||||||
*/
|
|
||||||
export function hideContextMenu(): void {
|
|
||||||
// 隐藏所有子菜单
|
|
||||||
hideAllSubmenus();
|
|
||||||
|
|
||||||
if (menuElement) {
|
|
||||||
// 添加淡出动画
|
|
||||||
menuElement.classList.remove("show");
|
|
||||||
menuElement.classList.add("hide");
|
|
||||||
|
|
||||||
// 等待动画完成后隐藏(不移除DOM元素)
|
|
||||||
setTimeout(() => {
|
|
||||||
if (menuElement) {
|
|
||||||
menuElement.style.display = "none";
|
|
||||||
menuElement.classList.remove("hide");
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clickOutsideHandler) {
|
|
||||||
document.removeEventListener("click", clickOutsideHandler, true);
|
|
||||||
clickOutsideHandler = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 定位菜单元素
|
|
||||||
*/
|
|
||||||
function positionMenu(menu: HTMLElement, clientX: number, clientY: number): void {
|
|
||||||
// 获取窗口尺寸
|
|
||||||
const windowWidth = window.innerWidth;
|
|
||||||
const windowHeight = window.innerHeight;
|
|
||||||
|
|
||||||
// 初始位置设置
|
|
||||||
let left = clientX;
|
|
||||||
let top = clientY;
|
|
||||||
|
|
||||||
// 确保菜单在视窗内
|
|
||||||
setTimeout(() => {
|
|
||||||
// 计算菜单尺寸
|
|
||||||
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`;
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
@@ -11,10 +11,6 @@ import { useKeybindingStore } from "@/stores/keybindingStore";
|
|||||||
import {
|
import {
|
||||||
undo, redo
|
undo, redo
|
||||||
} from "@codemirror/commands";
|
} from "@codemirror/commands";
|
||||||
import {
|
|
||||||
deleteBlock, formatCurrentBlock,
|
|
||||||
addNewBlockAfterCurrent, addNewBlockAfterLast, addNewBlockBeforeCurrent
|
|
||||||
} from "../extensions/codeblock/commands";
|
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import {useSystemStore} from "@/stores/systemStore";
|
import {useSystemStore} from "@/stores/systemStore";
|
||||||
|
|
||||||
@@ -133,44 +129,6 @@ function createHistoryItems(): MenuItem[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建代码块相关菜单项
|
|
||||||
*/
|
|
||||||
function createCodeBlockItems(): MenuItem[] {
|
|
||||||
const defaultOptions = { defaultBlockToken: 'text', defaultBlockAutoDetect: true };
|
|
||||||
return [
|
|
||||||
// 格式化
|
|
||||||
{
|
|
||||||
label: t("keybindings.commands.blockFormat"),
|
|
||||||
command: formatCurrentBlock,
|
|
||||||
shortcut: getShortcutText(KeyBindingCommand.BlockFormatCommand)
|
|
||||||
},
|
|
||||||
// 删除
|
|
||||||
{
|
|
||||||
label: t("keybindings.commands.blockDelete"),
|
|
||||||
command: deleteBlock(defaultOptions),
|
|
||||||
shortcut: getShortcutText(KeyBindingCommand.BlockDeleteCommand)
|
|
||||||
},
|
|
||||||
// 在当前块后添加新块
|
|
||||||
{
|
|
||||||
label: t("keybindings.commands.blockAddAfterCurrent"),
|
|
||||||
command: addNewBlockAfterCurrent(defaultOptions),
|
|
||||||
shortcut: getShortcutText(KeyBindingCommand.BlockAddAfterCurrentCommand)
|
|
||||||
},
|
|
||||||
// 在当前块前添加新块
|
|
||||||
{
|
|
||||||
label: t("keybindings.commands.blockAddBeforeCurrent"),
|
|
||||||
command: addNewBlockBeforeCurrent(defaultOptions),
|
|
||||||
shortcut: getShortcutText(KeyBindingCommand.BlockAddBeforeCurrentCommand)
|
|
||||||
},
|
|
||||||
// 在最后添加新块
|
|
||||||
{
|
|
||||||
label: t("keybindings.commands.blockAddAfterLast"),
|
|
||||||
command: addNewBlockAfterLast(defaultOptions),
|
|
||||||
shortcut: getShortcutText(KeyBindingCommand.BlockAddAfterLastCommand)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建主菜单项
|
* 创建主菜单项
|
||||||
@@ -185,11 +143,7 @@ function createMainMenuItems(): MenuItem[] {
|
|||||||
// 构建主菜单
|
// 构建主菜单
|
||||||
return [
|
return [
|
||||||
...basicItems,
|
...basicItems,
|
||||||
...historyItems,
|
...historyItems
|
||||||
{
|
|
||||||
label: t("extensions.codeblock.name"),
|
|
||||||
submenu: createCodeBlockItems()
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const blockSeparatorRegex = new RegExp(`\\n∞∞∞(${languageTokensMatcher})(-
|
|||||||
/**
|
/**
|
||||||
* 获取被复制的范围和内容
|
* 获取被复制的范围和内容
|
||||||
*/
|
*/
|
||||||
function copiedRange(state: EditorState) {
|
function copiedRange(state: EditorState, forCut: boolean = false) {
|
||||||
const content: string[] = [];
|
const content: string[] = [];
|
||||||
const ranges: any[] = [];
|
const ranges: any[] = [];
|
||||||
|
|
||||||
@@ -37,7 +37,13 @@ function copiedRange(state: EditorState) {
|
|||||||
const lineContent = state.sliceDoc(line.from, line.to);
|
const lineContent = state.sliceDoc(line.from, line.to);
|
||||||
if (!copiedLines.includes(line.from)) {
|
if (!copiedLines.includes(line.from)) {
|
||||||
content.push(lineContent);
|
content.push(lineContent);
|
||||||
ranges.push(range);
|
// 对于剪切操作,需要包含整行范围(包括换行符)
|
||||||
|
if (forCut) {
|
||||||
|
const lineEnd = line.to < state.doc.length ? line.to + 1 : line.to;
|
||||||
|
ranges.push({ from: line.from, to: lineEnd });
|
||||||
|
} else {
|
||||||
|
ranges.push(range);
|
||||||
|
}
|
||||||
copiedLines.push(line.from);
|
copiedLines.push(line.from);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,7 +74,7 @@ export const codeBlockCopyCut = EditorView.domEventHandlers({
|
|||||||
},
|
},
|
||||||
|
|
||||||
cut(event, view) {
|
cut(event, view) {
|
||||||
let { text, ranges } = copiedRange(view.state);
|
let { text, ranges } = copiedRange(view.state, true);
|
||||||
// 将块分隔符替换为双换行符
|
// 将块分隔符替换为双换行符
|
||||||
text = text.replaceAll(blockSeparatorRegex, "\n\n");
|
text = text.replaceAll(blockSeparatorRegex, "\n\n");
|
||||||
|
|
||||||
@@ -93,7 +99,7 @@ export const codeBlockCopyCut = EditorView.domEventHandlers({
|
|||||||
* 复制和剪切的通用函数
|
* 复制和剪切的通用函数
|
||||||
*/
|
*/
|
||||||
const copyCut = (view: EditorView, cut: boolean): boolean => {
|
const copyCut = (view: EditorView, cut: boolean): boolean => {
|
||||||
let { text, ranges } = copiedRange(view.state);
|
let { text, ranges } = copiedRange(view.state, cut);
|
||||||
// 将块分隔符替换为双换行符
|
// 将块分隔符替换为双换行符
|
||||||
text = text.replaceAll(blockSeparatorRegex, "\n\n");
|
text = text.replaceAll(blockSeparatorRegex, "\n\n");
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const defaultEditorOptions = {
|
|||||||
* 前端命令注册表
|
* 前端命令注册表
|
||||||
* 将后端定义的command字段映射到具体的前端方法和翻译键
|
* 将后端定义的command字段映射到具体的前端方法和翻译键
|
||||||
*/
|
*/
|
||||||
export const commandRegistry = {
|
export const commands = {
|
||||||
[KeyBindingCommand.ShowSearchCommand]: {
|
[KeyBindingCommand.ShowSearchCommand]: {
|
||||||
handler: showSearchVisibilityCommand,
|
handler: showSearchVisibilityCommand,
|
||||||
descriptionKey: 'keybindings.commands.showSearch'
|
descriptionKey: 'keybindings.commands.showSearch'
|
||||||
@@ -299,7 +299,7 @@ export const commandRegistry = {
|
|||||||
* @returns 对应的处理函数,如果不存在则返回 undefined
|
* @returns 对应的处理函数,如果不存在则返回 undefined
|
||||||
*/
|
*/
|
||||||
export const getCommandHandler = (command: KeyBindingCommand) => {
|
export const getCommandHandler = (command: KeyBindingCommand) => {
|
||||||
return commandRegistry[command]?.handler;
|
return commands[command]?.handler;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -308,7 +308,7 @@ export const getCommandHandler = (command: KeyBindingCommand) => {
|
|||||||
* @returns 对应的描述,如果不存在则返回 undefined
|
* @returns 对应的描述,如果不存在则返回 undefined
|
||||||
*/
|
*/
|
||||||
export const getCommandDescription = (command: KeyBindingCommand) => {
|
export const getCommandDescription = (command: KeyBindingCommand) => {
|
||||||
const descriptionKey = commandRegistry[command]?.descriptionKey;
|
const descriptionKey = commands[command]?.descriptionKey;
|
||||||
return descriptionKey ? i18n.global.t(descriptionKey) : undefined;
|
return descriptionKey ? i18n.global.t(descriptionKey) : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -318,7 +318,7 @@ export const getCommandDescription = (command: KeyBindingCommand) => {
|
|||||||
* @returns 是否已注册
|
* @returns 是否已注册
|
||||||
*/
|
*/
|
||||||
export const isCommandRegistered = (command: KeyBindingCommand): boolean => {
|
export const isCommandRegistered = (command: KeyBindingCommand): boolean => {
|
||||||
return command in commandRegistry;
|
return command in commands;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -326,5 +326,5 @@ export const isCommandRegistered = (command: KeyBindingCommand): boolean => {
|
|||||||
* @returns 已注册的命令列表
|
* @returns 已注册的命令列表
|
||||||
*/
|
*/
|
||||||
export const getRegisteredCommands = (): KeyBindingCommand[] => {
|
export const getRegisteredCommands = (): KeyBindingCommand[] => {
|
||||||
return Object.keys(commandRegistry) as KeyBindingCommand[];
|
return Object.keys(commands) as KeyBindingCommand[];
|
||||||
};
|
};
|
||||||
@@ -2,11 +2,9 @@ import { Extension } from '@codemirror/state';
|
|||||||
import { useKeybindingStore } from '@/stores/keybindingStore';
|
import { useKeybindingStore } from '@/stores/keybindingStore';
|
||||||
import { useExtensionStore } from '@/stores/extensionStore';
|
import { useExtensionStore } from '@/stores/extensionStore';
|
||||||
import { KeymapManager } from './keymapManager';
|
import { KeymapManager } from './keymapManager';
|
||||||
import { ExtensionID } from '@/../bindings/voidraft/internal/models/models';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 异步创建快捷键扩展
|
* 异步创建快捷键扩展
|
||||||
* 确保快捷键配置和扩展配置已加载
|
|
||||||
*/
|
*/
|
||||||
export const createDynamicKeymapExtension = async (): Promise<Extension> => {
|
export const createDynamicKeymapExtension = async (): Promise<Extension> => {
|
||||||
const keybindingStore = useKeybindingStore();
|
const keybindingStore = useKeybindingStore();
|
||||||
@@ -42,17 +40,7 @@ export const updateKeymapExtension = (view: any): void => {
|
|||||||
KeymapManager.updateKeymap(view, keybindingStore.keyBindings, enabledExtensionIds);
|
KeymapManager.updateKeymap(view, keybindingStore.keyBindings, enabledExtensionIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取指定扩展的快捷键
|
|
||||||
* @param extensionId 扩展ID
|
|
||||||
* @returns 该扩展的快捷键列表
|
|
||||||
*/
|
|
||||||
export const getExtensionKeyBindings = (extensionId: ExtensionID) => {
|
|
||||||
const keybindingStore = useKeybindingStore();
|
|
||||||
return keybindingStore.getKeyBindingsByExtension(extensionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 导出相关模块
|
// 导出相关模块
|
||||||
export { KeymapManager } from './keymapManager';
|
export { KeymapManager } from './keymapManager';
|
||||||
export { commandRegistry, getCommandHandler, getCommandDescription, isCommandRegistered, getRegisteredCommands } from './commandRegistry';
|
export { commands, getCommandHandler, getCommandDescription, isCommandRegistered, getRegisteredCommands } from './commands';
|
||||||
export type { KeyBinding, CommandHandler, CommandDefinition, KeymapResult } from './types';
|
export type { KeyBinding, CommandHandler, CommandDefinition, KeymapResult } from './types';
|
||||||
@@ -2,7 +2,7 @@ import {keymap} from '@codemirror/view';
|
|||||||
import {Extension, Compartment} from '@codemirror/state';
|
import {Extension, Compartment} from '@codemirror/state';
|
||||||
import {KeyBinding as KeyBindingConfig, ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
import {KeyBinding as KeyBindingConfig, ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import {KeyBinding, KeymapResult} from './types';
|
import {KeyBinding, KeymapResult} from './types';
|
||||||
import {getCommandHandler, isCommandRegistered} from './commandRegistry';
|
import {getCommandHandler, isCommandRegistered} from './commands';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 快捷键管理器
|
* 快捷键管理器
|
||||||
@@ -82,45 +82,4 @@ export class KeymapManager {
|
|||||||
effects: this.compartment.reconfigure(keymap.of(cmKeyBindings))
|
effects: this.compartment.reconfigure(keymap.of(cmKeyBindings))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 按扩展分组快捷键
|
|
||||||
* @param keyBindings 快捷键配置列表
|
|
||||||
* @returns 按扩展分组的快捷键映射
|
|
||||||
*/
|
|
||||||
static groupByExtension(keyBindings: KeyBindingConfig[]): Map<ExtensionID, KeyBindingConfig[]> {
|
|
||||||
const groups = new Map<ExtensionID, KeyBindingConfig[]>();
|
|
||||||
|
|
||||||
for (const binding of keyBindings) {
|
|
||||||
if (!groups.has(binding.extension)) {
|
|
||||||
groups.set(binding.extension, []);
|
|
||||||
}
|
|
||||||
groups.get(binding.extension)!.push(binding);
|
|
||||||
}
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证快捷键配置
|
|
||||||
* @param keyBindings 快捷键配置列表
|
|
||||||
* @returns 验证结果
|
|
||||||
*/
|
|
||||||
static validateKeyBindings(keyBindings: KeyBindingConfig[]): {
|
|
||||||
valid: KeyBindingConfig[]
|
|
||||||
invalid: KeyBindingConfig[]
|
|
||||||
} {
|
|
||||||
const valid: KeyBindingConfig[] = [];
|
|
||||||
const invalid: KeyBindingConfig[] = [];
|
|
||||||
|
|
||||||
for (const binding of keyBindings) {
|
|
||||||
if (binding.enabled && binding.key && isCommandRegistered(binding.command)) {
|
|
||||||
valid.push(binding);
|
|
||||||
} else {
|
|
||||||
invalid.push(binding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {valid, invalid};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,53 +1,10 @@
|
|||||||
import {Compartment, Extension, StateEffect} from '@codemirror/state';
|
import {Compartment, Extension} from '@codemirror/state';
|
||||||
import {EditorView} from '@codemirror/view';
|
import {EditorView} from '@codemirror/view';
|
||||||
import {Extension as ExtensionConfig, ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
import {Extension as ExtensionConfig, ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||||
|
import {ExtensionState, EditorViewInfo, ExtensionFactory} from './types'
|
||||||
|
import {createDebounce} from '@/common/utils/debounce';
|
||||||
|
|
||||||
/**
|
|
||||||
* 扩展工厂接口
|
|
||||||
* 每个扩展需要实现此接口来创建和配置扩展
|
|
||||||
*/
|
|
||||||
export interface ExtensionFactory {
|
|
||||||
/**
|
|
||||||
* 创建扩展实例
|
|
||||||
* @param config 扩展配置
|
|
||||||
* @returns CodeMirror扩展
|
|
||||||
*/
|
|
||||||
create(config: any): Extension
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取默认配置
|
|
||||||
* @returns 默认配置对象
|
|
||||||
*/
|
|
||||||
getDefaultConfig(): any
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证配置
|
|
||||||
* @param config 配置对象
|
|
||||||
* @returns 是否有效
|
|
||||||
*/
|
|
||||||
validateConfig?(config: any): boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 扩展状态
|
|
||||||
*/
|
|
||||||
interface ExtensionState {
|
|
||||||
id: ExtensionID
|
|
||||||
factory: ExtensionFactory
|
|
||||||
config: any
|
|
||||||
enabled: boolean
|
|
||||||
compartment: Compartment
|
|
||||||
extension: Extension
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 视图信息
|
|
||||||
*/
|
|
||||||
interface EditorViewInfo {
|
|
||||||
view: EditorView
|
|
||||||
documentId: number
|
|
||||||
registered: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扩展管理器
|
* 扩展管理器
|
||||||
@@ -66,8 +23,11 @@ export class ExtensionManager {
|
|||||||
private extensionFactories = new Map<ExtensionID, ExtensionFactory>();
|
private extensionFactories = new Map<ExtensionID, ExtensionFactory>();
|
||||||
|
|
||||||
// 防抖处理
|
// 防抖处理
|
||||||
private debounceTimers = new Map<ExtensionID, number>();
|
private debouncedUpdateFunctions = new Map<ExtensionID, {
|
||||||
private debounceDelay = 300; // 默认防抖时间为300毫秒
|
debouncedFn: (enabled: boolean, config: any) => void;
|
||||||
|
cancel: () => void;
|
||||||
|
flush: () => void;
|
||||||
|
}>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册扩展工厂
|
* 注册扩展工厂
|
||||||
@@ -91,6 +51,22 @@ export class ExtensionManager {
|
|||||||
extension: [] // 默认为空扩展(禁用状态)
|
extension: [] // 默认为空扩展(禁用状态)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 为每个扩展创建防抖函数
|
||||||
|
if (!this.debouncedUpdateFunctions.has(id)) {
|
||||||
|
const { debouncedFn, cancel, flush } = createDebounce(
|
||||||
|
(enabled: boolean, config: any) => {
|
||||||
|
this.updateExtensionImmediate(id, enabled, config);
|
||||||
|
},
|
||||||
|
{ delay: 300 }
|
||||||
|
);
|
||||||
|
|
||||||
|
this.debouncedUpdateFunctions.set(id, {
|
||||||
|
debouncedFn,
|
||||||
|
cancel,
|
||||||
|
flush
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,18 +173,13 @@ export class ExtensionManager {
|
|||||||
* @param config 扩展配置
|
* @param config 扩展配置
|
||||||
*/
|
*/
|
||||||
updateExtension(id: ExtensionID, enabled: boolean, config: any = {}): void {
|
updateExtension(id: ExtensionID, enabled: boolean, config: any = {}): void {
|
||||||
// 清除之前的定时器
|
const debouncedUpdate = this.debouncedUpdateFunctions.get(id);
|
||||||
if (this.debounceTimers.has(id)) {
|
if (debouncedUpdate) {
|
||||||
window.clearTimeout(this.debounceTimers.get(id));
|
debouncedUpdate.debouncedFn(enabled, config);
|
||||||
}
|
} else {
|
||||||
|
// 如果没有防抖函数,直接执行
|
||||||
// 设置新的定时器
|
|
||||||
const timerId = window.setTimeout(() => {
|
|
||||||
this.updateExtensionImmediate(id, enabled, config);
|
this.updateExtensionImmediate(id, enabled, config);
|
||||||
this.debounceTimers.delete(id);
|
}
|
||||||
}, this.debounceDelay);
|
|
||||||
|
|
||||||
this.debounceTimers.set(id, timerId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,72 +239,6 @@ export class ExtensionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量更新扩展
|
|
||||||
* @param updates 更新配置数组
|
|
||||||
*/
|
|
||||||
updateExtensions(updates: Array<{
|
|
||||||
id: ExtensionID
|
|
||||||
enabled: boolean
|
|
||||||
config: any
|
|
||||||
}>): void {
|
|
||||||
// 清除所有相关的防抖定时器
|
|
||||||
for (const update of updates) {
|
|
||||||
if (this.debounceTimers.has(update.id)) {
|
|
||||||
window.clearTimeout(this.debounceTimers.get(update.id));
|
|
||||||
this.debounceTimers.delete(update.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新所有扩展状态
|
|
||||||
for (const update of updates) {
|
|
||||||
// 获取扩展状态
|
|
||||||
const state = this.extensionStates.get(update.id);
|
|
||||||
if (!state) continue;
|
|
||||||
|
|
||||||
// 获取工厂
|
|
||||||
const factory = state.factory;
|
|
||||||
|
|
||||||
// 验证配置
|
|
||||||
if (factory.validateConfig && !factory.validateConfig(update.config)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 创建新的扩展实例
|
|
||||||
const extension = update.enabled ? factory.create(update.config) : [];
|
|
||||||
|
|
||||||
// 更新内部状态
|
|
||||||
state.config = update.config;
|
|
||||||
state.enabled = update.enabled;
|
|
||||||
state.extension = extension;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to update extension ${update.id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将更改应用到所有视图
|
|
||||||
for (const viewInfo of this.viewsMap.values()) {
|
|
||||||
if (!viewInfo.registered) continue;
|
|
||||||
|
|
||||||
const effects: StateEffect<any>[] = [];
|
|
||||||
|
|
||||||
for (const update of updates) {
|
|
||||||
const state = this.extensionStates.get(update.id);
|
|
||||||
if (!state) continue;
|
|
||||||
|
|
||||||
effects.push(state.compartment.reconfigure(state.extension));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (effects.length > 0) {
|
|
||||||
try {
|
|
||||||
viewInfo.view.dispatch({ effects });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to apply extensions to document ${viewInfo.documentId}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取扩展当前状态
|
* 获取扩展当前状态
|
||||||
@@ -380,11 +285,11 @@ export class ExtensionManager {
|
|||||||
* 销毁管理器
|
* 销毁管理器
|
||||||
*/
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
// 清除所有防抖定时器
|
// 清除所有防抖函数
|
||||||
for (const timerId of this.debounceTimers.values()) {
|
for (const { cancel } of this.debouncedUpdateFunctions.values()) {
|
||||||
window.clearTimeout(timerId);
|
cancel();
|
||||||
}
|
}
|
||||||
this.debounceTimers.clear();
|
this.debouncedUpdateFunctions.clear();
|
||||||
|
|
||||||
this.viewsMap.clear();
|
this.viewsMap.clear();
|
||||||
this.activeViewId = null;
|
this.activeViewId = null;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {ExtensionFactory, ExtensionManager} from './ExtensionManager';
|
import {ExtensionManager} from './extensionManager';
|
||||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||||
import i18n from '@/i18n';
|
import i18n from '@/i18n';
|
||||||
|
import {ExtensionFactory} from './types'
|
||||||
|
|
||||||
// 导入现有扩展的创建函数
|
// 导入现有扩展的创建函数
|
||||||
import rainbowBracketsExtension from '../extensions/rainbowBracket/rainbowBracketsExtension';
|
import rainbowBracketsExtension from '../extensions/rainbowBracket/rainbowBracketsExtension';
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import {Extension} from '@codemirror/state';
|
import {Extension} from '@codemirror/state';
|
||||||
import {EditorView} from '@codemirror/view';
|
import {EditorView} from '@codemirror/view';
|
||||||
import {useExtensionStore} from '@/stores/extensionStore';
|
import {useExtensionStore} from '@/stores/extensionStore';
|
||||||
import {ExtensionManager} from './ExtensionManager';
|
import {ExtensionManager} from './extensionManager';
|
||||||
import {registerAllExtensions} from './factories';
|
import {registerAllExtensions} from './extensions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局扩展管理器实例
|
* 全局扩展管理器实例
|
||||||
@@ -58,6 +58,5 @@ export const removeExtensionManagerView = (documentId: number): void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 导出相关模块
|
// 导出相关模块
|
||||||
export {ExtensionManager} from './ExtensionManager';
|
export {ExtensionManager} from './extensionManager';
|
||||||
export {registerAllExtensions, getExtensionDisplayName, getExtensionDescription} from './factories';
|
export {registerAllExtensions, getExtensionDisplayName, getExtensionDescription} from './extensions';
|
||||||
export type {ExtensionFactory} from './ExtensionManager';
|
|
||||||
49
frontend/src/views/editor/manager/types.ts
Normal file
49
frontend/src/views/editor/manager/types.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {Compartment, Extension} from '@codemirror/state';
|
||||||
|
import {EditorView} from '@codemirror/view';
|
||||||
|
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||||
|
/**
|
||||||
|
* 扩展工厂接口
|
||||||
|
* 每个扩展需要实现此接口来创建和配置扩展
|
||||||
|
*/
|
||||||
|
export interface ExtensionFactory {
|
||||||
|
/**
|
||||||
|
* 创建扩展实例
|
||||||
|
* @param config 扩展配置
|
||||||
|
* @returns CodeMirror扩展
|
||||||
|
*/
|
||||||
|
create(config: any): Extension
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取默认配置
|
||||||
|
* @returns 默认配置对象
|
||||||
|
*/
|
||||||
|
getDefaultConfig(): any
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证配置
|
||||||
|
* @param config 配置对象
|
||||||
|
* @returns 是否有效
|
||||||
|
*/
|
||||||
|
validateConfig?(config: any): boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扩展状态
|
||||||
|
*/
|
||||||
|
export interface ExtensionState {
|
||||||
|
id: ExtensionID
|
||||||
|
factory: ExtensionFactory
|
||||||
|
config: any
|
||||||
|
enabled: boolean
|
||||||
|
compartment: Compartment
|
||||||
|
extension: Extension
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视图信息
|
||||||
|
*/
|
||||||
|
export interface EditorViewInfo {
|
||||||
|
view: EditorView
|
||||||
|
documentId: number
|
||||||
|
registered: boolean
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
getExtensionDescription,
|
getExtensionDescription,
|
||||||
getExtensionDisplayName,
|
getExtensionDisplayName,
|
||||||
hasExtensionConfig
|
hasExtensionConfig
|
||||||
} from '@/views/editor/manager/factories';
|
} from '@/views/editor/manager/extensions';
|
||||||
import SettingSection from '../components/SettingSection.vue';
|
import SettingSection from '../components/SettingSection.vue';
|
||||||
import SettingItem from '../components/SettingItem.vue';
|
import SettingItem from '../components/SettingItem.vue';
|
||||||
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import SettingSection from '../components/SettingSection.vue';
|
|||||||
import { useKeybindingStore } from '@/stores/keybindingStore';
|
import { useKeybindingStore } from '@/stores/keybindingStore';
|
||||||
import { useExtensionStore } from '@/stores/extensionStore';
|
import { useExtensionStore } from '@/stores/extensionStore';
|
||||||
import { useSystemStore } from '@/stores/systemStore';
|
import { useSystemStore } from '@/stores/systemStore';
|
||||||
import { getCommandDescription } from '@/views/editor/keymap/commandRegistry';
|
import { getCommandDescription } from '@/views/editor/keymap/commands';
|
||||||
import {KeyBindingCommand} from "@/../bindings/voidraft/internal/models";
|
import {KeyBindingCommand} from "@/../bindings/voidraft/internal/models";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -61,7 +61,7 @@ const parseKeyBinding = (keyStr: string, command?: string): string[] => {
|
|||||||
if (systemStore.isMacOS) {
|
if (systemStore.isMacOS) {
|
||||||
return ['⌘', '⌥', '[']; // macOS: Cmd+Alt+[
|
return ['⌘', '⌥', '[']; // macOS: Cmd+Alt+[
|
||||||
} else {
|
} else {
|
||||||
return ['Ctrl', '⇧', '[']; // Windows/Linux: Ctrl+Shift+[
|
return ['Ctrl', 'Shift', '[']; // Windows/Linux: Ctrl+Shift+[
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ const parseKeyBinding = (keyStr: string, command?: string): string[] => {
|
|||||||
if (systemStore.isMacOS) {
|
if (systemStore.isMacOS) {
|
||||||
return ['⌘', '⌥', ']']; // macOS: Cmd+Alt+]
|
return ['⌘', '⌥', ']']; // macOS: Cmd+Alt+]
|
||||||
} else {
|
} else {
|
||||||
return ['Ctrl', '⇧', ']']; // Windows/Linux: Ctrl+Shift+]
|
return ['Ctrl', 'Shift', ']']; // Windows/Linux: Ctrl+Shift+]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ const parseKeyBinding = (keyStr: string, command?: string): string[] => {
|
|||||||
if (systemStore.isMacOS) {
|
if (systemStore.isMacOS) {
|
||||||
return ['⇧', '⌘', '\\']; // macOS: Shift+Cmd+\
|
return ['⇧', '⌘', '\\']; // macOS: Shift+Cmd+\
|
||||||
} else {
|
} else {
|
||||||
return ['⇧', 'Ctrl', '\\']; // Windows/Linux: Shift+Ctrl+\
|
return ['Shift', 'Ctrl', '\\']; // Windows/Linux: Shift+Ctrl+\
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user