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:
@@ -48,6 +48,19 @@ Markdown 没有尺寸语法,通行写法是 HTML。方案(imageSchema.ts)
|
||||
往返实测:`<img src="b.png" alt="乙" width="300">` 解析/序列化逐字
|
||||
一致;普通图片、非 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://`,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user