This commit is contained in:
2025-12-08 23:28:36 +08:00
parent 3660d13d7d
commit a9c81c878e
8 changed files with 8 additions and 8 deletions

View File

@@ -0,0 +1,181 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import { contextMenuManager } from './manager';
import type { RenderMenuItem } from './menuSchema';
const props = defineProps<{
portalTarget?: HTMLElement | null;
}>();
const menuState = contextMenuManager.useState();
const menuRef = ref<HTMLDivElement | null>(null);
const adjustedPosition = ref({ x: 0, y: 0 });
const isVisible = computed(() => menuState.value.visible);
const items = computed(() => menuState.value.items);
const position = computed(() => menuState.value.position);
const teleportTarget = computed<HTMLElement | string>(() => props.portalTarget ?? 'body');
watch(
position,
(newPosition) => {
adjustedPosition.value = { ...newPosition };
if (isVisible.value) {
nextTick(adjustMenuWithinViewport);
}
},
{ deep: true }
);
watch(isVisible, (visible) => {
if (visible) {
nextTick(adjustMenuWithinViewport);
// 显示时添加 outside 点击监听
document.addEventListener('mousedown', handleClickOutside);
} else {
// 隐藏时移除监听
document.removeEventListener('mousedown', handleClickOutside);
}
});
// 清理
onUnmounted(() => {
document.removeEventListener('mousedown', handleClickOutside);
});
const menuStyle = computed(() => ({
left: `${adjustedPosition.value.x}px`,
top: `${adjustedPosition.value.y}px`
}));
async function adjustMenuWithinViewport() {
await nextTick();
const menuEl = menuRef.value;
if (!menuEl) return;
const rect = menuEl.getBoundingClientRect();
let nextX = adjustedPosition.value.x;
let nextY = adjustedPosition.value.y;
if (rect.right > window.innerWidth) {
nextX = Math.max(0, window.innerWidth - rect.width - 8);
}
if (rect.bottom > window.innerHeight) {
nextY = Math.max(0, window.innerHeight - rect.height - 8);
}
adjustedPosition.value = { x: nextX, y: nextY };
}
function handleItemClick(item: RenderMenuItem) {
if (item.type !== "action" || item.disabled) {
return;
}
contextMenuManager.runCommand(item);
}
function handleClickOutside(event: MouseEvent) {
// 如果点击在菜单内部,不关闭
if (menuRef.value?.contains(event.target as Node)) {
return;
}
contextMenuManager.hide();
}
</script>
<template>
<Teleport :to="teleportTarget">
<template v-if="isVisible">
<div
ref="menuRef"
class="cm-context-menu show"
:style="menuStyle"
role="menu"
@contextmenu.prevent
>
<template v-for="item in items" :key="item.id">
<div v-if="item.type === 'separator'" class="cm-context-menu-divider" />
<div
v-else
class="cm-context-menu-item"
:class="{ 'is-disabled': item.disabled }"
role="menuitem"
:aria-disabled="item.disabled ? 'true' : 'false'"
@click="handleItemClick(item)"
>
<div class="cm-context-menu-item-label">
<span>{{ item.label }}</span>
</div>
<span v-if="item.shortcut" class="cm-context-menu-item-shortcut">
{{ item.shortcut }}
</span>
</div>
</template>
</div>
</template>
</Teleport>
</template>
<style scoped lang="scss">
.cm-context-menu {
position: fixed;
min-width: 180px;
max-width: 320px;
padding: 4px 0;
border-radius: 3px;
background-color: var(--settings-card-bg, #1c1c1e);
color: var(--settings-text, #f6f6f6);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
z-index: 10000;
opacity: 0;
transform: scale(0.96);
transform-origin: top left;
transition: opacity 0.12s ease, transform 0.12s ease;
}
.cm-context-menu.show {
opacity: 1;
transform: scale(1);
}
.cm-context-menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
font-size: 14px;
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
white-space: nowrap;
}
.cm-context-menu-item:hover {
background-color: var(--toolbar-button-hover);
color: var(--toolbar-text, #ffffff);
}
.cm-context-menu-item.is-disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cm-context-menu-item-label {
display: flex;
align-items: center;
gap: 8px;
}
.cm-context-menu-item-shortcut {
font-size: 12px;
opacity: 0.65;
}
.cm-context-menu-divider {
height: 1px;
margin: 4px 0;
border: none;
background-color: rgba(255, 255, 255, 0.08);
}
</style>

View File

@@ -0,0 +1,141 @@
import { EditorView } from '@codemirror/view';
import { Extension } from '@codemirror/state';
import { copyCommand, cutCommand, pasteCommand } from '../codeblock/copyPaste';
import { KeyBindingCommand } from '../../../../../bindings/voidraft/internal/models/models';
import { useKeybindingStore } from '@/stores/keybindingStore';
import { undo, redo } from '@codemirror/commands';
import i18n from '@/i18n';
import { useSystemStore } from '@/stores/systemStore';
import { showContextMenu } from './manager';
import {
buildRegisteredMenu,
createMenuContext,
registerMenuNodes
} from './menuSchema';
import type { MenuSchemaNode } from './menuSchema';
function t(key: string): string {
return i18n.global.t(key);
}
function formatKeyBinding(keyBinding: string): string {
const systemStore = useSystemStore();
const isMac = systemStore.isMacOS;
return keyBinding
.replace("Mod", isMac ? "Cmd" : "Ctrl")
.replace("Shift", "Shift")
.replace("Alt", isMac ? "Option" : "Alt")
.replace("Ctrl", isMac ? "Ctrl" : "Ctrl")
.replace(/-/g, " + ");
}
const shortcutCache = new Map<KeyBindingCommand, string>();
function getShortcutText(command?: KeyBindingCommand): string {
if (command === undefined) {
return "";
}
const cached = shortcutCache.get(command);
if (cached !== undefined) {
return cached;
}
try {
const keybindingStore = useKeybindingStore();
const binding = keybindingStore.keyBindings.find(
(kb) => kb.command === command && kb.enabled
);
if (binding?.key) {
const formatted = formatKeyBinding(binding.key);
shortcutCache.set(command, formatted);
return formatted;
}
} catch (error) {
console.warn("An error occurred while getting the shortcut:", error);
}
shortcutCache.set(command, "");
return "";
}
function getBuiltinMenuNodes(): MenuSchemaNode[] {
return [
{
id: "copy",
labelKey: "keybindings.commands.blockCopy",
command: copyCommand,
shortcutCommand: KeyBindingCommand.BlockCopyCommand,
enabled: (context) => context.hasSelection
},
{
id: "cut",
labelKey: "keybindings.commands.blockCut",
command: cutCommand,
shortcutCommand: KeyBindingCommand.BlockCutCommand,
visible: (context) => context.isEditable,
enabled: (context) => context.hasSelection && context.isEditable
},
{
id: "paste",
labelKey: "keybindings.commands.blockPaste",
command: pasteCommand,
shortcutCommand: KeyBindingCommand.BlockPasteCommand,
visible: (context) => context.isEditable
},
{
id: "undo",
labelKey: "keybindings.commands.historyUndo",
command: undo,
shortcutCommand: KeyBindingCommand.HistoryUndoCommand,
visible: (context) => context.isEditable
},
{
id: "redo",
labelKey: "keybindings.commands.historyRedo",
command: redo,
shortcutCommand: KeyBindingCommand.HistoryRedoCommand,
visible: (context) => context.isEditable
}
];
}
let builtinMenuRegistered = false;
function ensureBuiltinMenuRegistered(): void {
if (builtinMenuRegistered) return;
registerMenuNodes(getBuiltinMenuNodes());
builtinMenuRegistered = true;
}
export function createEditorContextMenu(): Extension {
ensureBuiltinMenuRegistered();
return EditorView.domEventHandlers({
contextmenu: (event, view) => {
event.preventDefault();
const context = createMenuContext(view, event as MouseEvent);
const menuItems = buildRegisteredMenu(context, {
translate: t,
formatShortcut: getShortcutText
});
if (menuItems.length === 0) {
return false;
}
showContextMenu(view, event.clientX, event.clientY, menuItems);
return true;
}
});
}
export default createEditorContextMenu;

View File

@@ -0,0 +1,108 @@
import type { EditorView } from '@codemirror/view';
import { readonly, shallowRef, type ShallowRef } from 'vue';
import type { RenderMenuItem } from './menuSchema';
interface MenuPosition {
x: number;
y: number;
}
interface ContextMenuState {
visible: boolean;
position: MenuPosition;
items: RenderMenuItem[];
view: EditorView | null;
}
class ContextMenuManager {
private state: ShallowRef<ContextMenuState> = shallowRef({
visible: false,
position: { x: 0, y: 0 },
items: [] as RenderMenuItem[],
view: null as EditorView | null
});
useState() {
return readonly(this.state);
}
show(view: EditorView, clientX: number, clientY: number, items: RenderMenuItem[]): void {
const currentState = this.state.value;
// 如果菜单已经显示且位置很接近20px范围内则只更新内容避免闪烁
if (currentState.visible) {
const dx = Math.abs(currentState.position.x - clientX);
const dy = Math.abs(currentState.position.y - clientY);
const isSamePosition = dx < 20 && dy < 20;
if (isSamePosition) {
// 只更新items和view保持visible状态和位置
this.state.value = {
...currentState,
items,
view
};
return;
}
}
// 否则正常显示菜单
this.state.value = {
visible: true,
position: { x: clientX, y: clientY },
items,
view
};
}
hide(): void {
if (!this.state.value.visible) {
return;
}
const previousPosition = this.state.value.position;
const view = this.state.value.view;
this.state.value = {
visible: false,
position: previousPosition,
items: [],
view: null
};
if (view) {
view.focus();
}
}
runCommand(item: RenderMenuItem): void {
if (item.type !== "action" || item.disabled) {
return;
}
const { view } = this.state.value;
if (item.command && view) {
item.command(view);
}
this.hide();
}
destroy(): void {
this.state.value = {
visible: false,
position: { x: 0, y: 0 },
items: [],
view: null
};
}
}
export const contextMenuManager = new ContextMenuManager();
export function showContextMenu(
view: EditorView,
clientX: number,
clientY: number,
items: RenderMenuItem[]
): void {
contextMenuManager.show(view, clientX, clientY, items);
}

View File

@@ -0,0 +1,102 @@
import type { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import type { KeyBindingCommand } from '../../../../../bindings/voidraft/internal/models/models';
export interface MenuContext {
view: EditorView;
event: MouseEvent;
hasSelection: boolean;
selectionText: string;
isEditable: boolean;
}
export type MenuSchemaNode =
| {
id: string;
type?: "action";
labelKey: string;
command?: (view: EditorView) => boolean;
shortcutCommand?: KeyBindingCommand;
visible?: (context: MenuContext) => boolean;
enabled?: (context: MenuContext) => boolean;
}
| {
id: string;
type: "separator";
visible?: (context: MenuContext) => boolean;
};
export interface RenderMenuItem {
id: string;
type: "action" | "separator";
label?: string;
shortcut?: string;
disabled?: boolean;
command?: (view: EditorView) => boolean;
}
interface MenuBuildOptions {
translate: (key: string) => string;
formatShortcut: (command?: KeyBindingCommand) => string;
}
const menuRegistry: MenuSchemaNode[] = [];
export function createMenuContext(view: EditorView, event: MouseEvent): MenuContext {
const { state } = view;
const hasSelection = state.selection.ranges.some((range) => !range.empty);
const selectionText = hasSelection
? state.sliceDoc(state.selection.main.from, state.selection.main.to)
: "";
const isEditable = !state.facet(EditorState.readOnly);
return {
view,
event,
hasSelection,
selectionText,
isEditable
};
}
export function registerMenuNodes(nodes: MenuSchemaNode[]): void {
menuRegistry.push(...nodes);
}
export function buildRegisteredMenu(
context: MenuContext,
options: MenuBuildOptions
): RenderMenuItem[] {
return menuRegistry
.map((node) => convertNode(node, context, options))
.filter((item): item is RenderMenuItem => Boolean(item));
}
function convertNode(
node: MenuSchemaNode,
context: MenuContext,
options: MenuBuildOptions
): RenderMenuItem | null {
if (node.visible && !node.visible(context)) {
return null;
}
if (node.type === "separator") {
return {
id: node.id,
type: "separator"
};
}
const disabled = node.enabled ? !node.enabled(context) : false;
const shortcut = options.formatShortcut(node.shortcutCommand);
return {
id: node.id,
type: "action",
label: options.translate(node.labelKey),
shortcut: shortcut || undefined,
disabled,
command: node.command
};
}