dropcursor:拖拽时的插入位置指示

2 分钟阅读
·

上一篇的 gapcursor 解决的是「光标落不进去的缝隙」,这篇的 dropcursor 解决相邻的一个问题:拖拽内容经过编辑器上方时,怎么告诉用户松手会插到哪。浏览器原生的拖拽反馈只有鼠标箭头上的一个小图标,在文档里拖图片或者从外部拖文件进来时,用户看不到插入点,只能凭手感。dropcursor 画的就是这个插入点指示。整个包只有 src/dropcursor.ts 一个文件,170 行。参考代码是 prosemirror-dropcursor 的 3003cfc;顺带引用的 prosemirror-transform、prosemirror-view、prosemirror-state 分别是 662b7a9、ca4c78e、ffad5d9。

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21 ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05 ResolvedPos:一个数字位置怎么变成路径
07-12 Slice 与 replace:切一块文档出来再塞回去
07-19 DOMSerializer:文档怎么变成 DOM 和 HTML
08-02 DOMParser:parseDOM 规则与 HTML 解析
08-09 findDiffStart / findDiffEnd:两份文档怎么求差
08-16 model 收官:Node 上的辅助方法与位置约定总结
09-06 ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20 ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03 StepMap:一步修改怎么映射每个位置
10-11 Mapping:多步映射的链式合并,rebase 的地基
10-18 structure.ts:split/join/lift/wrap 的可达性判断
10-25 Transform 类:构建修改的 API 层
11-08 ProseMirror state(上):EditorState,不可变编辑器状态
11-15 Selection 体系:四种选区与选区书签
11-22 Transaction:Transform 加上状态语义
12-06 Plugin 系统(上):StateField 与插件状态
12-13 Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20 state 收官:动手写三个插件验证理解
01-03 ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10 ViewDesc(上):文档到 DOM 的描述树
01-17 ViewDesc(下):增量更新怎么做到只改动的部分
02-07 DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档
02-14 input.ts:从 keydown 到 dispatchTransaction 的输入管线
02-21 选区同步:state 选区与 DOM 选区的双向对齐
02-28 Composition 与 IME:中文输入法事件的处理
03-07 NodeView 与 MarkView:把渲染权交给你
03-14 Decoration 体系:不修改文档的视觉标注
03-21 clipboard:复制粘贴的序列化与解析
04-04 domcoords:屏幕坐标与文档位置的双向换算
04-11 browser.ts:浏览器差异补丁集
04-18 view 收官:不用官方扩展,手写一个最小可用编辑器
05-09 扩展(上):keymap,最小的插件
05-16 commands:命令的签名约定与组合器
05-23 history:undo/redo 栈与 rebasing
06-06 inputrules:「# 空格」变成标题是怎么实现的
06-13 schema-basic:官方基础文档结构
06-20 schema-list:列表节点与最复杂的一批命令
07-04 gapcursor:光标落不进去的地方怎么办
07-11 dropcursor:拖拽时的插入位置指示(本篇)

插件形态:只有 view,没有 state

dropCursor(options) 返回的 Plugin 只挂了一个 view 字段,没有 StateField,没有 props:

export function dropCursor(options: DropCursorOptions = {}): Plugin {
  return new Plugin({
    view(editorView) { return new DropCursorView(editorView, options) }
  })
}

插件的运行期状态全部存在 DropCursorView 实例的字段上:cursorPos(当前指示的文档位置)、element(指示线 DOM)、timeout(移除定时器)、handlers(事件监听登记表)。三个选项:color 默认 black,传 false 则完全交给 CSS class 控制;width 指示线粗细,默认 1;class 附加到指示线元素上的自定义类名。没有任何字段进 EditorState,transaction 层面完全感知不到这个插件的存在。

