feat(menus): 编辑区自绘右键菜单 + 全局屏蔽 WebView 自带菜单(docs/26,含 docs/21 勘误)
This commit is contained in:
61
src/App.tsx
61
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}
|
||||
/>
|
||||
<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}
|
||||
|
||||
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
|
||||
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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user