diff --git a/docs/21-menus-and-toggles.md b/docs/21-menus-and-toggles.md
index 38dceac..3ac615b 100644
--- a/docs/21-menus-and-toggles.md
+++ b/docs/21-menus-and-toggles.md
@@ -1,5 +1,7 @@
# 菜单与切换五项(2026-11-14)
+> **勘误(2026-11-14 晚,见 docs/26)**:本文 §1 所称「全局监听 contextmenu 屏蔽 WebView 菜单」当时并未真正落进代码(提交里只有文档与说明文字),编辑区右键一直是浏览器自带菜单。屏蔽与编辑区自绘右键菜单已在 docs/26 补齐。
+
## 1. 屏蔽浏览器右键菜单
App 全局监听 contextmenu 并 preventDefault:编辑区、标题栏等处不再冒出
diff --git a/docs/26-编辑区右键菜单.md b/docs/26-编辑区右键菜单.md
new file mode 100644
index 0000000..f5fcd75
--- /dev/null
+++ b/docs/26-编辑区右键菜单.md
@@ -0,0 +1,41 @@
+# docs/26 编辑区自绘右键菜单 + WebView 自带菜单屏蔽(补账)
+
+## 先说清一笔旧账
+
+docs/21 §1 写了「App 全局监听 contextmenu 并 preventDefault、编辑区
+右键已被屏蔽」,但核查提交 2d1cd30 的实际代码后确认:**那段监听从未
+落进代码**,只有文档与提交说明写了。结果就是编辑区右键一直是 WebView
+自带菜单——用户再次报回。docs/21 已加勘误。本次把屏蔽与自绘菜单一并
+真正实现,并用无头浏览器的真实右键逐项验证(见下)。
+
+## 实现
+
+1. **全局屏蔽**(App 挂载 effect):document 监听 contextmenu 并
+ preventDefault;`input / textarea` 豁免(输入框保留系统菜单的拼写
+ 检查与粘贴)。React 的各自定义菜单(标签/文件树/编辑区)在冒泡链
+ 上先于 document 监听打开,不受影响。
+2. **编辑区自绘菜单**(新组件 EditorContextMenu):在 .editor-area 上
+ 按事件落点分发——富文本落在 .ProseMirror、源码落在 .source-cm 才
+ 弹出。条目与顶部「编辑/格式」菜单同一套实现:
+ - 剪切 / 复制(无选区禁用,选区状态在打开瞬间从编辑器状态读取,
+ 源码模式经 SourceEditorApi 新增的 hasSelection);
+ - 粘贴(走既有 pasteFromClipboard:富文本经剪贴板读文本插入,
+ 源码聚焦后原生粘贴);全选;
+ - 富文本独有:加粗 / 斜体 / 删除线 / 行内代码 / 超链接…;
+ - 末项模式切换(源码模式 ↔ 即时渲染)。
+ 菜单容器 mousedown 阻止默认行为,焦点与选区留在编辑器里,
+ execCommand 类命令作用准确;位置按窗口尺寸夹取不溢出。
+
+## 验证
+
+- 无头浏览器真实右键:菜单弹出且条目齐全;无选区时剪切/复制禁用、
+ 全选后可用;点「加粗」选中文本真的变粗;标题栏右键无自绘菜单
+ (自带菜单已被屏蔽);合成事件核查:编辑区 contextmenu 被
+ preventDefault、输入框放行。
+- 组件测试 2 例(tests/editor-context-menu.test.tsx);vitest 108+2;
+ tsc 0 错误;构建通过。
+
+## 待真机复验
+
+- WebView2 里右键不再出现自带菜单(拼写检查/检查元素等);
+- 粘贴在桌面端的剪贴板权限表现(与顶部「编辑 → 粘贴」同一路径)。
diff --git a/src/App.tsx b/src/App.tsx
index 8165c00..1688d32 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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}
/>
-
+
{!tab && (
没有打开的文档
@@ -1533,6 +1582,16 @@ export default function App() {
/>
)}
{notice &&
{notice}
}
+ {editorMenu && tab && (
+
setEditorMenu(null)}
+ />
+ )}
{recoveryOffer.length > 0 && !recoveryDismissed && (
void
+ onClose: () => void
+}
+
+/**
+ * 编辑区自绘右键菜单(替代 WebView 自带菜单):编辑命令与顶部「编辑/格式」
+ * 菜单同一套实现。容器 mousedown 阻止默认行为,让焦点与选区留在编辑器里,
+ * execCommand 类命令才作用得准。
+ */
+export default function EditorContextMenu({ x, y, hasSelection, sourceMode, onAction, onClose }: Props) {
+ const ref = useRef(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 (
+ e.stopPropagation()}
+ onMouseDown={(e) => e.preventDefault()}
+ onContextMenu={(e) => e.preventDefault()}
+ >
+
+
+
+
+ {!sourceMode && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/src/components/SourceEditor.tsx b/src/components/SourceEditor.tsx
index 3eab843..5994ff1 100644
--- a/src/components/SourceEditor.tsx
+++ b/src/components/SourceEditor.tsx
@@ -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) => {
diff --git a/src/styles.css b/src/styles.css
index 656f2a5..f170400 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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 {
diff --git a/tests/editor-context-menu.test.tsx b/tests/editor-context-menu.test.tsx
new file mode 100644
index 0000000..7441b8d
--- /dev/null
+++ b/tests/editor-context-menu.test.tsx
@@ -0,0 +1,49 @@
+// 编辑区右键菜单:条目随模式/选区变化,点击回传动作并关闭。
+import { describe, it, expect, vi } from 'vitest'
+import { createRoot } from 'react-dom/client'
+import { act } from 'react-dom/test-utils'
+import EditorContextMenu from '../src/components/EditorContextMenu'
+
+async function renderMenu(props: Record = {}) {
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ const root = createRoot(container)
+ const actions: string[] = []
+ let closed = 0
+ await act(async () => {
+ root.render(
+ actions.push(a)}
+ onClose={() => { closed += 1 }}
+ {...props}
+ />,
+ )
+ })
+ return { container, root, actions, closedCount: () => closed }
+}
+
+const btn = (c: HTMLElement, text: string) => [...c.querySelectorAll('button')].find((b) => b.textContent === text)
+
+describe('编辑区右键菜单', () => {
+ it('无选区时剪切/复制禁用,点加粗回传 bold 并关闭', async () => {
+ const { container, root, actions, closedCount } = await renderMenu()
+ expect(btn(container, '剪切')?.disabled).toBe(true)
+ expect(btn(container, '复制')?.disabled).toBe(true)
+ expect(btn(container, '粘贴')?.disabled).toBe(false)
+ await act(async () => { btn(container, '加粗')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
+ expect(actions).toEqual(['bold'])
+ expect(closedCount()).toBe(1)
+ await act(async () => { root.unmount() })
+ container.remove()
+ })
+
+ it('源码模式只出编辑命令与「即时渲染」', async () => {
+ const { container, root } = await renderMenu({ sourceMode: true, hasSelection: true })
+ expect(btn(container, '加粗')).toBeUndefined()
+ expect(btn(container, '即时渲染')).toBeTruthy()
+ expect(btn(container, '剪切')?.disabled).toBe(false)
+ await act(async () => { root.unmount() })
+ container.remove()
+ })
+})