事件挂载的方式值得单拿出来说。前面讲过的插件要响应 DOM 事件,走的都是 handleDOMEvents prop,由 view 的事件管线按插件顺序逐个询问(第 29 篇)。dropcursor 没走这条路,它在构造函数里直接对 editorView.domaddEventListener,挂 dragover、dragend、drop、dragleave 四个原生监听,handlers 数组把名字和函数成对存下来,destroy 里按表逐个 removeEventListener 清掉,不留泄漏。原因我想有两层。一是这四个事件不需要和其它插件竞争:指示线是纯视觉反馈,不要求拦截事件、也不要求在某个 keymap 之前或之后执行,someProp 的顺序语义对它没有意义。二是 handleDOMEvents 的 handler 返回 true 会阻止后续 handler 和默认行为,dropcursor 的 dragover 只想旁观,连 preventDefault 都不调,旁观者的身份用独立监听表达更直接。view 自己在 input.ts 里对 dragover、drop 另有一套处理(那里才负责 preventDefault 和真正的插入),两边各挂各的监听,互不干扰。

dragover:从鼠标坐标到落点

主流程在 dragover(event) 方法里,每次拖拽移动都会触发。按顺序做四件事。

第一,editorView.editable 为假直接返回,只读编辑器不显示指示。

第二,posAtCoords({left: event.clientX, top: event.clientY}) 把鼠标坐标换算成文档位置(坐标换算的实现见第 35 篇),返回 {pos, inside}inside 标出坐标直接落在哪个节点的边界内部。拿不到位置(返回 null)就什么都不做。posAtCoords 本身已经做过一轮位置合理化:它逐层下钻 DOM,返回的是离坐标最近的合法文档位置,拖拽在块元素之间的空白带上移动时,拿到的就是某个块边界位置,这正好是指示线需要的那类落点。

第三,查 disableDropCursor 开关。pos.inside 非负时 doc.nodeAt(pos.inside) 拿到鼠标正下方的节点,读它 type.spec.disableDropCursor;inside 为 -1(坐标没有直接落在某个节点边界内部)时查不到节点,这一步自然放行。这个字段是 dropcursor 通过 declare module "prosemirror-model" 给 NodeSpec 补的声明,写法上是个约定字段,和 gapcursor 的 allowGapCursor 同一个路子。值可以是布尔,也可以是函数,函数签名是 (view, pos, event) => boolean,能拿到当前事件做动态判断,比如按 DataTransfer 里的类型区分对待。值为真表示这个节点内部不显示指示线,本次 dragover 到此为止。代码块这类节点适合开这个开关:往代码块中间拖一张图片,schema 本来就放不下,指示线画出来只会误导。

第四,算落点并显示。target 先取 pos.pos,然后有一个针对内部拖拽的修正:如果 editorView.dragging 非空且带着 slice,说明这次拖拽是从编辑器内部发起的(view 的 src/input.ts 在 dragstart 时把被拖内容序列化成 Slice,连同 move 标记一起存进 Dragging 对象),就调 prosemirror-transform 的 dropPoint 做一次 schema 校验,返回值非空就把它作为落点。最后 setCursor(target),并调 scheduleRemoval(5000),五秒内没有新的 dragover 就自动撤掉。

拿一个具体场景串一遍。文档是 doc(paragraph, image, paragraph),用户按住 image 往下拖,鼠标移到第二个 paragraph 附近。dragover 触发,posAtCoords 返回的位置落在第二个 paragraph 内部开头一带,inside 指向它;nodeAt 查 disableDropCursor 没开;dragging 里是 image 的 slice,dropPoint 拿这个位置和 slice 去校验,paragraph 的内容表达式只允许 inline,image 在段落内部任何位置都放不进,向外退到 doc 层,按鼠标偏向取段落之前或之后的边界,那里可以放;setCursor 记下这个位置,updateOverlay 画出横线。用户看到的指示线出现在段落的边界上,没有跟进鼠标正下方的段落内部,这就是 schema 校验造成的修正。

dropPoint:不可放的位置怎么处理

dropPoint(doc, pos, slice) 来自 prosemirror-transform 的 src/structure.ts,第 17 篇讲结构判断时提过 insertPoint,它是同一族的函数,区别是 insertPoint 按节点类型查,dropPoint 按 Slice 查,并且不要求 pos 本身在节点边界上。实现分两段。

