fix: replace reka-ui context menu with lightweight custom component to fix editor lag

The reka-ui ContextMenu component caused significant performance
degradation when multiple editor tabs were cached via KeepAlive,
due to its heavy state machine, provide/inject chain, and document-level
event listeners accumulating across cached instances.

Replaced with a minimal CustomContextMenu that renders via Teleport
without any DOM wrapping or reka-ui dependency, eliminating the lag
while preserving the same visual appearance and functionality.
This commit is contained in:
t8y2 2026-05-28 12:50:57 +08:00
parent ed10edb5f7
commit cdadbc19f1
2 changed files with 150 additions and 24 deletions

View File

@ -1,17 +1,12 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, shallowRef, computed } from "vue";
import { Play, Copy, TextSelect } from "lucide-vue-next";
import { useI18n } from "vue-i18n";
import type { CompletionContext } from "@codemirror/autocomplete";
import type { EditorView as EditorViewType } from "@codemirror/view";
import { search as cmSearch } from "@codemirror/search";
import EditorSearchPanel from "./EditorSearchPanel.vue";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { copyToClipboard } from "@/lib/clipboard";
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
@ -334,6 +329,23 @@ function selectAllSqlFromContextMenu() {
focusEditor();
}
const contextMenuItems = computed<ContextMenuItem[]>(() => [
{
label: executeContextMenuLabel.value,
action: executeFromContextMenu,
disabled: !canExecuteContextSql.value,
icon: Play,
},
{ label: "", separator: true },
{
label: t("editor.contextMenu.copySelection"),
action: copySelectedSqlFromContextMenu,
disabled: !canCopySelectedSql.value,
icon: Copy,
},
{ label: t("editor.contextMenu.selectAll"), action: selectAllSqlFromContextMenu, icon: TextSelect },
]);
function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view"))["keymap"]) {
const shortcuts = settingsStore.editorSettings.shortcuts;
const Prec = codeMirrorPrec;
@ -1514,23 +1526,19 @@ defineExpose({ openSearch, openReplace });
@gesturechange="onEditorGestureChange"
@gestureend="onEditorGestureEnd"
>
<ContextMenu>
<ContextMenuTrigger as-child>
<div ref="editorRef" data-query-editor-root data-context-menu class="h-full w-full overflow-hidden" />
</ContextMenuTrigger>
<ContextMenuContent class="w-52">
<ContextMenuItem :disabled="!canExecuteContextSql" @click="executeFromContextMenu">
{{ executeContextMenuLabel }}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem :disabled="!canCopySelectedSql" @click="copySelectedSqlFromContextMenu">
{{ t("editor.contextMenu.copySelection") }}
</ContextMenuItem>
<ContextMenuItem @click="selectAllSqlFromContextMenu">
{{ t("editor.contextMenu.selectAll") }}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<CustomContextMenu :items="contextMenuItems" v-slot="{ onContextMenu }">
<div
ref="editorRef"
data-query-editor-root
class="h-full w-full overflow-hidden"
@contextmenu="
(e: MouseEvent) => {
if (view) syncContextMenuState(view);
onContextMenu(e);
}
"
/>
</CustomContextMenu>
<EditorSearchPanel ref="searchPanelRef" :view="view" />
</div>
</template>

View File

@ -0,0 +1,118 @@
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, nextTick, type Component } from "vue";
export interface ContextMenuItem {
label: string;
action?: () => void;
disabled?: boolean;
separator?: boolean;
icon?: Component;
}
const props = defineProps<{
items: ContextMenuItem[];
}>();
const show = ref(false);
const x = ref(0);
const y = ref(0);
const menuRef = ref<HTMLElement>();
function onContextMenu(event: MouseEvent) {
if (props.items.length === 0) return;
event.preventDefault();
event.stopPropagation();
x.value = event.clientX;
y.value = event.clientY;
show.value = true;
nextTick(() => adjustPosition());
}
function close() {
show.value = false;
}
function adjustPosition() {
if (!menuRef.value) return;
const rect = menuRef.value.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
if (rect.right > vw) {
x.value = Math.max(0, vw - rect.width - 8);
}
if (rect.bottom > vh) {
y.value = Math.max(0, vh - rect.height - 8);
}
}
function onClickOutside(e: MouseEvent) {
if (menuRef.value && !menuRef.value.contains(e.target as Node)) {
close();
}
}
function onScroll() {
close();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
function onResize() {
close();
}
watch(show, (val) => {
if (val) {
document.addEventListener("click", onClickOutside, true);
document.addEventListener("keydown", onKeydown);
document.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
} else {
document.removeEventListener("click", onClickOutside, true);
document.removeEventListener("keydown", onKeydown);
document.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
}
});
function handleItemClick(item: ContextMenuItem) {
if (item.disabled) return;
close();
item.action?.();
}
onBeforeUnmount(() => {
document.removeEventListener("click", onClickOutside, true);
document.removeEventListener("keydown", onKeydown);
document.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
});
</script>
<template>
<slot :on-context-menu="onContextMenu" />
<Teleport to="body">
<div
v-if="show"
ref="menuRef"
:style="{ position: 'fixed', left: x + 'px', top: y + 'px', zIndex: 9999 }"
class="text-popover-foreground min-w-36 rounded-xl p-1 cn-menu-translucent overflow-x-hidden overflow-y-auto"
>
<template v-for="(item, index) in items" :key="index">
<div v-if="item.separator" class="bg-foreground/6 -mx-1 my-0.5 h-px" />
<button
v-else
:disabled="item.disabled"
class="w-full gap-2 rounded-md px-2 py-1 text-[13px] outline-hidden select-none text-left cursor-default flex items-center hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
@click="handleItemClick(item)"
>
<component :is="item.icon" v-if="item.icon" class="size-4 shrink-0" />
{{ item.label }}
</button>
</template>
</div>
</Teleport>
</template>