diff --git a/docs/31-图片编辑体验.md b/docs/31-图片编辑体验.md
index cbaab04..ea48fd0 100644
--- a/docs/31-图片编辑体验.md
+++ b/docs/31-图片编辑体验.md
@@ -48,6 +48,19 @@ Markdown 没有尺寸语法,通行写法是 HTML。方案(imageSchema.ts)
往返实测:`
` 解析/序列化逐字
一致;普通图片、非 img 的 HTML 均不受影响(tests/image-size.test.ts)。
+**拖拽缩放**(同日用户追问后补):NodeView 改为包装结构
+`span.img-wrap > img + span.img-resize-handle`,图片选中时右下角出现
+手柄,拖动实时预览(只改 CSS 宽度),松手才提交节点属性(px 值,
+上限为段落宽度、下限 20px)。两个坑:
+- PM 用 mouse 事件:手柄上的 mousedown/mouseup/click 要吞掉,否则
+ PM 会按点击位置捣乱;
+- `setNodeMarkup` 提交后 PM 会按 DOM 选区回读、把 NodeSelection 塌
+ 成文本光标(手柄随之消失、无法连续拖)——提交事务里必须同带
+ `setSelection(NodeSelection)` 把选区设回去;
+ `updateImageAtElement`(弹窗改尺寸/链接)同理保选区。
+无头 Chrome 真实拖拽实测:240 → 拖 +80 得 width="320"、再拖 −30 得
+"290",选区全程不丢。
+
## ② 图片超链接
原「超链接」是文本链接:对选区套 link 标记,预填只有 `https://`,
diff --git a/src/editor/editor.ts b/src/editor/editor.ts
index 02f099c..b86bda4 100644
--- a/src/editor/editor.ts
+++ b/src/editor/editor.ts
@@ -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
})
diff --git a/src/editor/plugins.ts b/src/editor/plugins.ts
index e7dc205..fdeccfd 100644
--- a/src/editor/plugins.ts
+++ b/src/editor/plugins.ts
@@ -463,10 +463,20 @@ export function createImageDisplayPlugin(resolve: (src: string) => Promise 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 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
diff --git a/src/styles.css b/src/styles.css
index 9a2269c..fb1bdd2 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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; }