资讯详情

Milkdown Block 插件实战:基于 @milkdown/plugin-block 构建可拖拽的块级操作手柄

📅 2026/9/15 16:12:13 | 华诺云谱 👁 阅读
Milkdown Block 插件实战:基于 @milkdown/plugin-block 构建可拖拽的块级操作手柄
Milkdown Block 插件实战基于 milkdown/plugin-block 构建可拖拽的块级操作手柄【免费下载链接】milkdown Plugin driven WYSIWYG markdown editor framework.项目地址: https://gitcode.com/GitHub_Trending/mi/milkdown导读milkdown/plugin-block是 Milkdown 插件体系中负责“块Block”交互的核心插件它为文档中的每一个块级节点段落、标题、列表、代码块等提供一个统一的操作手柄handle并基于该手柄实现节点选择、拖拽移动与拖放落点等能力。本文以官方 API 文档为主体骨架结合本仓库中该插件的源码实现系统讲解块视图Block View的创建、块配置的绑定、BlockProvider的完整选项、底层BlockService的事件流与拖拽原理并给出可复制的 TypeScript 配置示例帮助你为 Milkdown 编辑器快速接入或深度定制块级操作交互。一、插件是什么为每个块提供一个处理器在milkdown/plugin-block中Block 指文档结构中的块级节点Block Node。该插件会在每个块的旁边渲染一个自定义手柄通常表现为一个可拖拽的图标用户可以通过手柄选中当前块创建NodeSelection按住手柄将块拖拽到文档其他位置包括跨列表结构移动在拖拽过程中获得编辑器自动滚动等辅助行为。插件的入口聚合位于 packages/plugins/plugin-block/src/index.ts它通过$ctx与$prose组合出五个切片sliceblockSpec、blockConfig、blockService、blockServiceInstance与blockPlugin并整体导出为block。安装插件只需import { Editor } from milkdown/core import { block } from milkdown/plugin-block Editor.make() .use(block) .create()block同时暴露了block.key指向blockSpec与block.pluginKey指向 ProseMirror 插件的PluginKey方便在配置阶段读取或覆写相关切片。二、创建块视图Create Block View官方文档指出创建块视图非常简单核心工作就是实现 ProseMirror Plugin.view。因为blockPlugin本身是一个$prose切片它的spec完全由blockSpec提供而view正是PluginSpec的一个字段。2.1 基础实现import { BlockProvider } from milkdown/kit/plugin/block function createBlockPluginView(ctx) { return (view) { const content document.createElement(div) const provider new BlockProvider({ ctx, content, }) return { update: (updatedView, prevState) { provider.update(updatedView, prevState) }, destroy: () { provider.destroy() content.remove() }, } } }这段代码中BlockProvider负责把手柄 DOM 与编辑器的块服务BlockService绑定起来并处理显示 / 隐藏update回调被 ProseMirror 在每次状态更新时调用内部通过provider.update延迟到下一帧初始化并刷新手柄状态destroy负责解绑服务、移除事件监听并删除手柄 DOM防止内存泄漏。2.2 绑定块视图Bind Block View创建好的视图函数需要通过editor.config绑定到插件的blockSpec切片上。官方文档给出的完整示例为import { Editor } from milkdown/core import { block } from milkdown/plugin-block Editor.make() .config((ctx) { ctx.set(block.key, { view: blockPluginView(ctx), }) }) .use(block) .create()注意这里的block.key就是blockSpec的SliceType。查看 block-plugin.ts 可以发现blockSpec被声明为$ctxPluginSpecany, blockSpec默认值为空对象{}在 blockPlugin 的实现 中插件创建时会读取ctx.get(blockSpec.key)作为PluginSpec并与插件内置的props.handleDOMEvents合并因此你配置的view、props都能生效而拖拽相关的 DOM 事件处理则始终由插件兜底。2.3 安装与导出milkdown/plugin-block同时被 packages/kit/src/plugin/block.ts 通过export * from milkdown/plugin-block重新导出因此文档中的两条导入路径等价import { block } from milkdown/plugin-block // 直接安装 import { block } from milkdown/kit/plugin/block // 通过 kit 聚合包安装三、BlockProvider 的完整选项与 APIBlockProvider是官方文档中标注的 API 核心类其完整定义位于 block-provider.ts。下表汇总了BlockProviderOptions的全部字段选项类型说明默认值ctxCtx编辑器上下文用于读取editorViewCtx与blockServiceInstance必填contentHTMLElement手柄的 DOM 内容必填shouldShow(view, prevState?) boolean决定手柄是否应该显示未提供时由服务内部决定getOffset(deriveContext) OffsetOptions计算手柄相对于锚点块的偏移来自floating-ui/dom的OffsetOptions0getPosition(deriveContext) OmitDOMRect, toJSON自定义锚点矩形覆盖“以活动节点 DOM 矩形为锚”的默认行为活动节点的getBoundingClientRect()getPlacement(deriveContext) Placement手柄的浮层方向left/right/top/bottom等leftmiddlewareMiddleware[]追加的 Floating UI 中间件会拼接在内部flip()等中间件之后[]floatingUIOptionsPartialComputePositionConfig直接透传给 Floating UIcomputePosition的选项传入middleware或placement会覆盖内部设置{}rootHTMLElement手柄挂载的根元素view.dom.parentElement ?? document.body3.1 核心方法update(updatedView, prevState)由 ProseMirror 插件view.update触发内部通过requestAnimationFrame延迟初始化#init一次之后不再重复初始化。show(active: ActiveNode)根据活动节点构建DeriveContext使用 Floating UI 的computePosition计算手柄位置并设置dataset.show true。hide()将dataset.show置为false配合 CSS 控制显隐[data-showtrue]/[data-showfalse]。destroy()解绑BlockService、移除手柄上的事件监听并删除 DOM。3.2 派生上下文 DeriveContextshow方法在每次展示时都会构造一个DeriveContext见 block-provider.ts它包含四个字段供getOffset/getPosition/getPlacement使用export interface DeriveContext { ctx: Ctx // 编辑器上下文 active: ActiveNode // 当前活动节点 editorDom: HTMLElement // 编辑器 DOM blockDom: HTMLElement // 手柄 DOM }其中ActiveNode的定义位于 types.tsexport type ActiveNode Readonly{ $pos: ResolvedPos // 节点在文档中的解析位置 node: Node // ProseMirror 节点 el: HTMLElement // 节点对应的 DOM 元素 }3.3 定位原理Floating UIBlockProvider依赖floating-ui/dom见 package.json 的 dependencies完成手柄的浮层定位。show中会构造一个VirtualElement其getBoundingClientRect默认返回活动节点 DOM 的矩形随后调用computePosition(virtualEl, blockDom, { placement, middleware, ...floatingUIOptions })其中内部中间件固定包含flip()防止手柄溢出编辑器边界时自动翻转方向若提供了getOffset还会追加offset(...)中间件最终把计算出的x / y写入手柄的left / top样式。四、blockConfig自定义哪些节点可拖拽blockConfig是官方文档标注的另一个可配置切片它保存一个filterNodes函数见 block-config.tsexport const blockConfig $ctx{ filterNodes: FilterNodes }, blockConfig( { filterNodes: defaultNodeFilter }, blockConfig )FilterNodes签名为(pos: ResolvedPos, node: Node) boolean返回true表示该节点可以被选中 / 拖拽。仓库内置的默认过滤器会排除表格内部的节点export const defaultNodeFilter: FilterNodes (pos) { const table findParent((node) node.type.name table)(pos) if (table) return false return true }在 select-node-by-dom.ts 中selectRootNodeByDom会先用view.posAtCoords定位鼠标坐标对应的位置然后向上回溯当filterNodes返回false时递归查找父节点最终返回一个满足过滤条件的根节点。因此你可以这样覆盖默认行为——例如禁止拖拽代码块Editor.make() .config((ctx) { ctx.set(blockConfig.key, { filterNodes: (pos, node) { if (node.type.name code_block) return false return true }, }) }) .use(block) .create()注意blockConfig在block组合插件中位于blockSpec之后blockService之前见 index.ts这也意味着配置阶段可以安全地读取到前序切片。五、blockSpec定制底层 ProseMirror 插件blockSpec是一个PluginSpec切片默认值为空对象。在 blockPlugin 创建 ProseMirrorPlugin时会以{ key, ...spec, props: { ...spec.props, handleDOMEvents: {...} } }的方式合并其中内置的handleDOMEvents覆盖了以下事件drop→service.dropCallbackpointermove→service.mousemoveCallback内部经 lodashthrottle节流keydown→service.keydownCallbackdragover/dragleave/dragenter/dragend→ 对应的滚动辅助与状态清理回调由于...spec与...spec.props均放在前面你的自定义props与事件处理器可以覆盖或补充内置行为文档中的view字段正是通过blockSpec注入的。六、底层原理BlockService 的事件流与拖拽流程BlockService是官方文档标注的隐藏 APIGenerally you dont need to use this class directly但它完整解释了插件的工作方式。其核心实现位于 block-service.ts。6.1 显示 / 隐藏消息机制BlockService维护一个#notify回调通过bind(ctx, notify)与BlockProvider建立一对多实际为单对单通信消息类型为type BlockServiceMessage | { type: hide } | { type: show; active: ActiveNode }鼠标移动时节流后的#mousemoveCallback会在编辑器水平中线rect.left rect.width / 2沿event.clientY做命中测试命中节点后调用#show(active)未命中或节点不可编辑时调用#hide()。BlockProvider在#init中订阅这些消息block-provider.ts收到show即定位并展示手柄收到hide即隐藏并清空活动节点。6.2 拖拽链路一次完整拖拽的调用链如下mousedown#handleMouseDown记录活动节点矩形并调用#createSelection()——若节点支持NodeSelectionNodeSelection.isSelectable为真则派发setSelection事务并聚焦编辑器dragstart#handleDragStart设置view.dom.dataset.dragging true通过view.serializeForClipboard序列化选区内容写入dataTransfer兼容旧 IE / iOS WebKit 时降级为Text类型并设置拖拽图片为活动节点 DOM同时把捕获到的NodeSelection塞进view.dragging.node确保放下时删除的是抓起的那个块而非当前光标处的内容dragover#dragoverCallback在手柄拖拽期间根据滚动条状态实现接近顶部 / 底部 20px 缓冲区自动滚动buffer 20drop / dragenddropCallback、dragendCallback统一调用#dragEnd复位dragging标记与dataset.dragging并在dragend时延迟 50ms 清理view.dragging以兼容浏览器先触发dragend再触发drop的时序。仓库在 block-drag.spec.ts 中提供了针对拖拽数学的单元测试它构造了一个包含bullet_list → list_item → paragraph的 Schemalist_item位于独立的listItemgroup 而非blockgroup验证了列表项只能作为列表内兄弟节点被放下等约束是理解filterNodes与位置解析行为的最佳参考用例。6.3 键盘与离开编辑器的兜底keydownCallback在任何按键时隐藏手柄并复位拖拽状态dragleaveCallback检测鼠标是否移出窗口边界x 0 || y 0 || x innerWidth || y innerHeight移出即清理活动节点——这些细节保证了手柄不会在异常路径下残留。七、与 React / Vue 集成官方文档为 React 与 Vue 分别提供了 StackBlitz 在线示例入口react-block与vue-block。在本仓库中你可以参考 packages/integrations/react 与 packages/integrations/vue 的use-editor.ts实现将上文Editor.make().config(...).use(block).create()的流程放进框架的副作用生命周期中并把手柄 DOM 的创建、更新与销毁逻辑与框架组件如 Vue 的onMounted/onUnmounted对齐。八、完整示例与常见问题8.1 一个带完整配置的示例import { Editor } from milkdown/core import { commonmark } from milkdown/preset-commonmark import { block, blockConfig } from milkdown/plugin-block import { BlockProvider } from milkdown/kit/plugin/block function createBlockPluginView(ctx) { return (view) { const content document.createElement(div) content.className block-handle const provider new BlockProvider({ ctx, content, // 手柄显示在块右侧 getPlacement: () right, // 距锚点 8px getOffset: () 8, }) return { update: (updatedView, prevState) { provider.update(updatedView, prevState) }, destroy: () { provider.destroy() content.remove() }, } } } Editor.make() .config((ctx) { ctx.set(block.key, { view: createBlockPluginView(ctx) }) ctx.set(blockConfig.key, { filterNodes: (pos, node) node.type.name ! table, }) }) .use(commonmark) .use(block) .create()配合 CSS 使用[data-showtrue]/[data-showfalse]属性控制手柄显隐BlockProvider.hide/show仅修改该属性不操作display即可得到一个最小可用的块手柄。8.2 常见问题手柄不显示确认已通过ctx.set(block.key, { view })注入view且BlockProvider.update在插件view.update中被调用——初始化发生在requestAnimationFrame内依赖editorViewCtx与blockServiceInstance均已就绪。某些节点无法拖拽检查blockConfig.key中的filterNodes默认实现会排除表格内节点findParent(node node.type.name table)。拖拽后出现空块这是view.dragging.node缺失时的典型症状ProseMirror 会退化为tr.deleteSelection()只删文本本插件的实现已在#handleDragStart中显式写入node: selection规避升级到 7.x 以上版本即可。手柄位置不对优先使用getPlacement与getOffset调整若锚点矩形需要自定义例如基于光标位置使用getPosition返回自定义矩形。九、版本与环境说明本文涉及的实现对应milkdown/plugin-block的7.22.1版本见 packages/plugins/plugin-block/package.json包类型为 ESMtype: module需配合支持 ESM 的构建环境如 Vite使用。插件依赖milkdown/core、milkdown/ctx、milkdown/prose、milkdown/utils以及floating-ui/dom、lodash-es均为workspace:*内部版本或锁定的运行时依赖若在仓库外单独安装请使用与milkdown/core主版本匹配的发布版本。测试用例位于 packages/plugins/plugin-block/src/test/block-drag.spec.ts可作为验证自定义filterNodes与拖拽行为的最小回归基准。结语milkdown/plugin-block用一个view 一个provider 一个服务完成了块级手柄的全部工作blockSpec决定 ProseMirror 插件行为blockConfig决定哪些节点可交互BlockProvider决定手柄的呈现与定位BlockService在幕后驱动显示、隐藏、选中与拖拽。理解这四个切片的协作关系你就能在 Milkdown 上自由定制出符合自己产品形态的块级编辑体验。【免费下载链接】milkdown Plugin driven WYSIWYG markdown editor framework.项目地址: https://gitcode.com/GitHub_Trending/mi/milkdown创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。