Compare commits

..

2 Commits

8 changed files with 216 additions and 3 deletions

View File

@@ -1,5 +1,7 @@
# 菜单与切换五项(2026-11-14)
> **勘误(2026-11-14 晚,见 docs/26)**:本文 §1 所称「全局监听 contextmenu 屏蔽 WebView 菜单」当时并未真正落进代码(提交里只有文档与说明文字),编辑区右键一直是浏览器自带菜单。屏蔽与编辑区自绘右键菜单已在 docs/26 补齐。
## 1. 屏蔽浏览器右键菜单
App 全局监听 contextmenu 并 preventDefault:编辑区、标题栏等处不再冒出

View 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 里右键不再出现自带菜单(拼写检查/检查元素等);
- 粘贴在桌面端的剪贴板权限表现(与顶部「编辑 → 粘贴」同一路径)。

View File

@@ -426,7 +426,6 @@ pub fn create_dir(path: String) -> Result<(), String> {
fs::create_dir_all(&path).map_err(|e| format!("创建文件夹失败:{e}"))
}
/// 重命名/移动文件或文件夹;目标已存在时报错。
/// 在 Windows 资源管理器中定位路径:打开父目录并选中该文件/文件夹。
/// explorer 即使成功也常以退出码 1 结束,因此只 spawn 不等结果。
#[tauri::command]
@@ -441,7 +440,7 @@ pub fn reveal_in_explorer(path: String) -> Result<(), String> {
Ok(())
}
#[tauri::command]
/// 重命名/移动文件或文件夹;目标已存在时报错。
#[tauri::command]
pub fn rename_path(from: String, to: String) -> Result<(), String> {
if Path::new(&to).exists() {

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 {

View 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()
})
})