feat(menus): 编辑区自绘右键菜单 + 全局屏蔽 WebView 自带菜单(docs/26,含 docs/21 勘误)
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# 菜单与切换五项(2026-11-14)
|
# 菜单与切换五项(2026-11-14)
|
||||||
|
|
||||||
|
> **勘误(2026-11-14 晚,见 docs/26)**:本文 §1 所称「全局监听 contextmenu 屏蔽 WebView 菜单」当时并未真正落进代码(提交里只有文档与说明文字),编辑区右键一直是浏览器自带菜单。屏蔽与编辑区自绘右键菜单已在 docs/26 补齐。
|
||||||
|
|
||||||
## 1. 屏蔽浏览器右键菜单
|
## 1. 屏蔽浏览器右键菜单
|
||||||
|
|
||||||
App 全局监听 contextmenu 并 preventDefault:编辑区、标题栏等处不再冒出
|
App 全局监听 contextmenu 并 preventDefault:编辑区、标题栏等处不再冒出
|
||||||
|
|||||||
41
docs/26-编辑区右键菜单.md
Normal file
41
docs/26-编辑区右键菜单.md
Normal file
@@ -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 里右键不再出现自带菜单(拼写检查/检查元素等);
|
||||||
|
- 粘贴在桌面端的剪贴板权限表现(与顶部「编辑 → 粘贴」同一路径)。
|
||||||
61
src/App.tsx
61
src/App.tsx
@@ -9,6 +9,7 @@ import StatusBar from './components/StatusBar'
|
|||||||
import EditorPane from './components/EditorPane'
|
import EditorPane from './components/EditorPane'
|
||||||
import type { SourceEditorApi } from './components/SourceEditor'
|
import type { SourceEditorApi } from './components/SourceEditor'
|
||||||
import FindBar from './components/FindBar'
|
import FindBar from './components/FindBar'
|
||||||
|
import EditorContextMenu, { type EditorMenuAction } from './components/EditorContextMenu'
|
||||||
import type { EditorPaneApi } from './components/EditorPane'
|
import type { EditorPaneApi } from './components/EditorPane'
|
||||||
import type { FindOptions } from './editor/search'
|
import type { FindOptions } from './editor/search'
|
||||||
import { comboFromEvent, resolveShortcuts } from './utils/shortcuts'
|
import { comboFromEvent, resolveShortcuts } from './utils/shortcuts'
|
||||||
@@ -912,6 +913,54 @@ export default function App() {
|
|||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => document.removeEventListener('keydown', onKey)
|
||||||
}, [tab, newDoc, openFile, saveTab, closeTab, withEditor, ui.shortcuts, openFind, closeFind])
|
}, [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 () => {
|
const pasteFromClipboard = useCallback(async () => {
|
||||||
if (sourceMode) {
|
if (sourceMode) {
|
||||||
// 源码模式下 CodeMirror 自带粘贴处理,聚焦后由浏览器原生粘贴完成
|
// 源码模式下 CodeMirror 自带粘贴处理,聚焦后由浏览器原生粘贴完成
|
||||||
@@ -1360,7 +1409,7 @@ export default function App() {
|
|||||||
onExportTab={exportTabById}
|
onExportTab={exportTabById}
|
||||||
onPrintTab={printTabById}
|
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 && (
|
{!tab && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h2>没有打开的文档</h2>
|
<h2>没有打开的文档</h2>
|
||||||
@@ -1533,6 +1582,16 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{notice && <div className="app-notice">{notice}</div>}
|
{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 && (
|
{recoveryOffer.length > 0 && !recoveryDismissed && (
|
||||||
<RecoveryModal
|
<RecoveryModal
|
||||||
items={recoveryOffer}
|
items={recoveryOffer}
|
||||||
|
|||||||
59
src/components/EditorContextMenu.tsx
Normal file
59
src/components/EditorContextMenu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export interface SourceEditorApi {
|
|||||||
jumpToLine: (line: number) => void
|
jumpToLine: (line: number) => void
|
||||||
getAnchor: () => CursorAnchor | null
|
getAnchor: () => CursorAnchor | null
|
||||||
focus: () => void
|
focus: () => void
|
||||||
|
hasSelection: () => boolean
|
||||||
findAll: (query: string, opts: FindOptions) => number
|
findAll: (query: string, opts: FindOptions) => number
|
||||||
gotoMatch: (index: number) => number
|
gotoMatch: (index: number) => number
|
||||||
replaceCurrent: (replacement: string) => boolean
|
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),
|
getAnchor: () => anchorFromMarkdown(view.state.doc.toString(), view.state.selection.main.head),
|
||||||
focus: () => view.focus(),
|
focus: () => view.focus(),
|
||||||
|
hasSelection: () => !view.state.selection.main.empty,
|
||||||
findAll,
|
findAll,
|
||||||
gotoMatch,
|
gotoMatch,
|
||||||
replaceCurrent: (replacement) => {
|
replaceCurrent: (replacement) => {
|
||||||
|
|||||||
@@ -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 { 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:hover { background: var(--accent-weak); }
|
||||||
.tree-context-menu button.danger { color: #b3261e; }
|
.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 上) ——— */
|
/* ——— 阶段 5:文档主题(变量层,[data-doc-theme] 挂在 html 上) ——— */
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
49
tests/editor-context-menu.test.tsx
Normal file
49
tests/editor-context-menu.test.tsx
Normal file
@@ -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<string, unknown> = {}) {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
const root = createRoot(container)
|
||||||
|
const actions: string[] = []
|
||||||
|
let closed = 0
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<EditorContextMenu
|
||||||
|
x={10} y={10} hasSelection={false} sourceMode={false}
|
||||||
|
onAction={(a) => 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user