feat(menus): 编辑区自绘右键菜单 + 全局屏蔽 WebView 自带菜单(docs/26,含 docs/21 勘误)

This commit is contained in:
Muse
2026-11-14 16:46:39 +08:00
parent bd6fcf3d45
commit bfacdd5fbb
7 changed files with 215 additions and 1 deletions

View File

@@ -9,6 +9,7 @@ import StatusBar from './components/StatusBar'
import EditorPane from './components/EditorPane'
import type { SourceEditorApi } from './components/SourceEditor'
import FindBar from './components/FindBar'
import EditorContextMenu, { type EditorMenuAction } from './components/EditorContextMenu'
import type { EditorPaneApi } from './components/EditorPane'
import type { FindOptions } from './editor/search'
import { comboFromEvent, resolveShortcuts } from './utils/shortcuts'
@@ -912,6 +913,54 @@ export default function App() {
return () => document.removeEventListener('keydown', onKey)
}, [tab, newDoc, openFile, saveTab, closeTab, withEditor, ui.shortcuts, openFind, closeFind])
// 屏蔽 WebView 自带右键菜单:编辑区/标题栏等处右键不再冒出浏览器菜单;
// 输入框保留系统菜单(拼写检查、粘贴等在输入框里是刚需)。
// 应用自绘菜单(标签/文件树/编辑区)走各自的 contextmenu 处理,不受影响
// (React 处理在冒泡链上先于 document 监听执行)。
useEffect(() => {
const onContextMenu = (e: MouseEvent) => {
if ((e.target as HTMLElement | null)?.closest('input, textarea')) return
e.preventDefault()
}
document.addEventListener('contextmenu', onContextMenu)
return () => document.removeEventListener('contextmenu', onContextMenu)
}, [])
// ——— 编辑区自绘右键菜单 ———
const [editorMenu, setEditorMenu] = useState<{ x: number; y: number; hasSelection: boolean } | null>(null)
const openEditorMenu = (e: React.MouseEvent) => {
const target = e.target as HTMLElement
const inEditor = sourceMode
? Boolean(target.closest('.source-cm'))
: Boolean(target.closest('.ProseMirror'))
if (!inEditor) return
e.preventDefault()
let hasSelection = false
if (sourceMode) {
hasSelection = sourceApiRef.current?.hasSelection() ?? false
} else if (tab) {
const editor = paneApisRef.current.get(tab.id)?.editor
if (editor) {
try { hasSelection = editor.action((ctx) => !ctx.get(editorViewCtx).state.selection.empty) } catch { hasSelection = false }
}
}
setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection })
}
const runEditorMenuAction = (action: EditorMenuAction) => {
switch (action) {
case 'cut': document.execCommand('cut'); break
case 'copy': document.execCommand('copy'); break
case 'paste': void pasteFromClipboard(); break
case 'selectAll': document.execCommand('selectAll'); break
case 'bold': withEditor(editorCommands.bold); break
case 'italic': withEditor(editorCommands.italic); break
case 'strikethrough': withEditor(editorCommands.strikethrough); break
case 'inlineCode': withEditor(editorCommands.inlineCode); break
case 'link': setModal('link'); break
case 'toggleSource': toggleSourceMode(); break
}
}
const pasteFromClipboard = useCallback(async () => {
if (sourceMode) {
// 源码模式下 CodeMirror 自带粘贴处理,聚焦后由浏览器原生粘贴完成
@@ -1360,7 +1409,7 @@ export default function App() {
onExportTab={exportTabById}
onPrintTab={printTabById}
/>
<div className="editor-area" style={{ ['--doc-zoom' as string]: zoom }}>
<div className="editor-area" style={{ ['--doc-zoom' as string]: zoom }} onContextMenu={openEditorMenu}>
{!tab && (
<div className="empty-state">
<h2>没有打开的文档</h2>
@@ -1533,6 +1582,16 @@ export default function App() {
/>
)}
{notice && <div className="app-notice">{notice}</div>}
{editorMenu && tab && (
<EditorContextMenu
x={editorMenu.x}
y={editorMenu.y}
hasSelection={editorMenu.hasSelection}
sourceMode={sourceMode}
onAction={runEditorMenuAction}
onClose={() => setEditorMenu(null)}
/>
)}
{recoveryOffer.length > 0 && !recoveryDismissed && (
<RecoveryModal
items={recoveryOffer}

View File

@@ -0,0 +1,59 @@
import { useRef } from 'react'
import { useDismiss } from '../hooks/useDismiss'
export type EditorMenuAction =
| 'cut' | 'copy' | 'paste' | 'selectAll'
| 'bold' | 'italic' | 'strikethrough' | 'inlineCode' | 'link'
| 'toggleSource'
interface Props {
x: number
y: number
/** 当前是否有选区(决定剪切/复制是否可用) */
hasSelection: boolean
sourceMode: boolean
onAction: (action: EditorMenuAction) => void
onClose: () => void
}
/**
* 编辑区自绘右键菜单(替代 WebView 自带菜单):编辑命令与顶部「编辑/格式」
* 菜单同一套实现。容器 mousedown 阻止默认行为,让焦点与选区留在编辑器里,
* execCommand 类命令才作用得准。
*/
export default function EditorContextMenu({ x, y, hasSelection, sourceMode, onAction, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null)
useDismiss(ref, true, onClose)
const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式)
const left = Math.max(4, Math.min(x, window.innerWidth - 196))
const estHeight = sourceMode ? 236 : 388
const top = Math.max(4, Math.min(y, window.innerHeight - estHeight - 8))
return (
<div
className="tree-context-menu editor-context-menu"
ref={ref}
style={{ left, top }}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.preventDefault()}
onContextMenu={(e) => e.preventDefault()}
>
<button disabled={!hasSelection} onClick={() => act('cut')}>剪切</button>
<button disabled={!hasSelection} onClick={() => act('copy')}>复制</button>
<button onClick={() => act('paste')}>粘贴</button>
<button onClick={() => act('selectAll')}>全选</button>
{!sourceMode && (
<>
<div className="tree-menu-divider" />
<button onClick={() => act('bold')}>加粗</button>
<button onClick={() => act('italic')}>斜体</button>
<button onClick={() => act('strikethrough')}>删除线</button>
<button onClick={() => act('inlineCode')}>行内代码</button>
<button onClick={() => act('link')}>超链接…</button>
</>
)}
<div className="tree-menu-divider" />
<button onClick={() => act('toggleSource')}>{sourceMode ? '即时渲染' : '源码模式'}</button>
</div>
)
}

View File

@@ -13,6 +13,7 @@ export interface SourceEditorApi {
jumpToLine: (line: number) => void
getAnchor: () => CursorAnchor | null
focus: () => void
hasSelection: () => boolean
findAll: (query: string, opts: FindOptions) => number
gotoMatch: (index: number) => number
replaceCurrent: (replacement: string) => boolean
@@ -154,6 +155,7 @@ export default function SourceEditor({ tabId, initialText, initialCursorOffset,
},
getAnchor: () => anchorFromMarkdown(view.state.doc.toString(), view.state.selection.main.head),
focus: () => view.focus(),
hasSelection: () => !view.state.selection.main.empty,
findAll,
gotoMatch,
replaceCurrent: (replacement) => {

View File

@@ -216,6 +216,8 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.tree-context-menu button { display: block; width: 100%; text-align: left; border: 0; background: transparent; padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; }
.tree-context-menu button:hover { background: var(--accent-weak); }
.tree-context-menu button.danger { color: #b3261e; }
.editor-context-menu button:disabled { opacity: 0.45; cursor: default; }
.editor-context-menu button:disabled:hover { background: transparent; }
/* ——— 阶段 5:文档主题(变量层,[data-doc-theme] 挂在 html 上) ——— */
:root {