From bfacdd5fbbf327fe0c9e5ca8bfbe141248b1d052 Mon Sep 17 00:00:00 2001 From: Muse Date: Sat, 14 Nov 2026 16:46:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(menus):=20=E7=BC=96=E8=BE=91=E5=8C=BA?= =?UTF-8?q?=E8=87=AA=E7=BB=98=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=20+=20?= =?UTF-8?q?=E5=85=A8=E5=B1=80=E5=B1=8F=E8=94=BD=20WebView=20=E8=87=AA?= =?UTF-8?q?=E5=B8=A6=E8=8F=9C=E5=8D=95=EF=BC=88docs/26=EF=BC=8C=E5=90=AB?= =?UTF-8?q?=20docs/21=20=E5=8B=98=E8=AF=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/21-menus-and-toggles.md | 2 + docs/26-编辑区右键菜单.md | 41 +++++++++++++++++++ src/App.tsx | 61 +++++++++++++++++++++++++++- src/components/EditorContextMenu.tsx | 59 +++++++++++++++++++++++++++ src/components/SourceEditor.tsx | 2 + src/styles.css | 2 + tests/editor-context-menu.test.tsx | 49 ++++++++++++++++++++++ 7 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 docs/26-编辑区右键菜单.md create mode 100644 src/components/EditorContextMenu.tsx create mode 100644 tests/editor-context-menu.test.tsx 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() + }) +})