diff --git a/packages/editor/src/components/ContentMenu.vue b/packages/editor/src/components/ContentMenu.vue index 38f38fcd..4236ee19 100644 --- a/packages/editor/src/components/ContentMenu.vue +++ b/packages/editor/src/components/ContentMenu.vue @@ -128,20 +128,42 @@ const outsideClickHideHandler = (e: MouseEvent) => { hide(); }; -const setPosition = (e: { clientY: number; clientX: number }) => { - const menuHeight = menuEl.value?.clientHeight || 0; +// 根据菜单实际尺寸修正位置,避免超出可视范围 +const fixPosition = () => { + const menu = menuEl.value; + if (!menu || !visible.value) return; - let top = e.clientY; - if (menuHeight + e.clientY > document.body.clientHeight) { - top = document.body.clientHeight - menuHeight; + const menuHeight = menu.clientHeight; + const menuWidth = menu.clientWidth; + + let { top, left } = menuPosition.value; + + if (top + menuHeight > document.body.clientHeight) { + top = Math.max(0, document.body.clientHeight - menuHeight); } + if (left + menuWidth > document.body.clientWidth) { + left = Math.max(0, document.body.clientWidth - menuWidth); + } + + if (top !== menuPosition.value.top || left !== menuPosition.value.left) { + menuPosition.value = { top, left }; + } +}; + +const setPosition = (e: { clientY: number; clientX: number }) => { menuPosition.value = { - top, + top: e.clientY, left: e.clientX, }; + fixPosition(); }; +// 菜单大小动态变化(如菜单项更新)后重新修正位置 +const resizeObserver = new ResizeObserver(() => { + fixPosition(); +}); + const show = (e?: { clientY: number; clientX: number }) => { visible.value = true; @@ -186,12 +208,18 @@ const mouseenterHandler = () => { }; onMounted(() => { + if (menuEl.value) { + resizeObserver.observe(menuEl.value); + } + if (props.isSubMenu) return; globalThis.addEventListener('mousedown', outsideClickHideHandler, true); }); onBeforeUnmount(() => { + resizeObserver.disconnect(); + if (props.isSubMenu) return; globalThis.removeEventListener('mousedown', outsideClickHideHandler, true); diff --git a/packages/editor/tests/unit/components/ContentMenu.spec.ts b/packages/editor/tests/unit/components/ContentMenu.spec.ts index 9345c00d..759070e8 100644 --- a/packages/editor/tests/unit/components/ContentMenu.spec.ts +++ b/packages/editor/tests/unit/components/ContentMenu.spec.ts @@ -4,10 +4,27 @@ * Copyright (C) 2025 Tencent. */ import { afterEach, describe, expect, test, vi } from 'vitest'; +import { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import ContentMenu from '@editor/components/ContentMenu.vue'; +const roCallbacks: Array<() => void> = []; +class FakeResizeObserver { + public static disconnectSpy = vi.fn(); + public cb: () => void; + constructor(cb: () => void) { + this.cb = cb; + roCallbacks.push(cb); + } + public observe() {} + public unobserve() {} + public disconnect() { + FakeResizeObserver.disconnectSpy(); + } +} +(globalThis as any).ResizeObserver = FakeResizeObserver; + const provideServices = () => ({ global: { provide: { @@ -22,6 +39,8 @@ const provideServices = () => ({ describe('ContentMenu.vue', () => { afterEach(() => { vi.useRealTimers(); + roCallbacks.length = 0; + FakeResizeObserver.disconnectSpy.mockClear(); }); test('show 后触发 show 事件', async () => { @@ -67,6 +86,62 @@ describe('ContentMenu.vue', () => { expect((wrapper.vm as any).menuPosition.top).toBeLessThanOrEqual(100); }); + test('菜单大小动态变化后修正位置避免超出可视范围', async () => { + // jsdom 中 clientWidth/clientHeight 默认为 0,先 mock 一个足够大的视口,避免 show 时位置被修正 + Object.defineProperty(document.body, 'clientHeight', { value: 1000, configurable: true }); + Object.defineProperty(document.body, 'clientWidth', { value: 1000, configurable: true }); + + const wrapper = mount(ContentMenu as any, { + ...provideServices(), + props: { menuData: [{ id: '1', type: 'button', text: 'a' }] as any }, + attachTo: document.body, + }); + (wrapper.vm as any).show({ clientX: 90, clientY: 90 }); + await new Promise((r) => setTimeout(r, 0)); + expect((wrapper.vm as any).menuPosition.top).toBe(90); + expect((wrapper.vm as any).menuPosition.left).toBe(90); + + // 视口缩小为 100x100 + Object.defineProperty(document.body, 'clientHeight', { value: 100, configurable: true }); + Object.defineProperty(document.body, 'clientWidth', { value: 100, configurable: true }); + + const menuEl = wrapper.find('.magic-editor-content-menu').element as HTMLElement; + // 模拟菜单内容增多后尺寸变为 50x50 + Object.defineProperty(menuEl, 'clientHeight', { value: 50, configurable: true }); + Object.defineProperty(menuEl, 'clientWidth', { value: 50, configurable: true }); + + roCallbacks.forEach((cb) => cb()); + await nextTick(); + + expect((wrapper.vm as any).menuPosition.top).toBe(50); + expect((wrapper.vm as any).menuPosition.left).toBe(50); + wrapper.unmount(); + }); + + test('菜单未显示时尺寸变化不修正位置', async () => { + const wrapper = mount(ContentMenu as any, { + ...provideServices(), + props: { menuData: [{ id: '1', type: 'button', text: 'a' }] as any }, + }); + Object.defineProperty(document.body, 'clientHeight', { value: 100, configurable: true }); + + roCallbacks.forEach((cb) => cb()); + await nextTick(); + + expect((wrapper.vm as any).menuPosition.top).toBe(0); + expect((wrapper.vm as any).menuPosition.left).toBe(0); + wrapper.unmount(); + }); + + test('卸载时断开 ResizeObserver', () => { + const wrapper = mount(ContentMenu as any, { + ...provideServices(), + props: { menuData: [] as any }, + }); + wrapper.unmount(); + expect(FakeResizeObserver.disconnectSpy).toHaveBeenCalled(); + }); + test('contains 判断 DOM 是否在菜单内部', async () => { const wrapper = mount(ContentMenu as any, { ...provideServices(),