e.stopPropagation()}>
{!menu.entry.isDir &&
}
+ {!menu.entry.isDir && onOpenInNewWindowEntry &&
}
+ {!menu.entry.isDir && onSaveAsEntry &&
}
+ {!menu.entry.isDir && onExportEntry &&
}
+ {!menu.entry.isDir && onPrintEntry &&
}
+ {!menu.entry.isDir && (onRenameEntry || onDeleteEntry) &&
}
{menu.entry.isDir && onCreateFileIn &&
}
{menu.entry.isDir && onCreateFolderIn &&
}
{onRenameEntry &&
}
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx
index b801bf4..fb59baa 100644
--- a/src/components/TabBar.tsx
+++ b/src/components/TabBar.tsx
@@ -1,6 +1,7 @@
-import { useState } from 'react'
+import { useCallback, useRef, useState } from 'react'
import type { DocTab } from '../stores/tabs'
import { isDirty } from '../stores/tabs'
+import { useDismiss } from '../hooks/useDismiss'
interface Props {
tabs: DocTab[]
@@ -11,16 +12,32 @@ interface Props {
onCloseRight: (id: string) => void
onNew: () => void
onMove: (id: string, toIndex: number) => void
+ /** 右键菜单的文件类动作(针对该标签,不必先切过去) */
+ onSaveTab?: (id: string) => void
+ onSaveAsTab?: (id: string) => void
+ onOpenInNewWindowTab?: (id: string) => void
+ onExportTab?: (id: string) => void
+ onPrintTab?: (id: string) => void
}
-export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOthers, onCloseRight, onNew, onMove }: Props) {
+export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOthers, onCloseRight, onNew, onMove, onSaveTab, onSaveAsTab, onOpenInNewWindowTab, onExportTab, onPrintTab }: Props) {
const [menuFor, setMenuFor] = useState<{ id: string; x: number; y: number } | null>(null)
const [dragId, setDragId] = useState
(null)
+ const menuRef = useRef(null)
+ const closeMenu = useCallback(() => setMenuFor(null), [])
+ useDismiss(menuRef, menuFor !== null, closeMenu)
if (tabs.length === 0) return null
+ const menuTab = menuFor ? tabs.find((t) => t.id === menuFor.id) : undefined
+ const act = (fn?: (id: string) => void) => {
+ if (!menuFor) return
+ setMenuFor(null)
+ fn?.(menuFor.id)
+ }
+
return (
- setMenuFor(null)}>
+
{tabs.map((tab, index) => (
))}
- {menuFor && (
-
-
-
-
+ {menuFor && menuTab && (
+
+
+
+ {onOpenInNewWindowTab &&
}
+
+
+
+
+
+
)}
diff --git a/src/hooks/useDismiss.ts b/src/hooks/useDismiss.ts
new file mode 100644
index 0000000..a18a2aa
--- /dev/null
+++ b/src/hooks/useDismiss.ts
@@ -0,0 +1,30 @@
+import { useEffect, type RefObject } from 'react'
+
+/**
+ * 菜单「点别处不消失」的统一修复:菜单打开期间,指针按下/右键落在菜单外、
+ * 按 Esc、窗口失焦、或菜单外发生滚动时关闭。
+ * 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行,
+ * 这样在另一个标签上右键时是「先关旧菜单、再开新菜单」,不会互相冲掉。
+ */
+export function useDismiss(ref: RefObject
, active: boolean, onClose: () => void) {
+ useEffect(() => {
+ if (!active) return
+ const outside = (target: EventTarget | null) =>
+ ref.current && target instanceof Node && !ref.current.contains(target)
+ const onPointer = (e: MouseEvent) => { if (outside(e.target)) onClose() }
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
+ const onScroll = (e: Event) => { if (outside(e.target)) onClose() }
+ document.addEventListener('mousedown', onPointer, true)
+ document.addEventListener('contextmenu', onPointer, true)
+ document.addEventListener('keydown', onKey)
+ document.addEventListener('scroll', onScroll, true)
+ window.addEventListener('blur', onClose)
+ return () => {
+ document.removeEventListener('mousedown', onPointer, true)
+ document.removeEventListener('contextmenu', onPointer, true)
+ document.removeEventListener('keydown', onKey)
+ document.removeEventListener('scroll', onScroll, true)
+ window.removeEventListener('blur', onClose)
+ }
+ }, [ref, active, onClose])
+}
diff --git a/src/styles.css b/src/styles.css
index 222670e..a13f2b3 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -68,6 +68,15 @@ button { font: inherit; color: inherit; }
.tab-new { border: 0; background: transparent; padding: 0 12px; cursor: pointer; color: var(--text-dim); font-size: 15px; }
.tab-new:hover { background: var(--hover); }
.context-menu { position: fixed; z-index: 90; background: var(--bg-editor); border: 1px solid var(--border); border-radius: 8px; padding: 5px; box-shadow: 0 8px 24px rgba(0,0,0,0.13); display: flex; flex-direction: column; min-width: 170px; }
+.context-menu button:disabled { opacity: 0.45; cursor: default; }
+.context-menu button:disabled:hover { background: transparent; }
+.sidebar-collapse { margin-left: auto; background: transparent; border: 0; color: var(--text-dim); cursor: pointer; display: flex; align-items: center; padding: 4px 6px; border-radius: 6px; }
+.sidebar-collapse:hover { color: var(--text); background: var(--accent-weak); }
+.sidebar-expand { position: absolute; left: 10px; top: 10px; z-index: 35; width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 7px; color: var(--text-dim); cursor: pointer; }
+.sidebar-expand:hover { color: var(--text); border-color: var(--accent); }
+.source-toggle { position: absolute; right: 14px; bottom: 14px; z-index: 35; background: var(--bg-panel); color: var(--text-dim); border: 1px solid var(--border); border-radius: 999px; padding: 5px 13px; font-size: 12.5px; cursor: pointer; box-shadow: 0 4px 14px rgba(15, 23, 42, 0.10); }
+.source-toggle:hover { color: var(--text); border-color: var(--accent); }
+.tree-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
.context-menu button { border: 0; background: transparent; text-align: left; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; }
.context-menu button:hover { background: var(--accent-weak); }
@@ -270,7 +279,7 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
/* ——— 阶段 5:打印(只打文档区,隐藏全部框架) ——— */
@media print {
.titlebar, .menubar, .tabbar, .sidebar, .statusbar, .modal-backdrop, .app-notice, .context-menu,
- .table-toolbar, .tree-context-menu, .findbar, .mermaid-head { display: none !important; }
+ .table-toolbar, .tree-context-menu, .findbar, .mermaid-head, .source-toggle, .sidebar-expand { display: none !important; }
.app, .main, .content-col, .editor-area, .editor-scroll { display: block !important; height: auto !important; overflow: visible !important; }
.editor-scroll { background: #fff !important; }
/* 打印只出活动窗格:保活的隐藏窗格不能被上面的 display:block 通杀带出来 */
diff --git a/tests/context-menus.test.tsx b/tests/context-menus.test.tsx
new file mode 100644
index 0000000..3dd16ed
--- /dev/null
+++ b/tests/context-menus.test.tsx
@@ -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()
+ 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(
+ 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(
+ 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()
+ })
+})