diff --git a/docs/31-图片编辑体验.md b/docs/31-图片编辑体验.md index 33d4f46..3ad650d 100644 --- a/docs/31-图片编辑体验.md +++ b/docs/31-图片编辑体验.md @@ -80,6 +80,16 @@ patch 到节点;包裹链接降为第二字段「点击时打开的链接( src/alt/width/linkHref)、`updateImageForElement`(按元素定位节点 改属性,右键菜单打开时记住元素与标签 id)。 +## 图片右键菜单精简(同日用户要求) + +右键命中图片时换成专用小菜单,只留与图片有关的项: +超链接… / 调整图片大小… / 上传到 S3(未上传图才有)/ 分隔线 / +剪切 / 复制 / 粘贴。加粗、斜体、删除线、行内代码这些文本格式项, +以及全选、源码模式切换,对图片没有意义,一律不出现(文本右键菜单 +不变)。配套:右键图片时先把该图片设为节点选区 +(`selectImageAtElement`),描边与手柄随之出现,菜单里的剪切/复制 +和随后的 Delete 都作用在这张图上,与常见编辑器一致。 + ## 待真机验 - WebView2 里点击图片的选中手感(本机为 Chromium 实测); diff --git a/src/App.tsx b/src/App.tsx index 0985af2..6f70efd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1113,7 +1113,13 @@ export default function App() { const img = target.closest('img') if (img && api) { 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 }) diff --git a/src/components/EditorContextMenu.tsx b/src/components/EditorContextMenu.tsx index e1a97cc..410d916 100644 --- a/src/components/EditorContextMenu.tsx +++ b/src/components/EditorContextMenu.tsx @@ -13,7 +13,7 @@ interface Props { y: number /** 当前是否有选区(决定剪切/复制是否可用) */ hasSelection: boolean - /** 右键命中的图片节点 src(非图片为 null);本地未上传图时多一项「上传到 S3」 */ + /** 右键命中的图片节点 src(非图片为 null);命中图片时换成精简的图片专用菜单 */ imageSrc: string | null sourceMode: boolean onAction: (action: EditorMenuAction) => void @@ -24,14 +24,18 @@ interface Props { * 编辑区自绘右键菜单(替代 WebView 自带菜单):编辑命令与顶部「编辑/格式」 * 菜单同一套实现。容器 mousedown 阻止默认行为,让焦点与选区留在编辑器里, * execCommand 类命令才作用得准。 + * 右键命中图片时只给图片相关项(docs/31):超链接/大小/上传 + 剪切/复制/ + * 粘贴;加粗等文本格式项、全选、源码模式对图片没有意义,不再出现。 */ export default function EditorContextMenu({ x, y, hasSelection, imageSrc, sourceMode, onAction, onClose }: Props) { const ref = useRef(null) useDismiss(ref, true, onClose) const act = (a: EditorMenuAction) => { onClose(); onAction(a) } const showUpload = Boolean(imageSrc && isUploadableImageSrc(imageSrc)) - // 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与图片项) - const { left, top } = clampMenuPosition(x, y, 188, (sourceMode ? 236 : 388) + (showUpload ? 40 : 0) + (imageSrc ? 40 : 0)) + const isImage = Boolean(imageSrc) + // 位置夹取,避免菜单溢出窗口右/下边(尺寸为估算值:宽约 190,高随模式与条目数) + const height = isImage ? (showUpload ? 292 : 252) : (sourceMode ? 236 : 388) + const { left, top } = clampMenuPosition(x, y, 188, height) return (
e.preventDefault()} onContextMenu={(e) => e.preventDefault()} > - - - - - {!sourceMode && ( + {isImage ? ( <> -
- - - - - - )} - {(showUpload || imageSrc) && ( - <> -
+ {showUpload && } - {imageSrc && } +
+ + + + + ) : ( + <> + + + + + {!sourceMode && ( + <> +
+ + + + + + + )} +
+ )} -
-
) } diff --git a/src/components/EditorPane.tsx b/src/components/EditorPane.tsx index c4ae7b1..ddf6093 100644 --- a/src/components/EditorPane.tsx +++ b/src/components/EditorPane.tsx @@ -3,7 +3,7 @@ import TableToolbar from './TableToolbar' import type { Editor } from '@milkdown/kit/core' import { editorViewCtx } from '@milkdown/kit/core' 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 { buildTextIndex, findInIndex, type FindOptions, type MatchRange } from '../editor/search' import { setFocusModeEnabled, refreshFocusDecorations } from '../editor/plugins' @@ -29,6 +29,8 @@ export interface EditorPaneApi { imageInfoForElement: (el: HTMLElement) => ImageNodeInfo | null /** 改某图片元素的节点属性(尺寸/链接等),成功返回 true */ updateImageForElement: (el: HTMLElement, patch: ImageNodePatch) => boolean + /** 选中某图片元素对应的节点(右键图片时用) */ + selectImageForElement: (el: HTMLElement) => boolean } interface Props { @@ -62,6 +64,7 @@ const EMPTY_API: EditorPaneApi = { replaceImageSrc: () => 0, imageInfoForElement: () => null, updateImageForElement: () => false, + selectImageForElement: () => false, imageSrcForElement: () => null, } @@ -255,6 +258,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal const imageSrcForElement = (el: HTMLElement): string | null => imageSrcAtElement(editor, el) const imageInfoForElement = (el: HTMLElement): ImageNodeInfo | null => imageInfoAtElement(editor, el) const updateImageForElement = (el: HTMLElement, patch: ImageNodePatch): boolean => updateImageAtElement(editor, el, patch) + const selectImageForElement = (el: HTMLElement): boolean => selectImageAtElement(editor, el) editor.create() .then(() => { // 已卸载(关标签/保活淘汰/epoch 重挂)时不再注册 API:cleanup 已 @@ -265,7 +269,7 @@ export default function EditorPane({ tabId, initialMarkdown, onMarkdown, onFinal try { editor.action((ctx) => refreshFocusDecorations(ctx.get(editorViewCtx) as never)) } 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) { setLoadProgress({ done: 1, total: chunkLoad.totalChunks }) void chunkLoad.appendRest( diff --git a/src/editor/editor.ts b/src/editor/editor.ts index b86bda4..3b23b6c 100644 --- a/src/editor/editor.ts +++ b/src/editor/editor.ts @@ -309,6 +309,23 @@ export function updateImageAtElement(editor: Editor, el: HTMLElement, patch: Ima } } +/** 选中某个 元素对应的图片节点(右键图片时先选中它,剪切/复制/删除才作用得准)。 */ +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) { editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key)) } diff --git a/tests/editor-context-menu.test.tsx b/tests/editor-context-menu.test.tsx index f665c8e..0002f73 100644 --- a/tests/editor-context-menu.test.tsx +++ b/tests/editor-context-menu.test.tsx @@ -52,6 +52,20 @@ describe('编辑区右键菜单', () => { 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 () => { const { container, root } = await renderMenu({ sourceMode: true, hasSelection: true }) expect(btn(container, '加粗')).toBeUndefined() diff --git a/tests/image-size.test.ts b/tests/image-size.test.ts index 6644874..a267ce0 100644 --- a/tests/image-size.test.ts +++ b/tests/image-size.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' 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' 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', () => { it('只认孤立 img 且必须有 src', () => { expect(parseImgTag('')).toEqual({ src: 'a.png', width: '300' })