diff --git a/docs/21-menus-and-toggles.md b/docs/21-menus-and-toggles.md new file mode 100644 index 0000000..38dceac --- /dev/null +++ b/docs/21-menus-and-toggles.md @@ -0,0 +1,44 @@ +# 菜单与切换五项(2026-11-14) + +## 1. 屏蔽浏览器右键菜单 + +App 全局监听 contextmenu 并 preventDefault:编辑区、标题栏等处不再冒出 +WebView 自带菜单;应用自绘菜单(标签/文件树/菜单栏)走各自的 contextmenu +处理,不受影响。文本输入框(input/textarea)保留系统菜单,方便粘贴。 + +## 2. 菜单点别处不消失——全量排查与统一修复 + +排查结论:菜单栏下拉本身有外部 mousedown + Esc 关闭(正常);文件树菜单 +只监听 window click(右键点别处、滚动时不关);**标签右键菜单只在标签栏 +内部点击时关闭,点编辑区/侧边栏都不消失(实测确认)**。 +新增共享 hook `src/hooks/useDismiss.ts`:捕获阶段监听 mousedown/contextmenu +(先于 React 的 contextmenu 处理,右键另一个标签时先关旧再开新)、Esc、 +窗口失焦、菜单外滚动,统一应用到标签菜单与文件树菜单;菜单栏维持原实现。 + +## 3. 左侧栏收起 + +开关逻辑本身正常(视图菜单与 Ctrl+Shift+L 实测可收起),问题在侧栏自身 +没有入口、必须去菜单里找。侧栏页签行右侧新增收起按钮(‹);收起后编辑区 +左上角出现展开按钮(›)。菜单与快捷键保持不变。 + +## 4. 源码模式切换按钮 + +编辑区右下角新增浮动按钮:渲染模式下显示「源码模式」、源码模式下显示 +「即时渲染」,一键切换,不必再去视图菜单。打印时隐藏。 + +## 5. 右键菜单补文件类动作 + +- 标签右键:新增 保存(未修改时禁用)、另存为…、在新窗口打开(桌面模式)、 + 导出…、打印 / 导出 PDF…,与原有关闭类动作以分隔线分组。动作针对被右键 + 的标签:导出/打印会先激活该标签再执行,不必先手动切过去。 +- 文件树右键(文件条目):新增 在新窗口打开(桌面模式)、另存为…、导出…、 + 打印 / 导出 PDF…。文件未打开时先打开/激活再执行(withOpenedTab)。 + +## 验证 + +- tsc 0 错误;vitest 97 通过 + 2 跳过(新增 tests/context-menus.test.tsx: + 两类菜单的条目、回调、点别处关闭)。 +- 无头浏览器实测:编辑区 contextmenu 已被 preventDefault;标签菜单条目齐全、 + 点编辑区后关闭;收起/展开按钮往返正常;源码按钮切换后源码编辑器出现、 + 按钮文案翻转为「即时渲染」。 +- 待真机验:桌面模式下「在新窗口打开」两处入口、文件树导出/打印的先开后行链路。 diff --git a/src/App.tsx b/src/App.tsx index 23ed982..9552795 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -357,6 +357,51 @@ export default function App() { requestClose([id], () => dispatch({ type: 'close', id })) }, [requestClose]) + // ——— 右键菜单的文件类动作:针对目标标签/文件,不必先切过去再进二级菜单 ——— + const saveTabById = useCallback((id: string) => { + const t = stateRef.current.tabs.find((x) => x.id === id) + if (t) void saveTab(t, false) + }, [saveTab]) + const saveAsTabById = useCallback((id: string) => { + const t = stateRef.current.tabs.find((x) => x.id === id) + if (t) void saveTab(t, true) + }, [saveTab]) + const newWindowTabById = useCallback((id: string) => { + const t = stateRef.current.tabs.find((x) => x.id === id) + if (t?.path) void openInNewWindow(t.path) + }, [openInNewWindow]) + const exportTabById = useCallback((id: string) => { + dispatch({ type: 'activate', id }) + setSourceMode(false) + setExportOpen(true) + }, []) + const printTabById = useCallback((id: string) => { + dispatch({ type: 'activate', id }) + setSourceMode(false) + window.setTimeout(() => printRef.current(), 80) + }, []) + /** 文件树条目:先打开(或激活)该文件,等状态落定后对它的标签执行动作 */ + const withOpenedTab = useCallback((path: string, fn: (t: DocTab) => void) => { + void openPath(path).then(() => { + window.setTimeout(() => { + const t = stateRef.current.tabs.find((x) => samePath(x.path, path)) + if (t) fn(t) + }, 60) + }) + }, [openPath ]) + const saveAsEntry = useCallback((entry: DirEntry) => { + withOpenedTab(entry.path, (t) => { void saveTab(t, true) }) + }, [withOpenedTab, saveTab]) + const printEntry = useCallback((entry: DirEntry) => { + withOpenedTab(entry.path, () => printRef.current()) + }, [withOpenedTab]) + const exportEntry = useCallback((entry: DirEntry) => { + void openPath(entry.path).then(() => setExportOpen(true)) + }, [openPath ]) + const newWindowEntry = useCallback((entry: DirEntry) => { + void openInNewWindow(entry.path) + }, [openInNewWindow]) + // ——— 阶段 4:存储初始化与启动恢复 ——— const reloadTabFromDisk = useCallback(async (target: DocTab, expectedContent?: string, force = false) => { if (!target.path) return @@ -1164,6 +1209,11 @@ export default function App() { onCreateFolderIn={tauriMode && workspaceRoot ? createFolderInDir : undefined} onRenameEntry={tauriMode ? renameEntry : undefined} onDeleteEntry={tauriMode ? deleteEntry : undefined} + onOpenInNewWindowEntry={tauriMode ? newWindowEntry : undefined} + onSaveAsEntry={saveAsEntry} + onExportEntry={exportEntry} + onPrintEntry={printEntry} + onCollapseSidebar={() => setUi((u) => ({ ...u, sidebar: false }))} refreshSignal={treeRefresh} outline={outline} onJumpHeading={jumpHeading} @@ -1185,6 +1235,11 @@ export default function App() { }} onNew={newDoc} onMove={(id, toIndex) => dispatch({ type: 'move', id, toIndex })} + onSaveTab={saveTabById} + onSaveAsTab={saveAsTabById} + onOpenInNewWindowTab={tauriMode ? newWindowTabById : undefined} + onExportTab={exportTabById} + onPrintTab={printTabById} />
{!tab && ( @@ -1252,6 +1307,25 @@ export default function App() { /> ))} + {tab && ( + + )} + {!ui.sidebar && ( + + )} {findUi.open && tab && ( void onRenameEntry?: (entry: DirEntry) => void onDeleteEntry?: (entry: DirEntry) => void + /** 文件条目右键的文件类动作(打开/新窗口/另存为/导出/打印) */ + onOpenInNewWindowEntry?: (entry: DirEntry) => void + onSaveAsEntry?: (entry: DirEntry) => void + onExportEntry?: (entry: DirEntry) => void + onPrintEntry?: (entry: DirEntry) => void + /** 收起侧边栏(侧栏自身的按钮,不必去视图菜单里找) */ + onCollapseSidebar?: () => void refreshSignal?: number outline: OutlineItem[] onJumpHeading: (item: OutlineItem) => void @@ -28,6 +36,11 @@ function Sidebar(p: Props) { + {p.onCollapseSidebar && ( + + )}
{p.mode === 'search' ? ( @@ -49,6 +62,10 @@ function Sidebar(p: Props) { ) : p.workspaceRoot ? ( void @@ -79,18 +96,17 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder onRenameEntry?: (entry: DirEntry) => void onDeleteEntry?: (entry: DirEntry) => void refreshSignal: number + onOpenInNewWindowEntry?: (entry: DirEntry) => void + onSaveAsEntry?: (entry: DirEntry) => void + onExportEntry?: (entry: DirEntry) => void + onPrintEntry?: (entry: DirEntry) => void }) { const [refreshKey, setRefreshKey] = useState(0) const [menu, setMenu] = useState<{ x: number; y: number; entry: DirEntry } | null>(null) + const menuRef = useRef(null) + const closeMenu = useCallback(() => setMenu(null), []) + useDismiss(menuRef, menu !== null, closeMenu) useEffect(() => { if (refreshSignal > 0) setRefreshKey((k) => k + 1) }, [refreshSignal]) - useEffect(() => { - if (!menu) return - const close = () => setMenu(null) - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMenu(null) } - window.addEventListener('click', close) - window.addEventListener('keydown', onKey) - return () => { window.removeEventListener('click', close); window.removeEventListener('keydown', onKey) } - }, [menu]) return (
@@ -104,8 +120,13 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder onEntryMenu={(entry, x, y) => setMenu({ entry, x, y })} /> {menu && ( -
e.stopPropagation()}> +
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() + }) +})