feat(editor): 图片可拖拽手柄缩放(docs/31 补)

上一笔只有弹窗输数值,用户追问为何不能拖边缘。NodeView 改包装
结构 span.img-wrap > img + 右下角手柄:图片选中时手柄出现,拖动
实时预览(只改 CSS),松手提交 width 属性(px,夹取 20..段落宽)。
两个坑:手柄的 mouse 事件须吞掉防 PM 按点击位置捣乱;setNodeMarkup
提交后 PM 会回读 DOM 选区把 NodeSelection 塌成文本光标,提交事务
须同带 setSelection 保住选区(updateImageAtElement 同修)。
无头 Chrome 真实拖拽实测:240→+80=320→−30=290,选区全程不丢;
vitest 148+2、构建通过。
This commit is contained in:
Muse
2026-11-15 09:48:54 +08:00
parent 5f4a80fa01
commit 0c09d94501
4 changed files with 76 additions and 3 deletions

View File

@@ -13,6 +13,7 @@ import { indent } from '@milkdown/kit/plugin/indent'
import { math } from '@milkdown/plugin-math'
import { upload, uploadConfig, type UploadOptions } from '@milkdown/kit/plugin/upload'
import { createSyntaxRevealPlugin, createCodeBlockTabPlugin, createTableTabPlugin, createFocusDimPlugin, createPlainTextPastePlugin, createImageDisplayPlugin, createImageEnterPlugin, createEmptyPlaceholderPlugin, selectionInTable } from './plugins'
import { NodeSelection } from '@milkdown/kit/prose/state'
import { createListItemViewPlugin, createCodeBlockViewPlugin } from './nodeViews'
import { extendImageSchema } from './imageSchema'
@@ -296,6 +297,10 @@ export function updateImageAtElement(editor: Editor, el: HTMLElement, patch: Ima
if (patch.linkHref) tr.addMark(pos, pos + 1, linkType.create({ href: patch.linkHref }))
}
}
// 同 plugins 的拖拽提交:换节点属性后保住节点选区,别被回读塌掉
if (view.state.selection instanceof NodeSelection && view.state.selection.from === pos) {
tr.setSelection(NodeSelection.create(tr.doc, pos))
}
if (tr.docChanged) view.dispatch(tr)
return true
})

View File

@@ -463,10 +463,20 @@ export function createImageDisplayPlugin(resolve: (src: string) => Promise<strin
return $prose(() => new Plugin({
props: {
nodeViews: {
image: (node) => {
image: (node, view, getPos) => {
// 包装结构:span.img-wrap(PM 的节点视图 dom)> img + 拖拽手柄。
// 手柄在图片被选中时显示(.ProseMirror-selectednode 落在包装上),
// 拖动时实时改 img 的 CSS 宽度预览,松手才把宽度提交进节点属性。
const wrap = document.createElement('span')
wrap.className = 'img-wrap'
const img = document.createElement('img')
img.alt = (node.attrs.alt as string) ?? ''
img.draggable = false
if (node.attrs.title) img.title = node.attrs.title as string
const handle = document.createElement('span')
handle.className = 'img-resize-handle'
wrap.append(img, handle)
let currentNode = node
const applyWidth = (w: unknown) => {
const css = widthToCss(typeof w === 'string' ? w : null)
if (css) img.style.width = css
@@ -486,12 +496,53 @@ export function createImageDisplayPlugin(resolve: (src: string) => Promise<strin
.catch(() => undefined)
}
apply((node.attrs.src as string) ?? '')
// PM 用的是 mouse 事件:手柄上的 mousedown/mouseup/click 必须吞掉,
// 否则松手时 PM 会按点击位置重设选区,手柄随即消失、没法连续拖。
for (const type of ['mousedown', 'mouseup', 'click', 'dblclick'] as const) {
handle.addEventListener(type, (e) => { e.preventDefault(); e.stopPropagation() })
}
let drag: { startX: number; startW: number; lastW: number } | null = null
handle.addEventListener('pointerdown', (e) => {
e.preventDefault()
e.stopPropagation()
const w = img.getBoundingClientRect().width
drag = { startX: e.clientX, startW: w, lastW: w }
handle.setPointerCapture(e.pointerId)
})
handle.addEventListener('pointermove', (e) => {
if (!drag) return
const max = wrap.parentElement?.clientWidth || 10000
const w = Math.min(Math.max(20, drag.startW + (e.clientX - drag.startX)), Math.max(20, max))
drag.lastW = w
img.style.width = `${Math.round(w)}px`
})
const endDrag = () => {
if (!drag) return
const w = Math.round(drag.lastW)
drag = null
const pos = typeof getPos === 'function' ? getPos() : undefined
if (pos == null || String(w) === String(currentNode.attrs.width ?? '')) {
applyWidth(currentNode.attrs.width)
return
}
// setNodeMarkup 换掉节点实例后 PM 会按 DOM 选区回读、把节点选区
// 塌成文本光标(选区一丢手柄就消失,没法连续拖)——同一事务里把
// 节点选区明确设回去。
const tr = view.state.tr.setNodeMarkup(pos, undefined, { ...currentNode.attrs, width: String(w) })
tr.setSelection(NodeSelection.create(tr.doc, pos))
view.dispatch(tr)
}
handle.addEventListener('pointerup', endDrag)
handle.addEventListener('pointercancel', endDrag)
return {
dom: img,
dom: wrap,
update: (next) => {
if (next.type.name !== 'image') return false
currentNode = next
img.alt = (next.attrs.alt as string) ?? ''
applyWidth(next.attrs.width)
if (!drag) applyWidth(next.attrs.width)
const src = (next.attrs.src as string) ?? ''
if (src !== currentSrc) apply(src)
return true

View File

@@ -185,6 +185,10 @@ kbd { background: var(--code-bg); border: 1px solid var(--border); border-radius
.milkdown .ProseMirror th, .milkdown .ProseMirror td { border: 1px solid var(--border); padding: 6px 12px; }
.milkdown .ProseMirror img { max-width: 100%; }
.milkdown .ProseMirror img.ProseMirror-selectednode { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
.milkdown .ProseMirror .img-wrap { position: relative; display: inline-block; max-width: 100%; line-height: 0; }
.milkdown .ProseMirror .img-wrap.ProseMirror-selectednode { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
.milkdown .ProseMirror .img-wrap .img-resize-handle { position: absolute; right: -6px; bottom: -6px; width: 12px; height: 12px; border-radius: 3px; background: var(--accent); border: 2px solid var(--bg-editor, #fff); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); cursor: nwse-resize; opacity: 0; pointer-events: none; z-index: 3; }
.milkdown .ProseMirror .img-wrap.ProseMirror-selectednode .img-resize-handle { opacity: 1; pointer-events: auto; }
.milkdown .ProseMirror a { color: var(--accent); }
.milkdown .ProseMirror hr { border: 0; border-top: 1px solid var(--border); }
.milkdown .ProseMirror li.task-item { list-style: none; display: flex; align-items: flex-start; gap: 2px; margin-left: -1.1em; }