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

@@ -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}
/>
<div className="editor-area" style={{ ['--doc-zoom' as string]: zoom }}>
{!tab && (
@@ -1252,6 +1307,25 @@ export default function App() {
/>
</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 && (
<FindBar
query={findUi.query}

View File

@@ -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 type { OutlineItem } from '../utils/text'
import { baseName } from '../utils/text'
@@ -15,6 +16,13 @@ interface Props {
onCreateFolderIn?: (dir: string) => 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) {
<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 === '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>
{p.mode === 'search' ? (
<WorkspaceSearch root={p.workspaceRoot} tauriMode={p.tauriMode} onOpen={p.onOpenSearchResult} />
@@ -49,6 +62,10 @@ function Sidebar(p: Props) {
</div>
) : p.workspaceRoot ? (
<FileTree
onOpenInNewWindowEntry={p.onOpenInNewWindowEntry}
onSaveAsEntry={p.onSaveAsEntry}
onExportEntry={p.onExportEntry}
onPrintEntry={p.onPrintEntry}
root={p.workspaceRoot}
activePath={p.activePath}
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
activePath: string | null
onOpenFile: (path: string) => 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<HTMLDivElement>(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 (
<div className="filetree">
<div className="filetree-head">
@@ -104,8 +120,13 @@ function FileTree({ root, activePath, onOpenFile, onCreateFileIn, onCreateFolder
onEntryMenu={(entry, x, y) => setMenu({ entry, x, y })}
/>
{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 && 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 && onCreateFolderIn && <button onClick={() => { onCreateFolderIn(menu.entry.path); setMenu(null) }}>在此新建文件夹…</button>}
{onRenameEntry && <button onClick={() => { onRenameEntry(menu.entry); setMenu(null) }}>重命名…</button>}

View File

@@ -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<string | null>(null)
const menuRef = useRef<HTMLDivElement>(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 (
<div className="tabbar" onClick={() => setMenuFor(null)}>
<div className="tabbar">
{tabs.map((tab, index) => (
<div
key={tab.id}
@@ -52,11 +69,17 @@ export default function TabBar({ tabs, activeId, onActivate, onClose, onCloseOth
</div>
))}
<button className="tab-new" aria-label="新建文档" onClick={onNew}>+</button>
{menuFor && (
<div className="context-menu" style={{ left: menuFor.x, top: menuFor.y }}>
<button onClick={() => onClose(menuFor.id)}>关闭</button>
<button onClick={() => onCloseOthers(menuFor.id)}>关闭其他标签页</button>
<button onClick={() => onCloseRight(menuFor.id)}>关闭右侧标签页</button>
{menuFor && menuTab && (
<div className="context-menu" ref={menuRef} style={{ left: menuFor.x, top: menuFor.y }}>
<button disabled={!isDirty(menuTab)} onClick={() => act(onSaveTab)}>保存</button>
<button onClick={() => act(onSaveAsTab)}>另存为…</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>

30
src/hooks/useDismiss.ts Normal file
View 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])
}

View File

@@ -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 通杀带出来 */