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:
parent
ed10edb5f7
commit
cdadbc19f1
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue