feat(editor): 图片右键换精简专用菜单(docs/31)

用户要求去掉多余菜单。右键命中图片时只出:超链接…/调整图片
大小…/上传到 S3(未上传图才有)/剪切/复制/粘贴;加粗、斜体、
删除线、行内代码等文本格式项与全选、源码模式对图片没意义,
不再出现(文本右键菜单不变)。配套:右键图片先把它设为节点
选区,描边/手柄随之出现,剪切/复制/Delete 都作用在这张图上。
vitest 153+2、构建通过。
This commit is contained in:
Muse
2026-11-15 13:49:19 +08:00
parent 1c0dd00088
commit 492051cef0
7 changed files with 99 additions and 25 deletions

View File

@@ -80,6 +80,16 @@ patch 到节点;包裹链接降为第二字段「点击时打开的链接(
src/alt/width/linkHref)、`updateImageForElement`(按元素定位节点 src/alt/width/linkHref)、`updateImageForElement`(按元素定位节点
改属性,右键菜单打开时记住元素与标签 id)。 改属性,右键菜单打开时记住元素与标签 id)。
## 图片右键菜单精简(同日用户要求)
右键命中图片时换成专用小菜单,只留与图片有关的项:
超链接… / 调整图片大小… / 上传到 S3(未上传图才有)/ 分隔线 /
剪切 / 复制 / 粘贴。加粗、斜体、删除线、行内代码这些文本格式项,
以及全选、源码模式切换,对图片没有意义,一律不出现(文本右键菜单
不变)。配套:右键图片时先把该图片设为节点选区
(`selectImageAtElement`),描边与手柄随之出现,菜单里的剪切/复制
和随后的 Delete 都作用在这张图上,与常见编辑器一致。
## 待真机验 ## 待真机验
- WebView2 里点击图片的选中手感(本机为 Chromium 实测); - WebView2 里点击图片的选中手感(本机为 Chromium 实测);

View File

@@ -1113,7 +1113,13 @@ export default function App() {
const img = target.closest('img') const img = target.closest('img')
if (img && api) { if (img && api) {
imageSrc = api.imageSrcForElement(img) imageSrc = api.imageSrcForElement(img)
if (imageSrc) { imageEl = img; imageTabId = tab.id } if (imageSrc) {
imageEl = img
imageTabId = tab.id
// 右键图片先把它选中:描边/手柄随之出现,菜单里的剪切/复制与
// 之后的 Delete 都作用在这张图上(与常见编辑器行为一致)
if (api.selectImageForElement(img)) hasSelection = true
}
} }
} }
setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection, imageSrc, imageEl, tabId: imageTabId }) setEditorMenu({ x: e.clientX, y: e.clientY, hasSelection, imageSrc, imageEl, tabId: imageTabId })

View File

@@ -13,7 +13,7 @@ interface Props {
y: number y: number
/** 当前是否有选区(决定剪切/复制是否可用) */ /** 当前是否有选区(决定剪切/复制是否可用) */
hasSelection: boolean hasSelection: boolean
/** 右键命中的图片节点 src(非图片为 null);本地未上传图时多一项「上传到 S3」 */ /** 右键命中的图片节点 src(非图片为 null);命中图片时换成精简的图片专用菜单 */
imageSrc: string | null imageSrc: string | null
sourceMode: boolean sourceMode: boolean
onAction: (action: EditorMenuAction) => void onAction: (action: EditorMenuAction) => void
@@ -24,14 +24,18 @@ interface Props {
* 编辑区自绘右键菜单(替代 WebView 自带菜单):编辑命令与顶部「编辑/格式」 * 编辑区自绘右键菜单(替代 WebView 自带菜单):编辑命令与顶部「编辑/格式」
* 菜单同一套实现。容器 mousedown 阻止默认行为,让焦点与选区留在编辑器里, * 菜单同一套实现。容器 mousedown 阻止默认行为,让焦点与选区留在编辑器里,
* execCommand 类命令才作用得准。 * execCommand 类命令才作用得准。
* 右键命中图片时只给图片相关项(docs/31):超链接/大小/上传 + 剪切/复制/
* 粘贴;加粗等文本格式项、全选、源码模式对图片没有意义,不再出现。
*/ */
export default function EditorContextMenu({ x, y, hasSelection, imageSrc, sourceMode, onAction, onClose }: Props) { export default function EditorContextMenu({ x, y, hasSelection, imageSrc, sourceMode, onAction, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
useDismiss(ref, true, onClose) useDismiss(ref, true, onClose)
const act = (a: EditorMenuAction) => { onClose(); onAction(a) } const act = (a: EditorMenuAction) => { onClose(); onAction(a) }
const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc)) const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc))
// 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项) const isImage = Boolean(imageSrc)
const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0) + (imageSrc ? 40 : 0)) // 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与条目数)
const height = isImage ? (showUpload ? 292 : 252) : (sourceMode ? 236 : 388)
const { left, top } = clampMenuPosition(x, y, 188, height)
return ( return (
<div <div
className="tree-context-menu editor-context-menu" className="tree-context-menu editor-context-menu"
@@ -41,6 +45,18 @@ export default function EditorContextMenu({ x, y, hasSelection, imageSrc, source
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{isImage ? (
<>
<button onClick={() => act('link')}>超链接…</button>
<button onClick={() => act('imageSize')}>调整图片大小…</button>
{showUpload && <button onClick={() => act('uploadS3')}>上传到 S3</button>}
<div className="tree-menu-divider" />
<button disabled={!hasSelection} onClick={() => act('cut')}>剪切</button>
<button disabled={!hasSelection} onClick={() => act('copy')}>复制</button>
<button onClick={() => act('paste')}>粘贴</button>
</>
) : (
<>
<button disabled={!hasSelection} onClick={() => act('cut')}>剪切</button> <button disabled={!hasSelection} onClick={() => act('cut')}>剪切</button>
<button disabled={!hasSelection} onClick={() => act('copy')}>复制</button> <button disabled={!hasSelection} onClick={() => act('copy')}>复制</button>
<button onClick={() => act('paste')}>粘贴</button> <button onClick={() => act('paste')}>粘贴</button>
@@ -55,15 +71,10 @@ export default function EditorContextMenu({ x, y, hasSelection, imageSrc, source
<button onClick={() => act('link')}>超链接…</button> <button onClick={() => act('link')}>超链接…</button>
</> </>
)} )}
{(showUpload || imageSrc) && (
<>
<div className="tree-menu-divider" />
{showUpload && <button onClick={() => act('uploadS3')}>上传到 S3</button>}
{imageSrc && <button onClick={() => act('imageSize')}>调整图片大小…</button>}
</>
)}
<div className="tree-menu-divider" /> <div className="tree-menu-divider" />
<button onClick={() => act('toggleSource')}>{sourceMode ? '即时渲染' : '源码模式'}</button> <button onClick={() => act('toggleSource')}>{sourceMode ? '即时渲染' : '源码模式'}</button>
</>
)}
</div> </div>
) )
} }

View File

@@ -3,7 +3,7 @@ import TableToolbar from './TableToolbar'
import type { Editor } from '@milkdown/kit/core' import type { Editor } from '@milkdown/kit/core'
import { editorViewCtx } from '@milkdown/kit/core' import { editorViewCtx } from '@milkdown/kit/core'
import { TextSelection } from '@milkdown/kit/prose/state' import { TextSelection } from '@milkdown/kit/prose/state'
import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc as replaceImageSrcInEditor, imageSrcAtElement, imageInfoAtElement, updateImageAtElement, type CursorInfo, type ImageNodeInfo, type ImageNodePatch } from '../editor/editor' import { createMarkdownEditor, getMarkdown, collectImageSrcs, replaceImageSrc as replaceImageSrcInEditor, imageSrcAtElement, imageInfoAtElement, updateImageAtElement, selectImageAtElement, type CursorInfo, type ImageNodeInfo, type ImageNodePatch } from '../editor/editor'
import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync' import { findBlockIndex, type CursorAnchor } from '../editor/cursorSync'
import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search' import { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search'
import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins' import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins'
@@ -29,6 +29,8 @@ export interface EditorPaneApi {
imageInfoForElement: (el: HTMLElement) => ImageNodeInfo | null imageInfoForElement: (el: HTMLElement) => ImageNodeInfo | null
/** 改某图片元素的节点属性(尺寸/链接等),成功返回 true */ /** 改某图片元素的节点属性(尺寸/链接等),成功返回 true */
updateImageForElement: (el: HTMLElement, patch: ImageNodePatch) => boolean updateImageForElement: (el: HTMLElement, patch: ImageNodePatch) => boolean
/** 选中某图片元素对应的节点(右键图片时用) */
selectImageForElement: (el: HTMLElement) => boolean
} }
interface Props { interface Props {
@@ -62,6 +64,7 @@ const EMPTY_API: EditorPaneApi = {
replaceImageSrc: () => 0, replaceImageSrc: () => 0,
imageInfoForElement: () => null, imageInfoForElement: () => null,
updateImageForElement: () => false, updateImageForElement: () => false,
selectImageForElement: () => false,
imageSrcForElement: () => null, imageSrcForElement: () => null,
} }
@@ -255,6 +258,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
const imageSrcForElement = (el: HTMLElement): string | null => imageSrcAtElement(editor, el) const imageSrcForElement = (el: HTMLElement): string | null => imageSrcAtElement(editor, el)
const imageInfoForElement = (el: HTMLElement): ImageNodeInfo | null => imageInfoAtElement(editor, el) const imageInfoForElement = (el: HTMLElement): ImageNodeInfo | null => imageInfoAtElement(editor, el)
const updateImageForElement = (el: HTMLElement, patch: ImageNodePatch): boolean => updateImageAtElement(editor, el, patch) const updateImageForElement = (el: HTMLElement, patch: ImageNodePatch): boolean => updateImageAtElement(editor, el, patch)
const selectImageForElement = (el: HTMLElement): boolean => selectImageAtElement(editor, el)
editor.create() editor.create()
.then(() => { .then(() => {
// 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已 // 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已
@@ -265,7 +269,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal
try { try {
editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never)) editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never))
} catch { /* 忽略 */ } } catch { /* 忽略 */ }
callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind, listImages, replaceImageSrc, imageSrcForElement, imageInfoForElement, updateImageForElement }) callbacksRef.current.onReady({ editor, jumpToHeading, getAnchor, setCursorByAnchor, findAll, gotoMatch, replaceCurrent, replaceAll, clearFind, listImages, replaceImageSrc, imageSrcForElement, imageInfoForElement, updateImageForElement, selectImageForElement })
if (chunkLoad) { if (chunkLoad) {
setLoadProgress({ done: 1, total: chunkLoad.totalChunks }) setLoadProgress({ done: 1, total: chunkLoad.totalChunks })
void chunkLoad.appendRest( void chunkLoad.appendRest(

View File

@@ -309,6 +309,23 @@ export function updateImageAtElement(editor: Editor, el: HTMLElement, patch: Ima
} }
} }
/** 选中某个 <img> 元素对应的图片节点(右键图片时先选中它,剪切/复制/删除才作用得准)。 */
export function selectImageAtElement(editor: Editor, el: HTMLElement): boolean {
try {
return editor.action((ctx) => {
const view = ctx.get(editorViewCtx)
const pos = imagePosAtElement(view, el)
if (pos < 0) return false
const sel = view.state.selection
if (sel instanceof NodeSelection && sel.from === pos) return true
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, pos)))
return true
})
} catch {
return false
}
}
export function callUndo(editor: Editor) { export function callUndo(editor: Editor) {
editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key)) editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key))
} }

View File

@@ -52,6 +52,20 @@ describe('编辑区右键菜单', () => {
remote.container.remove() remote.container.remove()
}) })
it('命中图片时是精简专用菜单:无格式项/全选/源码模式,有超链接与调整大小', async () => {
const { container, root, actions } = await renderMenu({ imageSrc: 'https://cdn.example.com/a.png', hasSelection: true })
for (const t of ['加粗', '斜体', '删除线', '行内代码', '全选', '源码模式', '上传到 S3']) {
expect(btn(container, t)).toBeUndefined()
}
for (const t of ['超链接…', '调整图片大小…', '剪切', '复制', '粘贴']) {
expect(btn(container, t)).toBeTruthy()
}
await act(async () => { btn(container, '调整图片大小…')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
expect(actions).toEqual(['imageSize'])
await act(async () => { root.unmount() })
container.remove()
})
it('源码模式只出编辑命令与「即时渲染」', async () => { it('源码模式只出编辑命令与「即时渲染」', async () => {
const { container, root } = await renderMenu({ sourceMode: true, hasSelection: true }) const { container, root } = await renderMenu({ sourceMode: true, hasSelection: true })
expect(btn(container, '加粗')).toBeUndefined() expect(btn(container, '加粗')).toBeUndefined()

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { editorViewCtx } from '@milkdown/kit/core' import { editorViewCtx } from '@milkdown/kit/core'
import { createMarkdownEditor, getMarkdown, imageInfoAtElement, updateImageAtElement } from '../src/editor/editor' import { createMarkdownEditor, getMarkdown, imageInfoAtElement, updateImageAtElement, selectImageAtElement } from '../src/editor/editor'
import { NodeSelection } from '@milkdown/kit/prose/state'
import { parseImgTag, widthToCss } from '../src/editor/imageSchema' import { parseImgTag, widthToCss } from '../src/editor/imageSchema'
async function makeEditor(md: string) { async function makeEditor(md: string) {
@@ -69,6 +70,17 @@ describe('图片尺寸往返(docs/31)', () => {
}) })
}) })
describe('右键选中图片(docs/31)', () => {
it('selectImageAtElement 把图片设为节点选区', async () => {
const { root, editor } = await makeEditor('前文 ![图](a.png) 后文\n')
const img = root.querySelector('img')!
expect(selectImageAtElement(editor, img)).toBe(true)
const sel = editor.action((ctx) => ctx.get(editorViewCtx).state.selection)
expect(sel instanceof NodeSelection).toBe(true)
await editor.destroy(); root.remove()
})
})
describe('parseImgTag / widthToCss', () => { describe('parseImgTag / widthToCss', () => {
it('只认孤立 img 且必须有 src', () => { it('只认孤立 img 且必须有 src', () => {
expect(parseImgTag('<img src="a.png" width="300">')).toEqual({ src: 'a.png', width: '300' }) expect(parseImgTag('<img src="a.png" width="300">')).toEqual({ src: 'a.png', width: '300' })