第一段处理 slice。被拖的 slice 内容为空时函数直接返回原 pos,不进入任何校验,这是函数开头的早退分支。slice 还可能带着 openStart(从文本块中间切开选区时产生的打开深度,第 8 篇讲过),打开的部分只是上下文包装,真正要插入的内容在剥开 openStart 层之后才拿到。代码用一个循环把 content 逐层换成 firstChild.content,剥完再进入校验。

第二段是逐层向外的扫描,最多两轮。从 $pos.depth 开始向外,每层先定一个偏向:在最内层 bias 为 0,命中就直接返回原位置;在外层则比较 pos 和内层节点的中点,靠前为 -1、靠后为 1,对应取该层的 beforeafter 边界。每一层问 parent.canReplace(insertPos, insertPos, content) 能不能在这个下标放。第一轮全部失败时,如果 slice 没有打开深度,还有第二轮:用 contentMatchAtfindWrapping 给内容找一层包装节点(比如给段落外面包 blockquote),再问 canReplaceWith 能不能放包装后的形态。找到就返回对应边界位置,两轮都失败返回 null。

回到 dragover 里的调用方式:

let point = dropPoint(this.editorView.state.doc, target, this.editorView.dragging.slice)
if (point != null) target = point

注意 null 的分支:校验失败时 target 保留 pos.pos 原值,指示线照样显示。所以「校验不通过就不显示」的说法不准确。实际行为是校验通过时把指示线挪到最近的可放位置,比如把图片拖到两个列表项中间,落点被修正到列表之外;校验失败时退化为坐标直出的位置。真正让指示线不出现的只有两条路:posAtCoords 返回 null,或者 disableDropCursor 生效(此外还有个大前提:editable 为假时 dragover 在第一行就返回,整个插件不工作)。这个分工可以理解为:dropPoint 负责位置的合法性修正,disableDropCursor 负责整个区域的显式禁用,一个自动,一个手动。

还有一层前提要交代:dropPoint 只在内部拖拽时执行。从操作系统拖文件进来、从别的窗口拖 HTML 进来,editorView.dragging 是 null,此刻编辑器手上没有 slice,无法提前知道内容是什么,校验无从谈起,指示线直接按坐标位置画。

指示线怎么画:updateOverlay

setCursor 不直接画线,只更新状态:位置没变直接返回;置 null 时把 element 从父节点摘掉;有新位置时调 updateOverlay 绘制。绘制的产物是一个绝对定位的 div,挂在 editorView.dom.offsetParent 下面,不进文档 DOM,也不走 Decoration 体系。样式内联写死 position: absolute; z-index: 50; pointer-events: none;pointer-events: none 保证它不拦截拖拽事件,drop 发生时事件照常落到编辑器上。包里没有附带 CSS 文件,颜色靠 color 选项写成 backgroundColor,想加动画或圆角就走 class 选项自己写样式。

矩形计算分两种情况,判断依据是 $pos.parent.inlineContent:落点的父节点能否容纳 inline 内容。

dropcursor 的处理链与两种指示线

块级位置(父节点没有 inlineContent,比如两个块之间的缝隙)画横线。这类位置没有文本容器,coordsAtPos 在那里拿不到可靠的字符盒,所以换个思路,借相邻块节点的矩形。取 $pos.nodeBefore$pos.nodeAfter,用 editorView.nodeDOM 反查相邻节点的 DOM 元素(ViewDesc 树里存的引用,第 26 篇),拿它们的 getBoundingClientRect。只有前节点,线贴前节点的 bottom;只有后节点,线贴后节点的 top;两侧都有,取前节点 bottom 与后节点 top 的中点,落在两者正中间。宽度铺满节点矩形的左右边界,高度按 width 选项向上下各扩一半。nodeDOM 查不到元素时 rect 保持空,退回下面的行内路径,算一个兜底。

行内位置画竖线。落点在文本中间,coordsAtPos(this.cursorPos) 直接给出该位置的字符盒(第 35 篇那套坐标换算),矩形的 top/bottom 就是字符盒的上下沿,左右以 coords.left 为中心按 width 展开。元素本身是复用的:第一次绘制时创建并 append,之后每次 updateOverlay 只更新类名和四个尺寸值,dragover 以每几十毫秒的频率触发时不用反复增删 DOM。

