roymondchen df8790042f feat(editor): 导航菜单支持菜单项溢出收纳,新增 NavMenuColumn 组件
- 抽离每列渲染逻辑为 NavMenuColumn 组件,监听容器宽度
- 容器空间不足时自动隐藏溢出项,并通过更多按钮 Popover 展开
- ToolButton 暴露根元素引用,便于父级测量宽度
- design ButtonProps 新增 bg 属性,用于更多按钮的激活态样式
- 补充 NavMenuColumn / NavMenu / ToolButton 的单元测试

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-15 19:33:53 +08:00

181 lines
6.1 KiB
TypeScript

/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { defineComponent, h, ref } from 'vue';
import { mount } from '@vue/test-utils';
import NavMenu from '@editor/layouts/NavMenu.vue';
const editorService = {
get: vi.fn(),
remove: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
};
const historyService = { state: { canUndo: true, canRedo: true } };
const uiService = {
get: vi.fn(),
set: vi.fn(),
zoom: vi.fn(),
calcZoom: vi.fn(async () => 0.5),
};
vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ editorService, historyService, uiService }),
}));
vi.mock('@editor/components/ToolButton.vue', () => ({
default: defineComponent({
name: 'ToolButton',
props: ['data'],
setup(props, { expose }) {
const rootEl = ref<HTMLElement | null>(null);
expose({ getElRef: () => rootEl });
return () =>
h(
'button',
{
ref: rootEl,
class: ['tool-btn', (props.data as any).className],
onClick: () => (props.data as any).handler?.(),
},
(props.data as any).type === 'text' ? (props.data as any).text : '',
);
},
}),
}));
vi.mock('@editor/type', async () => {
const actual = await vi.importActual<any>('@editor/type');
return { ...actual, ColumnLayout: { LEFT: 'left', CENTER: 'center', RIGHT: 'right' } };
});
class FakeResizeObserver {
cb: any;
constructor(cb: any) {
this.cb = cb;
}
observe() {}
unobserve() {}
disconnect() {}
}
(globalThis as any).ResizeObserver = FakeResizeObserver;
beforeEach(() => {
vi.clearAllMocks();
uiService.get.mockImplementation((k: string) => {
if (k === 'columnWidth') return { left: 100, center: 200, right: 100 };
if (k === 'zoom') return 1;
if (k === 'showGuides') return true;
if (k === 'hasGuides') return true;
if (k === 'showRule') return true;
return null;
});
editorService.get.mockReturnValue({ id: 'n1', type: 'text' });
});
describe('NavMenu', () => {
test('支持 string 配置生成按钮', () => {
const wrapper = mount(NavMenu, {
props: {
data: {
left: ['delete', 'undo', 'redo'],
center: ['/'],
right: ['rule', 'guides'],
},
} as any,
});
expect(wrapper.findAll('.delete').length).toBe(1);
expect(wrapper.findAll('.undo').length).toBe(1);
expect(wrapper.findAll('.redo').length).toBe(1);
expect(wrapper.findAll('.rule').length).toBe(1);
expect(wrapper.findAll('.guides').length).toBe(1);
});
test('zoom 配置生成多个按钮和文本', () => {
const wrapper = mount(NavMenu, { props: { data: { left: ['zoom'] } } as any });
expect(wrapper.findAll('.zoom-out').length).toBe(1);
expect(wrapper.findAll('.zoom-in').length).toBe(1);
expect(wrapper.findAll('.scale-to-original').length).toBe(1);
expect(wrapper.findAll('.scale-to-fit').length).toBe(1);
expect(wrapper.text()).toContain('100%');
});
test('delete 按钮触发 editorService.remove', async () => {
const wrapper = mount(NavMenu, { props: { data: { left: ['delete'] } } as any });
await wrapper.find('.delete').trigger('click');
expect(editorService.remove).toHaveBeenCalled();
});
test('undo 按钮触发 editorService.undo', async () => {
const wrapper = mount(NavMenu, { props: { data: { left: ['undo'] } } as any });
await wrapper.find('.undo').trigger('click');
expect(editorService.undo).toHaveBeenCalled();
});
test('redo 按钮触发 editorService.redo', async () => {
const wrapper = mount(NavMenu, { props: { data: { left: ['redo'] } } as any });
await wrapper.find('.redo').trigger('click');
expect(editorService.redo).toHaveBeenCalled();
});
test('zoom-in/out 触发 uiService.zoom', async () => {
const wrapper = mount(NavMenu, { props: { data: { left: ['zoom'] } } as any });
await wrapper.find('.zoom-in').trigger('click');
expect(uiService.zoom).toHaveBeenCalledWith(0.1);
await wrapper.find('.zoom-out').trigger('click');
expect(uiService.zoom).toHaveBeenCalledWith(-0.1);
});
test('scale-to-original 触发 uiService.set zoom 1', async () => {
const wrapper = mount(NavMenu, { props: { data: { left: ['zoom'] } } as any });
await wrapper.find('.scale-to-original').trigger('click');
expect(uiService.set).toHaveBeenCalledWith('zoom', 1);
});
test('scale-to-fit 触发 calcZoom', async () => {
const wrapper = mount(NavMenu, { props: { data: { left: ['zoom'] } } as any });
await wrapper.find('.scale-to-fit').trigger('click');
await new Promise((r) => setTimeout(r, 0));
expect(uiService.calcZoom).toHaveBeenCalled();
});
test('rule 切换 showRule', async () => {
const wrapper = mount(NavMenu, { props: { data: { left: ['rule'] } } as any });
await wrapper.find('.rule').trigger('click');
expect(uiService.set).toHaveBeenCalledWith('showRule', false);
});
test('guides 切换 showGuides', async () => {
const wrapper = mount(NavMenu, { props: { data: { left: ['guides'] } } as any });
await wrapper.find('.guides').trigger('click');
expect(uiService.set).toHaveBeenCalledWith('showGuides', false);
});
test('hasGuides 为 false 时不渲染 guides 按钮', () => {
uiService.get.mockImplementation((k: string) => {
if (k === 'columnWidth') return { left: 100, center: 200, right: 100 };
if (k === 'hasGuides') return false;
if (k === 'zoom') return 1;
return null;
});
const wrapper = mount(NavMenu, { props: { data: { left: ['guides'] } } as any });
expect(wrapper.find('.guides').exists()).toBe(false);
});
test('对象配置直接传递', () => {
const wrapper = mount(NavMenu, {
props: { data: { left: [{ type: 'button', className: 'custom', text: 'A' }] } } as any,
});
expect(wrapper.find('.custom').exists()).toBe(true);
});
test('未知字符串生成 text 配置', () => {
const wrapper = mount(NavMenu, { props: { data: { left: ['xxxxx'] } } as any });
expect(wrapper.text()).toContain('xxxxx');
});
});