feat(editor): 图片右键换精简专用菜单(docs/31)
用户要求去掉多余菜单。右键命中图片时只出:超链接…/调整图片 大小…/上传到 S3(未上传图才有)/剪切/复制/粘贴;加粗、斜体、 删除线、行内代码等文本格式项与全选、源码模式对图片没意义, 不再出现(文本右键菜单不变)。配套:右键图片先把它设为节点 选区,描边/手柄随之出现,剪切/复制/Delete 都作用在这张图上。 vitest 153+2、构建通过。
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
className="tree-context-menu editor-context-menu"
|
||||
@@ -41,29 +45,36 @@ export default function EditorContextMenu({ x, y, hasSelection, imageSrc, source
|
||||
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 && (
|
||||
{isImage ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{(showUpload || imageSrc) && (
|
||||
<>
|
||||
<div className="tree-menu-divider" />
|
||||
<button onClick={() => act('imageSize')}>调整图片大小…</button>
|
||||
{showUpload && <button onClick={() => act('uploadS3')}>上传到 S3</button>}
|
||||
{imageSrc && <button onClick={() => act('imageSize')}>调整图片大小…</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('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 className="tree-menu-divider" />
|
||||
<button onClick={() => act('toggleSource')}>{sourceMode ? '即时渲染' : '源码模式'}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
editor.action((ctx) => ctx.get(commandsCtx).call(undoCommand.key))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user