矩形算出来后还有一步坐标系换算。元素挂在 offsetParent 下,left/top 要相对它表达:offsetParent 为空、或者是 static 定位的 body 时用 pageXOffset/pageYOffset 补偿,否则用父元素的矩形和滚动量换算。文件里还处理了一个不常见的细节:getBoundingClientRect 返回的是视觉尺寸,offsetWidth 是布局尺寸,两者的比值就是 CSS transform 的缩放系数。scaleXscaleY 两个系数贯穿全部计算,编辑器整体被 transform 缩放时指示线仍能画在正确的位置。prosemirror-dropcursor-blockprosemirror-dropcursor-inline 两个类名按 isBlock 切换,用户样式可以按这两类分别定制。

生命周期:什么时候撤掉

指示线的存续由一个定时器和两条被动路径共同管理。

定时器是 scheduleRemoval(timeout),先 clearTimeout 再重新计时。dragover 每次触发都续五秒,拖拽移动期间指示线一直在。五秒这个数字兜住的是一种边缘情况:用户拖着东西停在编辑器上方不动,浏览器停止派发 dragover,指示线不至于永远挂着。

dropdragend 把超时缩到 20 毫秒。两个事件意味着拖拽已经结束,指示线该撤了,但 20 毫秒的延迟让 drop 产生的 transaction 先派发完、DOM 先更新,指示线再在新文档的基础上消失,视觉上不会闪一下旧位置。

dragleave 的处理多一步判断。这个事件在鼠标进入子元素时也会触发(事件从子元素边界冒泡上来),直接撤掉会导致指示线在编辑器内部移动时闪烁,所以先查 event.relatedTarget:只有 relatedTarget 不在 editorView.dom 内部,即鼠标真的离开了编辑器区域,才立即 setCursor(null)

被动路径是 plugin view 的 update(editorView, prevState)。文档是否变化用引用比较判断:prevState.doc != editorView.state.doc,文档不可变,没有 transaction 改动时引用不变,直接跳过。确认文档变了且指示线正显示时:如果 cursorPos 已经超出新文档的 content.size,直接移除;否则重新调 updateOverlay,按新文档重算矩形。拖拽过程中文档可能被别的插件或远端改动,这条路径保证指示线跟得上。这里的处理是有意的简化:没有用 StepMap 去 map 位置,只做了越界检查加原地重算。指示线是瞬时 UI,拖一次画一次,偏差最多持续一次 dragover 的间隔,为它维护一套映射机制不划算。

三个选项组合起来的典型用法是:color 留默认就能用;想要虚线、动画或圆角,传 color: falseclass,在自己的样式表里按 prosemirror-dropcursor-blockprosemirror-dropcursor-inline 两个状态类分别写。包本身不带样式文件,这两个类名就是它和样式层之间的全部约定。

和 gapcursor 对照着看

两个包解决的都是「位置指示」,实现路线完全不同。gapcursor 把光标做成了正式选区:新增 Selection 子类、走 decorations 画 widget、参与选区同步和 history 书签,因为它要接收键盘输入,必须是编辑器状态的一部分。dropcursor 什么都没有:没有选区类型,没有 StateField,没有装饰,状态只有一个数字加一个 div,事件自己挂原生监听。区别来自需求本身。gap cursor 是落点,用户要在那里打字,选区、输入、undo 都得认识它;drop cursor 是预览,松手之后它就没有用了,真正的插入由 view 的 drop 处理器(input.ts 的 handleDrop)独立完成,指示线完全不参与插入逻辑。

接入面和 gapcursor 一样小:插件 view 规格、NodeSpec 的约定字段、view 的公开 API(posAtCoords、coordsAtPos、nodeDOM、dragging),外加 transform 的 dropPoint,没有给核心打任何补丁。170 行里大半是几何换算和生命周期管理,真正和文档模型打交道的只有 dropPoint 那一个调用,位置合法性的全部复杂度都复用了 transform 层的既有能力。这两个包读下来,基础扩展阶段剩下的组件已经不多了,下一篇看 menu,把 UI 组件层怎么架在命令体系上讲完。


950 字 · 38 段落
xi ming

Written by xi mingFollow onGitHub