feat(menus): 右键菜单与切换五项(docs/21)
屏蔽 WebView 自带右键菜单(输入框除外);新增 useDismiss 统一修复菜单 点别处不消失(标签菜单实测中招、文件树菜单一并加固);侧栏页签行加收起 按钮、收起后编辑区左上角有展开按钮;编辑区右下角加源码/即时渲染切换按钮; 标签与文件树右键补保存/另存为/新窗口打开/导出/打印(针对目标文件, 未打开先打开再执行)。vitest 97 通过 + 2 跳过。
This commit is contained in:
44
docs/21-menus-and-toggles.md
Normal file
44
docs/21-menus-and-toggles.md
Normal file
@@ -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;标签菜单条目齐全、
|
||||||
|
点编辑区后关闭;收起/展开按钮往返正常;源码按钮切换后源码编辑器出现、
|
||||||
|
按钮文案翻转为「即时渲染」。
|
||||||
|
- 待真机验:桌面模式下「在新窗口打开」两处入口、文件树导出/打印的先开后行链路。
|
||||||
74
src/App.tsx
74
src/App.tsx
@@ -357,6 +357,51 @@ export default function App() {
|
|||||||
requestClose([id], () => dispatch({ type: 'close', id }))
|
requestClose([id], () => dispatch({ type: 'close', id }))
|
||||||
}, [requestClose])
|
}, [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:存储初始化与启动恢复 ———
|
// ——— 阶段 4:存储初始化与启动恢复 ———
|
||||||
const reloadTabFromDisk = useCallback(async (target: DocTab, expectedContent?: string, force = false) => {
|
const reloadTabFromDisk = useCallback(async (target: DocTab, expectedContent?: string, force = false) => {
|
||||||
if (!target.path) return
|
if (!target.path) return
|
||||||
@@ -1164,6 +1209,11 @@ export default function App() {
|
|||||||
onCreateFolderIn={tauriMode && workspaceRoot ? createFolderInDir : undefined}
|
onCreateFolderIn={tauriMode && workspaceRoot ? createFolderInDir : undefined}
|
||||||
onRenameEntry={tauriMode ? renameEntry : undefined}
|
onRenameEntry={tauriMode ? renameEntry : undefined}
|
||||||
onDeleteEntry={tauriMode ? deleteEntry : undefined}
|
onDeleteEntry={tauriMode ? deleteEntry : undefined}
|
||||||
|
onOpenInNewWindowEntry={tauriMode ? newWindowEntry : undefined}
|
||||||
|
onSaveAsEntry={saveAsEntry}
|
||||||
|
onExportEntry={exportEntry}
|
||||||
|
onPrintEntry={printEntry}
|
||||||
|
onCollapseSidebar={() => setUi((u) => ({ ...u, sidebar: false }))}
|
||||||
refreshSignal={treeRefresh}
|
refreshSignal={treeRefresh}
|
||||||
outline={outline}
|
outline={outline}
|
||||||
onJumpHeading={jumpHeading}
|
onJumpHeading={jumpHeading}
|
||||||
@@ -1185,6 +1235,11 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
onNew={newDoc}
|
onNew={newDoc}
|
||||||
onMove={(id, toIndex) => dispatch({ type: 'move', id, toIndex })}
|
onMove={(id, toIndex) => dispatch({ type: 'move', id, toIndex })}
|
||||||
|
onSaveTab={saveTabById}
|
||||||
|
onSaveAsTab={saveAsTabById}
|
||||||
|
onOpenInNewWindowTab={tauriMode ? newWindowTabById : undefined}
|
||||||
|
onExportTab={exportTabById}
|
||||||
|
onPrintTab={printTabById}
|
||||||
/>
|
/>
|
||||||
<div className="editor-area" style={{ ['--doc-zoom' as string]: zoom }}>
|
<div className="editor-area" style={{ ['--doc-zoom' as string]: zoom }}>
|
||||||
{!tab && (
|
{!tab && (
|
||||||
@@ -1252,6 +1307,25 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
{tab && (
|
||||||
|
<button
|
||||||
|
className="source-toggle"
|
||||||
|
onClick={toggleSourceMode}
|
||||||
|
title={sourceMode ? '切回即时渲染模式' : '切换到源码模式'}
|
||||||
|
>
|
||||||
|
{sourceMode ? '即时渲染' : '源码模式'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!ui.sidebar && (
|
||||||
|
<button
|
||||||
|
className="sidebar-expand"
|
||||||
|
onClick={() => setUi((u) => ({ ...u, sidebar: true }))}
|
||||||
|
title="显示侧边栏 (Ctrl+Shift+L)"
|
||||||
|
aria-label="显示侧边栏"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M4 2 L8 6 L4 10" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{findUi.open && tab && (
|
{findUi.open && tab && (
|
||||||
<FindBar
|
<FindBar
|
||||||
query={findUi.query}
|
query={findUi.query}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { memo, useCallback, useEffect, useState } from 'react'
|
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useDismiss } from '../hooks/useDismiss'
|
||||||
import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform'
|
import { listDir, workspaceSearch, type DirEntry, type WorkspaceSearchFile } from '../services/platform'
|
||||||
import type { OutlineItem } from '../utils/text'
|
import type { OutlineItem } from '../utils/text'
|
||||||
import { baseName } from '../utils/text'
|
import { baseName } from '../utils/text'
|
||||||
@@ -15,6 +16,13 @@ interface Props {
|
|||||||
onCreateFolderIn?: (dir: string) => void
|
onCreateFolderIn?: (dir: string) => void
|
||||||
onRenameEntry?: (entry: DirEntry) => void
|
onRenameEntry?: (entry: DirEntry) => void
|
||||||
onDeleteEntry?: (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
|
refreshSignal?: number
|
||||||
outline: OutlineItem[]
|
outline: OutlineItem[]
|
||||||
onJumpHeading: (item: OutlineItem) => void
|
onJumpHeading: (item: OutlineItem) => void
|
||||||
@@ -28,6 +36,11 @@ function Sidebar(p: Props) {
|
|||||||
<button className={p.mode === 'files' ? 'active' : ''} onClick={() => p.onModeChange('files')}>文件</button>
|
<button className={p.mode === 'files' ? 'active' : ''} onClick={() => p.onModeChange('files')}>文件</button>
|
||||||
<button className={p.mode === 'outline' ? 'active' : ''} onClick={() => p.onModeChange('outline')}>大纲</button>
|
<button className={p.mode === 'outline' ? 'active' : ''} onClick={() => p.onModeChange('outline')}>大纲</button>
|
||||||
<button className={p.mode === 'search' ? 'active' : ''} onClick={() => p.onModeChange('search')}>搜索</button>
|
<button className={p.mode === 'search' ? 'active' : ''} onClick={() => p.onModeChange('search')}>搜索</button>
|
||||||
|
{p.onCollapseSidebar && (
|
||||||
|
<button className="sidebar-collapse" title="收起侧边栏 (Ctrl+Shift+L)" aria-label="收起侧边栏" onClick={p.onCollapseSidebar}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M8 2 L4 6 L8 10" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{p.mode === 'search' ? (
|
{p.mode === 'search' ? (
|
||||||
<WorkspaceSearch root={p.workspaceRoot} tauriMode={p.tauriMode} onOpen={p.onOpenSearchResult} />
|
<WorkspaceSearch root={p.workspaceRoot} tauriMode={p.tauriMode} onOpen={p.onOpenSearchResult} />
|
||||||
@@ -49,6 +62,10 @@ function Sidebar(p: Props) {
|
|||||||
</div>
|
</div>
|
||||||
) : p.workspaceRoot ? (
|
) : p.workspaceRoot ? (
|
||||||
<FileTree
|
<FileTree
|
||||||
|
onOpenInNewWindowEntry={p.onOpenInNewWindowEntry}
|
||||||
|
onSaveAsEntry={p.onSaveAsEntry}
|
||||||
|
onExportEntry={p.onExportEntry}
|
||||||
|
onPrintEntry={p.onPrintEntry}
|
||||||
root={p.workspaceRoot}
|
root={p.workspaceRoot}
|
||||||
activePath={p.activePath}
|
activePath={p.activePath}
|
||||||
onOpenFile={p.onOpenFile}
|
onOpenFile={p.onOpenFile}
|
||||||
@@ -70,7 +87,7 @@ function Sidebar(p: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolderIn, onRenameEntry, onDeleteEntry, refreshSignal }: {
|
function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolderIn, onRenameEntry, onDeleteEntry, refreshSignal, onOpenInNewWindowEntry, onSaveAsEntry, onExportEntry, onPrintEntry }: {
|
||||||
root: string
|
root: string
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
onOpenFile: (path: string) => void
|
onOpenFile: (path: string) => void
|
||||||
@@ -79,18 +96,17 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
|
|||||||
onRenameEntry?: (entry: DirEntry) => void
|
onRenameEntry?: (entry: DirEntry) => void
|
||||||
onDeleteEntry?: (entry: DirEntry) => void
|
onDeleteEntry?: (entry: DirEntry) => void
|
||||||
refreshSignal: number
|
refreshSignal: number
|
||||||
|
onOpenInNewWindowEntry?: (entry: DirEntry) => void
|
||||||
|
onSaveAsEntry?: (entry: DirEntry) => void
|
||||||
|
onExportEntry?: (entry: DirEntry) => void
|
||||||
|
onPrintEntry?: (entry: DirEntry) => void
|
||||||
}) {
|
}) {
|
||||||
const [refreshKey, setRefreshKey] = useState(0)
|
const [refreshKey, setRefreshKey] = useState(0)
|
||||||
const [menu, setMenu] = useState<{ x: number; y: number; entry: DirEntry } | null>(null)
|
const [menu, setMenu] = useState<{ x: number; y: number; entry: DirEntry } | null>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const closeMenu = useCallback(() => setMenu(null), [])
|
||||||
|
useDismiss(menuRef, menu !== null, closeMenu)
|
||||||
useEffect(() => { if (refreshSignal > 0) setRefreshKey((k) => k + 1) }, [refreshSignal])
|
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 (
|
return (
|
||||||
<div className="filetree">
|
<div className="filetree">
|
||||||
<div className="filetree-head">
|
<div className="filetree-head">
|
||||||
@@ -104,8 +120,13 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
|
|||||||
onEntryMenu={(entry, x, y) => setMenu({ entry, x, y })}
|
onEntryMenu={(entry, x, y) => setMenu({ entry, x, y })}
|
||||||
/>
|
/>
|
||||||
{menu && (
|
{menu && (
|
||||||
<div className="tree-context-menu" style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}>
|
<div className="tree-context-menu" ref={menuRef} style={{ left: menu.x, top: menu.y }} onClick={(e) => e.stopPropagation()}>
|
||||||
{!menu.entry.isDir && <button onClick={() => { onOpenFile(menu.entry.path); setMenu(null) }}>打开</button>}
|
{!menu.entry.isDir && <button onClick={() => { onOpenFile(menu.entry.path); setMenu(null) }}>打开</button>}
|
||||||
|
{!menu.entry.isDir && onOpenInNewWindowEntry && <button onClick={() => { onOpenInNewWindowEntry(menu.entry); setMenu(null) }}>在新窗口打开</button>}
|
||||||
|
{!menu.entry.isDir && onSaveAsEntry && <button onClick={() => { onSaveAsEntry(menu.entry); setMenu(null) }}>另存为…</button>}
|
||||||
|
{!menu.entry.isDir && onExportEntry && <button onClick={() => { onExportEntry(menu.entry); setMenu(null) }}>导出…</button>}
|
||||||
|
{!menu.entry.isDir && onPrintEntry && <button onClick={() => { onPrintEntry(menu.entry); setMenu(null) }}>打印 / 导出 PDF…</button>}
|
||||||
|
{!menu.entry.isDir && (onRenameEntry || onDeleteEntry) && <div className="tree-menu-divider" />}
|
||||||
{menu.entry.isDir && onCreateFileIn && <button onClick={() => { onCreateFileIn(menu.entry.path); setMenu(null) }}>在此新建文档…</button>}
|
{menu.entry.isDir && onCreateFileIn && <button onClick={() => { onCreateFileIn(menu.entry.path); setMenu(null) }}>在此新建文档…</button>}
|
||||||
{menu.entry.isDir && onCreateFolderIn && <button onClick={() => { onCreateFolderIn(menu.entry.path); setMenu(null) }}>在此新建文件夹…</button>}
|
{menu.entry.isDir && onCreateFolderIn && <button onClick={() => { onCreateFolderIn(menu.entry.path); setMenu(null) }}>在此新建文件夹…</button>}
|
||||||
{onRenameEntry && <button onClick={() => { onRenameEntry(menu.entry); setMenu(null) }}>重命名…</button>}
|
{onRenameEntry && <button onClick={() => { onRenameEntry(menu.entry); setMenu(null) }}>重命名…</button>}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import type { DocTab } from '../stores/tabs'
|
import type { DocTab } from '../stores/tabs'
|
||||||
import { isDirty } from '../stores/tabs'
|
import { isDirty } from '../stores/tabs'
|
||||||
|
import { useDismiss } from '../hooks/useDismiss'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tabs: DocTab[]
|
tabs: DocTab[]
|
||||||
@@ -11,16 +12,32 @@ interface Props {
|
|||||||
onCloseRight: (id: string) => void
|
onCloseRight: (id: string) => void
|
||||||
onNew: () => void
|
onNew: () => void
|
||||||
onMove: (id: string, toIndex: number) => 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 [menuFor, setMenuFor] = useState<{ id: string; x: number; y: number } | null>(null)
|
||||||
const [dragId, setDragId] = useState<string | null>(null)
|
const [dragId, setDragId] = useState<string | null>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const closeMenu = useCallback(() => setMenuFor(null), [])
|
||||||
|
useDismiss(menuRef, menuFor !== null, closeMenu)
|
||||||
|
|
||||||
if (tabs.length === 0) return null
|
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 (
|
return (
|
||||||
<div className="tabbar" onClick={() => setMenuFor(null)}>
|
<div className="tabbar">
|
||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
<div
|
<div
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
@@ -52,11 +69,17 @@ export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOth
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button className="tab-new" aria-label="新建文档" onClick={onNew}>+</button>
|
<button className="tab-new" aria-label="新建文档" onClick={onNew}>+</button>
|
||||||
{menuFor && (
|
{menuFor && menuTab && (
|
||||||
<div className="context-menu" style={{ left: menuFor.x, top: menuFor.y }}>
|
<div className="context-menu" ref={menuRef} style={{ left: menuFor.x, top: menuFor.y }}>
|
||||||
<button onClick={() => onClose(menuFor.id)}>关闭</button>
|
<button disabled={!isDirty(menuTab)} onClick={() => act(onSaveTab)}>保存</button>
|
||||||
<button onClick={() => onCloseOthers(menuFor.id)}>关闭其他标签页</button>
|
<button onClick={() => act(onSaveAsTab)}>另存为…</button>
|
||||||
<button onClick={() => onCloseRight(menuFor.id)}>关闭右侧标签页</button>
|
{onOpenInNewWindowTab && <button onClick={() => act(onOpenInNewWindowTab)}>在新窗口打开</button>}
|
||||||
|
<button onClick={() => act(onExportTab)}>导出…</button>
|
||||||
|
<button onClick={() => act(onPrintTab)}>打印 / 导出 PDF…</button>
|
||||||
|
<div className="menu-divider" />
|
||||||
|
<button onClick={() => act(onClose)}>关闭</button>
|
||||||
|
<button onClick={() => act(onCloseOthers)}>关闭其他标签页</button>
|
||||||
|
<button onClick={() => act(onCloseRight)}>关闭右侧标签页</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
30
src/hooks/useDismiss.ts
Normal file
30
src/hooks/useDismiss.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, type RefObject } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单「点别处不消失」的统一修复:菜单打开期间,指针按下/右键落在菜单外、
|
||||||
|
* 按 Esc、窗口失焦、或菜单外发生滚动时关闭。
|
||||||
|
* 指针监听用捕获阶段:先于 React 的 contextmenu 处理执行,
|
||||||
|
* 这样在另一个标签上右键时是「先关旧菜单、再开新菜单」,不会互相冲掉。
|
||||||
|
*/
|
||||||
|
export function useDismiss(ref: RefObject<HTMLElement | null>, 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])
|
||||||
|
}
|
||||||
@@ -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 { border: 0; background: transparent; padding: 0 12px; cursor: pointer; color: var(--text-dim); font-size: 15px; }
|
||||||
.tab-new:hover { background: var(--hover); }
|
.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 { 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 { 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); }
|
.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:打印(只打文档区,隐藏全部框架) ——— */
|
/* ——— 阶段 5:打印(只打文档区,隐藏全部框架) ——— */
|
||||||
@media print {
|
@media print {
|
||||||
.titlebar, .menubar, .tabbar, .sidebar, .statusbar, .modal-backdrop, .app-notice, .context-menu,
|
.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; }
|
.app, .main, .content-col, .editor-area, .editor-scroll { display: block !important; height: auto !important; overflow: visible !important; }
|
||||||
.editor-scroll { background: #fff !important; }
|
.editor-scroll { background: #fff !important; }
|
||||||
/* 打印只出活动窗格:保活的隐藏窗格不能被上面的 display:block 通杀带出来 */
|
/* 打印只出活动窗格:保活的隐藏窗格不能被上面的 display:block 通杀带出来 */
|
||||||
|
|||||||
113
tests/context-menus.test.tsx
Normal file
113
tests/context-menus.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user