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; }