🎨 Refactor and optimize code
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {onBeforeUnmount, onMounted, ref} from 'vue';
|
||||
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
||||
import {useEditorStore} from '@/stores/editorStore';
|
||||
import {useDocumentStore} from '@/stores/documentStore';
|
||||
import {useConfigStore} from '@/stores/configStore';
|
||||
@@ -17,6 +17,8 @@ const tabStore = useTabStore();
|
||||
|
||||
const editorElement = ref<HTMLElement | null>(null);
|
||||
|
||||
const enableLoadingAnimation = computed(() => configStore.config.general.enableLoadingAnimation);
|
||||
|
||||
// 创建滚轮缩放处理器
|
||||
const wheelHandler = createWheelZoomHandler(
|
||||
configStore.increaseFontSize,
|
||||
@@ -53,7 +55,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<Toolbar/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/**
|
||||
* 上下文菜单视图实现
|
||||
* 处理菜单的创建、定位和事件绑定
|
||||
* 优化为单例模式,避免频繁创建和销毁DOM元素
|
||||
*/
|
||||
|
||||
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 {
|
||||
if (!menuElement) {
|
||||
menuElement = document.createElement("div");
|
||||
menuElement.className = "cm-context-menu";
|
||||
menuElement.style.display = "none";
|
||||
document.body.appendChild(menuElement);
|
||||
class MenuItemPool {
|
||||
private pool: HTMLElement[] = [];
|
||||
private maxPoolSize = 50; // 最大池大小
|
||||
|
||||
/**
|
||||
* 获取或创建菜单项元素
|
||||
*/
|
||||
get(): HTMLElement {
|
||||
if (this.pool.length > 0) {
|
||||
return this.pool.pop()!;
|
||||
}
|
||||
|
||||
// 阻止菜单内右键点击冒泡
|
||||
menuElement.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
});
|
||||
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 {
|
||||
if (!submenuPool.has(id)) {
|
||||
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);
|
||||
|
||||
// 阻止子菜单点击事件冒泡
|
||||
submenu.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
return submenuPool.get(id)!;
|
||||
}
|
||||
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;
|
||||
|
||||
/**
|
||||
* 创建菜单项DOM元素
|
||||
*/
|
||||
function createMenuItemElement(item: MenuItem, view: EditorView): HTMLElement {
|
||||
// 创建菜单项容器
|
||||
const menuItem = document.createElement("div");
|
||||
menuItem.className = "cm-context-menu-item";
|
||||
|
||||
// 如果有子菜单,添加相应类
|
||||
if (item.submenu && item.submenu.length > 0) {
|
||||
menuItem.classList.add("cm-context-menu-item-with-submenu");
|
||||
private constructor() {
|
||||
this.initializeEventHandlers();
|
||||
}
|
||||
|
||||
// 创建内容容器
|
||||
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);
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
static getInstance(): ContextMenuManager {
|
||||
if (!ContextMenuManager.instance) {
|
||||
ContextMenuManager.instance = new ContextMenuManager();
|
||||
}
|
||||
return ContextMenuManager.instance;
|
||||
}
|
||||
|
||||
// 如果有子菜单,创建或获取子菜单
|
||||
if (item.submenu && item.submenu.length > 0) {
|
||||
// 使用菜单项标签作为子菜单ID
|
||||
const submenuId = `submenu-${item.label.replace(/\s+/g, '-').toLowerCase()}`;
|
||||
const submenu = getOrCreateSubmenu(submenuId);
|
||||
|
||||
/**
|
||||
* 初始化事件处理器
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
// 添加子菜单项
|
||||
item.submenu.forEach(subItem => {
|
||||
const subMenuItemElement = createMenuItemElement(subItem, view);
|
||||
items.forEach(item => {
|
||||
const subMenuItemElement = this.createMenuItemElement(item);
|
||||
submenu.appendChild(subMenuItemElement);
|
||||
});
|
||||
|
||||
@@ -114,313 +268,318 @@ function createMenuItemElement(item: MenuItem, view: EditorView): HTMLElement {
|
||||
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();
|
||||
|
||||
// 当鼠标悬停在菜单项上时,显示子菜单
|
||||
menuItem.addEventListener('mouseenter', () => {
|
||||
const rect = menuItem.getBoundingClientRect();
|
||||
|
||||
// 计算子菜单位置
|
||||
submenu.style.left = `${rect.right}px`;
|
||||
submenu.style.top = `${rect.top}px`;
|
||||
|
||||
// 检查子菜单是否会超出屏幕右侧
|
||||
setTimeout(() => {
|
||||
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`;
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// 显示子菜单
|
||||
submenu.style.opacity = '1';
|
||||
submenu.style.pointerEvents = 'auto';
|
||||
submenu.style.visibility = 'visible';
|
||||
submenu.style.transform = 'translateX(0)';
|
||||
});
|
||||
// 计算子菜单位置
|
||||
submenu.style.left = `${rect.right}px`;
|
||||
submenu.style.top = `${rect.top}px`;
|
||||
|
||||
// 当鼠标离开菜单项时,隐藏子菜单
|
||||
menuItem.addEventListener('mouseleave', (e) => {
|
||||
// 检查是否移动到子菜单上
|
||||
const toElement = e.relatedTarget as HTMLElement;
|
||||
if (submenu.contains(toElement)) {
|
||||
return; // 如果移动到子菜单上,不隐藏
|
||||
// 检查子菜单是否会超出屏幕
|
||||
requestAnimationFrame(() => {
|
||||
const submenuRect = submenu.getBoundingClientRect();
|
||||
if (submenuRect.right > window.innerWidth) {
|
||||
submenu.style.left = `${rect.left - submenuRect.width}px`;
|
||||
}
|
||||
|
||||
// 隐藏子菜单
|
||||
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);
|
||||
});
|
||||
|
||||
// 当鼠标离开子菜单时,隐藏它
|
||||
submenu.addEventListener('mouseleave', (e) => {
|
||||
// 检查是否移动回父菜单项
|
||||
const toElement = e.relatedTarget as HTMLElement;
|
||||
if (menuItem.contains(toElement)) {
|
||||
return; // 如果移动回父菜单项,不隐藏
|
||||
if (submenuRect.bottom > window.innerHeight) {
|
||||
const newTop = rect.top - (submenuRect.bottom - window.innerHeight);
|
||||
submenu.style.top = `${Math.max(0, newTop)}px`;
|
||||
}
|
||||
|
||||
// 隐藏子菜单
|
||||
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) {
|
||||
window.cmSubmenus = new Map();
|
||||
// 显示子菜单
|
||||
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;
|
||||
}
|
||||
window.cmSubmenus.set(submenuId, submenu);
|
||||
|
||||
this.hideSubmenu(submenu);
|
||||
}
|
||||
|
||||
// 点击事件(仅当有command时添加)
|
||||
if (item.command) {
|
||||
menuItem.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// 添加点击动画效果
|
||||
const ripple = document.createElement("div");
|
||||
|
||||
/**
|
||||
* 隐藏子菜单
|
||||
*/
|
||||
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";
|
||||
|
||||
// 计算相对位置
|
||||
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";
|
||||
|
||||
menuItem.appendChild(ripple);
|
||||
|
||||
// 执行点击动画
|
||||
setTimeout(() => {
|
||||
ripple.style.transform = "scale(1)";
|
||||
ripple.style.opacity = "0";
|
||||
|
||||
// 动画完成后移除ripple元素
|
||||
setTimeout(() => {
|
||||
if (ripple.parentNode === menuItem) {
|
||||
menuItem.removeChild(ripple);
|
||||
}
|
||||
}, 300);
|
||||
}, 10);
|
||||
|
||||
// 执行命令
|
||||
item.command!(view);
|
||||
|
||||
// 隐藏菜单
|
||||
hideContextMenu();
|
||||
if (ripple.parentNode === menuItem) {
|
||||
menuItem.removeChild(ripple);
|
||||
this.ripplePool.push(ripple);
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
return menuItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分隔线
|
||||
*/
|
||||
function createDivider(): HTMLElement {
|
||||
const divider = document.createElement("div");
|
||||
divider.className = "cm-context-menu-divider";
|
||||
return divider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加菜单组
|
||||
* @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);
|
||||
/**
|
||||
* 检查点击是否在菜单内
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// 添加菜单项
|
||||
items.forEach(item => {
|
||||
const menuItemElement = createMenuItemElement(item, view);
|
||||
menuElement.appendChild(menuItemElement);
|
||||
});
|
||||
}
|
||||
|
||||
// 获取单例实例
|
||||
const contextMenuManager = ContextMenuManager.getInstance();
|
||||
|
||||
/**
|
||||
* 显示上下文菜单
|
||||
*/
|
||||
export function showContextMenu(view: EditorView, clientX: number, clientY: number, items: MenuItem[]): void {
|
||||
// 获取或创建菜单元素
|
||||
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);
|
||||
}
|
||||
contextMenuManager.show(view, clientX, clientY, items);
|
||||
}
|
||||
@@ -11,10 +11,6 @@ import { useKeybindingStore } from "@/stores/keybindingStore";
|
||||
import {
|
||||
undo, redo
|
||||
} from "@codemirror/commands";
|
||||
import {
|
||||
deleteBlock, formatCurrentBlock,
|
||||
addNewBlockAfterCurrent, addNewBlockAfterLast, addNewBlockBeforeCurrent
|
||||
} from "../extensions/codeblock/commands";
|
||||
import i18n from "@/i18n";
|
||||
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 [
|
||||
...basicItems,
|
||||
...historyItems,
|
||||
{
|
||||
label: t("extensions.codeblock.name"),
|
||||
submenu: createCodeBlockItems()
|
||||
}
|
||||
...historyItems
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ranges: any[] = [];
|
||||
|
||||
@@ -37,7 +37,13 @@ function copiedRange(state: EditorState) {
|
||||
const lineContent = state.sliceDoc(line.from, line.to);
|
||||
if (!copiedLines.includes(line.from)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -68,7 +74,7 @@ export const codeBlockCopyCut = EditorView.domEventHandlers({
|
||||
},
|
||||
|
||||
cut(event, view) {
|
||||
let { text, ranges } = copiedRange(view.state);
|
||||
let { text, ranges } = copiedRange(view.state, true);
|
||||
// 将块分隔符替换为双换行符
|
||||
text = text.replaceAll(blockSeparatorRegex, "\n\n");
|
||||
|
||||
@@ -93,7 +99,7 @@ export const codeBlockCopyCut = EditorView.domEventHandlers({
|
||||
* 复制和剪切的通用函数
|
||||
*/
|
||||
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");
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ const defaultEditorOptions = {
|
||||
* 前端命令注册表
|
||||
* 将后端定义的command字段映射到具体的前端方法和翻译键
|
||||
*/
|
||||
export const commandRegistry = {
|
||||
export const commands = {
|
||||
[KeyBindingCommand.ShowSearchCommand]: {
|
||||
handler: showSearchVisibilityCommand,
|
||||
descriptionKey: 'keybindings.commands.showSearch'
|
||||
@@ -299,7 +299,7 @@ export const commandRegistry = {
|
||||
* @returns 对应的处理函数,如果不存在则返回 undefined
|
||||
*/
|
||||
export const getCommandHandler = (command: KeyBindingCommand) => {
|
||||
return commandRegistry[command]?.handler;
|
||||
return commands[command]?.handler;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -308,7 +308,7 @@ export const getCommandHandler = (command: KeyBindingCommand) => {
|
||||
* @returns 对应的描述,如果不存在则返回 undefined
|
||||
*/
|
||||
export const getCommandDescription = (command: KeyBindingCommand) => {
|
||||
const descriptionKey = commandRegistry[command]?.descriptionKey;
|
||||
const descriptionKey = commands[command]?.descriptionKey;
|
||||
return descriptionKey ? i18n.global.t(descriptionKey) : undefined;
|
||||
};
|
||||
|
||||
@@ -318,7 +318,7 @@ export const getCommandDescription = (command: KeyBindingCommand) => {
|
||||
* @returns 是否已注册
|
||||
*/
|
||||
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 已注册的命令列表
|
||||
*/
|
||||
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 { useExtensionStore } from '@/stores/extensionStore';
|
||||
import { KeymapManager } from './keymapManager';
|
||||
import { ExtensionID } from '@/../bindings/voidraft/internal/models/models';
|
||||
|
||||
/**
|
||||
* 异步创建快捷键扩展
|
||||
* 确保快捷键配置和扩展配置已加载
|
||||
*/
|
||||
export const createDynamicKeymapExtension = async (): Promise<Extension> => {
|
||||
const keybindingStore = useKeybindingStore();
|
||||
@@ -42,17 +40,7 @@ export const updateKeymapExtension = (view: any): void => {
|
||||
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 { commandRegistry, getCommandHandler, getCommandDescription, isCommandRegistered, getRegisteredCommands } from './commandRegistry';
|
||||
export { commands, getCommandHandler, getCommandDescription, isCommandRegistered, getRegisteredCommands } from './commands';
|
||||
export type { KeyBinding, CommandHandler, CommandDefinition, KeymapResult } from './types';
|
||||
@@ -2,7 +2,7 @@ import {keymap} from '@codemirror/view';
|
||||
import {Extension, Compartment} from '@codemirror/state';
|
||||
import {KeyBinding as KeyBindingConfig, ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||
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))
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按扩展分组快捷键
|
||||
* @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 {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 debounceTimers = new Map<ExtensionID, number>();
|
||||
private debounceDelay = 300; // 默认防抖时间为300毫秒
|
||||
private debouncedUpdateFunctions = new Map<ExtensionID, {
|
||||
debouncedFn: (enabled: boolean, config: any) => void;
|
||||
cancel: () => void;
|
||||
flush: () => void;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 注册扩展工厂
|
||||
@@ -91,6 +51,22 @@ export class ExtensionManager {
|
||||
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 扩展配置
|
||||
*/
|
||||
updateExtension(id: ExtensionID, enabled: boolean, config: any = {}): void {
|
||||
// 清除之前的定时器
|
||||
if (this.debounceTimers.has(id)) {
|
||||
window.clearTimeout(this.debounceTimers.get(id));
|
||||
}
|
||||
|
||||
// 设置新的定时器
|
||||
const timerId = window.setTimeout(() => {
|
||||
const debouncedUpdate = this.debouncedUpdateFunctions.get(id);
|
||||
if (debouncedUpdate) {
|
||||
debouncedUpdate.debouncedFn(enabled, config);
|
||||
} else {
|
||||
// 如果没有防抖函数,直接执行
|
||||
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,15 +285,15 @@ export class ExtensionManager {
|
||||
* 销毁管理器
|
||||
*/
|
||||
destroy(): void {
|
||||
// 清除所有防抖定时器
|
||||
for (const timerId of this.debounceTimers.values()) {
|
||||
window.clearTimeout(timerId);
|
||||
// 清除所有防抖函数
|
||||
for (const { cancel } of this.debouncedUpdateFunctions.values()) {
|
||||
cancel();
|
||||
}
|
||||
this.debounceTimers.clear();
|
||||
this.debouncedUpdateFunctions.clear();
|
||||
|
||||
this.viewsMap.clear();
|
||||
this.activeViewId = null;
|
||||
this.extensionFactories.clear();
|
||||
this.extensionStates.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {ExtensionFactory, ExtensionManager} from './ExtensionManager';
|
||||
import {ExtensionManager} from './extensionManager';
|
||||
import {ExtensionID} from '@/../bindings/voidraft/internal/models/models';
|
||||
import i18n from '@/i18n';
|
||||
import {ExtensionFactory} from './types'
|
||||
|
||||
// 导入现有扩展的创建函数
|
||||
import rainbowBracketsExtension from '../extensions/rainbowBracket/rainbowBracketsExtension';
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Extension} from '@codemirror/state';
|
||||
import {EditorView} from '@codemirror/view';
|
||||
import {useExtensionStore} from '@/stores/extensionStore';
|
||||
import {ExtensionManager} from './ExtensionManager';
|
||||
import {registerAllExtensions} from './factories';
|
||||
import {ExtensionManager} from './extensionManager';
|
||||
import {registerAllExtensions} from './extensions';
|
||||
|
||||
/**
|
||||
* 全局扩展管理器实例
|
||||
@@ -58,6 +58,5 @@ export const removeExtensionManagerView = (documentId: number): void => {
|
||||
};
|
||||
|
||||
// 导出相关模块
|
||||
export {ExtensionManager} from './ExtensionManager';
|
||||
export {registerAllExtensions, getExtensionDisplayName, getExtensionDescription} from './factories';
|
||||
export type {ExtensionFactory} from './ExtensionManager';
|
||||
export {ExtensionManager} from './extensionManager';
|
||||
export {registerAllExtensions, getExtensionDisplayName, getExtensionDescription} from './extensions';
|
||||
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,
|
||||
getExtensionDisplayName,
|
||||
hasExtensionConfig
|
||||
} from '@/views/editor/manager/factories';
|
||||
} from '@/views/editor/manager/extensions';
|
||||
import SettingSection from '../components/SettingSection.vue';
|
||||
import SettingItem from '../components/SettingItem.vue';
|
||||
import ToggleSwitch from '../components/ToggleSwitch.vue';
|
||||
|
||||
@@ -5,7 +5,7 @@ import SettingSection from '../components/SettingSection.vue';
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore';
|
||||
import { useExtensionStore } from '@/stores/extensionStore';
|
||||
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";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -61,7 +61,7 @@ const parseKeyBinding = (keyStr: string, command?: string): string[] => {
|
||||
if (systemStore.isMacOS) {
|
||||
return ['⌘', '⌥', '[']; // macOS: Cmd+Alt+[
|
||||
} 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) {
|
||||
return ['⌘', '⌥', ']']; // macOS: Cmd+Alt+]
|
||||
} 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) {
|
||||
return ['⇧', '⌘', '\\']; // macOS: Shift+Cmd+\
|
||||
} else {
|
||||
return ['⇧', 'Ctrl', '\\']; // Windows/Linux: Shift+Ctrl+\
|
||||
return ['Shift', 'Ctrl', '\\']; // Windows/Linux: Shift+Ctrl+\
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,4 +300,4 @@ const parseKeyBinding = (keyStr: string, command?: string): string[] => {
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
Reference in New Issue
Block a user