feat(menus): 右键菜单与切换五项(docs/21)

屏蔽 WebView 自带右键菜单(输入框除外);新增 useDismiss 统一修复菜单
点别处不消失(标签菜单实测中招、文件树菜单一并加固);侧栏页签行加收起
按钮、收起后编辑区左上角有展开按钮;编辑区右下角加源码/即时渲染切换按钮;
标签与文件树右键补保存/另存为/新窗口打开/导出/打印(针对目标文件,
未打开先打开再执行)。vitest 97 通过 + 2 跳过。
This commit is contained in:
Muse
2026-11-14 15:05:19 +08:00
parent 41e0331dc9
commit 2d1cd3045a
7 changed files with 334 additions and 20 deletions

View File

@@ -0,0 +1,113 @@
// 右键菜单回归(docs/21):标签菜单与文件树菜单的文件类动作、点别处关闭。
import { describe, it, expect, vi } from 'vitest'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
vi.mock('../src/services/platform', async (importOriginal) => {
const orig = await importOriginal<typeof import('../src/services/platform')>()
return {
...orig,
listDir: async (dir: string) =>
dir === 'C:/ws' ? [{ name: 'a.md', path: 'C:/ws/a.md', isDir: false }] : [],
}
})
import TabBar from '../src/components/TabBar'
import Sidebar from '../src/components/Sidebar'
import { createTab } from '../src/stores/tabs'
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
function rightClick(el: Element) {
el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 10, clientY: 10 }))
}
function mouseDownOutside() {
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
}
function menuTexts(container: HTMLElement, selector: string) {
const menu = container.querySelector(selector)
return menu ? [...menu.querySelectorAll('button')].map((b) => b.textContent) : null
}
describe('标签右键菜单', () => {
it('含文件类动作;动作回调带标签 id;点别处关闭', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const tab = createTab({ title: 'a', content: 'x', path: 'C:/a.md' })
const onSaveAsTab = vi.fn()
await act(async () => {
root.render(
<TabBar
tabs={[tab]} activeId={tab.id}
onActivate={() => undefined} onClose={() => undefined}
onCloseOthers={() => undefined} onCloseRight={() => undefined}
onNew={() => undefined} onMove={() => undefined}
onSaveAsTab={onSaveAsTab}
onOpenInNewWindowTab={() => undefined}
/>,
)
})
const tabEl = container.querySelector('.tab') as Element
await act(async () => { rightClick(tabEl) })
const texts = menuTexts(container, '.context-menu') ?? []
for (const label of ['另存为…', '在新窗口打开', '导出…', '打印 / 导出 PDF…', '关闭']) {
expect(texts.some((t) => t === label)).toBe(true)
}
// 点菜单外(文档任意处按下)→ 关闭
await act(async () => { mouseDownOutside() })
expect(container.querySelector('.context-menu')).toBeNull()
// 重新打开并点「另存为…」→ 回调触发且菜单关闭
await act(async () => { rightClick(tabEl) })
const saveAsBtn = [...container.querySelectorAll('.context-menu button')].find((b) => b.textContent === '另存为…')
await act(async () => { saveAsBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(onSaveAsTab).toHaveBeenCalledWith(tab.id)
expect(container.querySelector('.context-menu')).toBeNull()
await act(async () => { root.unmount() })
container.remove()
})
})
describe('文件树右键菜单', () => {
it('文件条目含打开/新窗口/另存为/导出/打印;点别处关闭', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const onExportEntry = vi.fn()
await act(async () => {
root.render(
<Sidebar
mode="files" onModeChange={() => undefined}
workspaceRoot="C:/ws" activePath={null}
onOpenFile={() => undefined} onOpenFolder={() => undefined}
outline={[]} onJumpHeading={() => undefined} tauriMode
onRenameEntry={() => undefined}
onOpenInNewWindowEntry={() => undefined}
onSaveAsEntry={() => undefined}
onExportEntry={onExportEntry}
onPrintEntry={() => undefined}
/>,
)
})
await act(async () => { await sleep(30) })
const item = [...container.querySelectorAll('.tree-item')].find((el) => el.textContent?.includes('a.md')) as Element
expect(item).toBeTruthy()
await act(async () => { rightClick(item) })
const texts = menuTexts(container, '.tree-context-menu') ?? []
for (const label of ['打开', '在新窗口打开', '另存为…', '导出…', '打印 / 导出 PDF…', '重命名…']) {
expect(texts.some((t) => t === label)).toBe(true)
}
const exportBtn = [...container.querySelectorAll('.tree-context-menu button')].find((b) => b.textContent === '导出…')
await act(async () => { exportBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(onExportEntry).toHaveBeenCalledTimes(1)
expect(onExportEntry.mock.calls[0][0].path).toBe('C:/ws/a.md')
expect(container.querySelector('.tree-context-menu')).toBeNull()
// 再次打开后点别处关闭
await act(async () => { rightClick(item) })
expect(container.querySelector('.tree-context-menu')).not.toBeNull()
await act(async () => { mouseDownOutside() })
expect(container.querySelector('.tree-context-menu')).toBeNull()
await act(async () => { root.unmount() })
container.remove()
})
})