上一篇看了剪贴板,view 阶段的独立模块还剩最后两块:坐标换算和浏览器差异补丁。这篇先看坐标换算。src/domcoords.ts 负责文档位置和屏幕坐标之间的双向翻译,点击定位、方向键移动、拖拽落点、选区滚动都经过这里。参考代码是 prosemirror-view 的 ca4c78e。
文档模型里的 pos 是一个一维整数,屏幕上的光标是一个二维矩形,两个方向都没有现成 API,必须借助浏览器的布局结果反推。而浏览器提供的几个原生接口(caretPositionFromPoint、elementFromPoint、getClientRects)各自有行为差异,这个文件的大部分篇幅在补这些差异。
系列目录
coordsAtPos:从文档位置到屏幕矩形
coordsAtPos(view, pos, side) 返回一个 Rect,表示 pos 处光标的落点。side 是倾向参数:pos 落在原子节点(图片这类不可拆的叶节点)边上时,side 为负取节点之前,为正取之后,EditorView 上的默认是 1。
第一步是把 pos 翻译成 DOM 位置。view.docView.domFromPos(pos, side) 沿 ViewDesc 树下行,返回 {node, offset, atom},atom 表示这个 DOM 位置整体落在某个原子节点内部,后面拿它修正。之后按 node 的类型分三路。
文本节点内部是最常见的情况。理想做法是构造一个空 range(起点终点都在 offset 处)直接查它的 client rect,单点查询天然能处理 bidi 文本里视觉顺序和逻辑顺序不一致的问题。只是各浏览器对空 range 查询的支持不齐,代码用 browser.webkit || browser.gecko 判定支持度,只在两种情况下走这条路:文本里出现了 bidi 字符(BIDI 正则覆盖希伯来文、阿拉伯文区段),或者 offset 在节点边缘且 side 指向节点之外。其余情况取邻近字符的矩形再加工:side 为负取前一个字符 rect 的右边缘,为正取后一个字符 rect 的左边缘,flattenV 把矩形压成一条零宽竖线。也就是说返回的光标矩形是一条竖线,x 坐标取自相邻字符的边缘。
这条路上有一个针对 Firefox 的补丁:查询紧跟在折行空白之后的位置时,Firefox 返回的是空白之前的矩形。代码检测前一个字符是空白、且查到的 rect 与前一个字符的 rect 同行时,改查下一个字符的 rect 作为结果。
第二种情况是块级上下文:$dom.parent.inlineContent 为假,说明 pos 位于两个块节点之间,比如段落与段落之间、图片与段落之间。这种位置没有文本行可以依附,光标的正确形态是一条横贯整行的水平线,flattenH 把邻近块节点或父节点的矩形压成一条横线返回。scrollToSelection 这类消费方在块间隙选区上拿到的滚动目标就是这条横线。
第三种是行内上下文但落在元素节点上,offset 指向某个子节点间隙。策略是找最近的实体矩形:offset 前面有兄弟就取它末尾的竖线,后面有兄弟就取它开头的竖线。取后面的兄弟时会跳过 pmViewDesc.ignoreForCoords 为真的节点,widget 装饰这类纯视觉产物不该参与坐标计算。BR 节点只会返回它前面的矩形,所以只有它是父节点最后一个孩子时才被采用。所有尝试都落空,最后兜底取 node 本身的 rect 压扁。
singleRect 是这个方向的公共工具:getClientRects 可能返回多个矩形(折行的 inline 元素每行一个),先按 bias 取第一个或最后一个,拿到零尺寸矩形就在全部矩形里找第一个非零的,全是零尺寸时退回 getBoundingClientRect。
posAtCoords:从屏幕坐标到文档位置
反方向的 posAtCoords(view, {left, top}) 返回 {pos, inside}。inside 是坐标直接命中的最内层节点在文档里的起始位置(desc.posAtStart - desc.border),没有命中节点时为 -1,拖拽落点这类功能用它区分「点在内容上」和「点在空隙里」。
函数先收集两路信息。第一份来自 caretFromPoint(src/dom.ts):优先 caretPositionFromPoint,退化到 caretRangeFromPoint,直接问浏览器这个坐标下的光标位置,返回 DOM node 和 offset。第二份是 elementFromPoint,问浏览器坐标下最上层的元素。如果命中的元素不在编辑器内、但坐标还在编辑器外框之内(比如落在段落的水平 padding 上),就启用本文件自己实现的 elementFromPoint:按 y 坐标在子节点列表里按比例猜一个起始下标,从那里环形扫描全部子节点,对每个子元素查 client rects,坐标落进某个 rect 就递归进那个子元素,直到没有子元素命中为止。比例猜测加环形扫描是为了避免每次都从头遍历。
caretFromPoint 返回的结果不能直接信任,三家浏览器各有毛病,posAtCoords 里挂着一串补丁。Safari 在 draggable 元素上返回无意义结果,发现祖先链上有 draggable 就丢弃 node;Firefox 会把 offset 返回进没有子节点的 input 元素里,需要按 childNodes 长度裁剪,还会把位置放到实际位于坐标后方的图片之前,需要检查后移一位;Chrome 点击不可编辑节点右上方时报告的位置在该节点之后,需要前移一位。还有一个文档末尾的补丁:caretFromPoint 永远不会返回文档末尾之后的位置,代码检测坐标落在最后一个子节点下方时直接返回 doc.content.size。
补丁之外还有一个小修正 targetKludge:列表项里,如果坐标落在内容元素的左边缘之外(即 LI 的项目符号区域),把目标元素从内容节点换成 LI 本身,让后面的换算把位置落在列表项开头,避免落进内容中间。点击定位、拖拽、拖放落点都走 posAtCoords(src/input.ts 的 mousedown、dragover、drop 处理里都能看到),这类边缘修正直接影响用户感知到的落点。
补丁之后,有效结果交给 posFromCaret 做语义修正。浏览器倾向于把位置规范化到附近的 inline 节点里,而 ProseMirror 需要表达块节点之间的位置。posFromCaret 从命中节点沿 ViewDesc 链向上爬,每层检查坐标是否落在该块的矩形之外:落在左方或上方取 desc.posBefore,落在右方或下方取 desc.posAfter。水平测试只对最内层块做,外层块只做垂直测试(表格的 TR、TBODY 等行容器跳过)。途中遇到没有 contentDOM 的叶子 ViewDesc,直接按坐标在矩形里的偏向返回 posBefore 或 posAfter,提前结束。一路爬到顶都没出块,才退回 view.docView.posFromDOM,用原始 node/offset 换算。
caretFromPoint 完全失败时走 posFromElement,它的核心是 findOffsetInNode:遍历元素的所有子节点和它们的 client rects,先确定坐标落在哪一行(rect 与坐标行相交就扩展行高),行内取水平距离最近的子节点。是文本节点就递归进 findOffsetInText,逐字符构造 range 查 rect,找到包含坐标的字符后按左半右半决定 offset 取 i 还是 i+1。逐字符探测成本不低,好在它只在浏览器原生 API 失灵时启用。
endOfTextblock:光标是不是在文本块边缘
endOfTextblock(view, state, dir) 回答一个问题:从当前选区出发,沿 dir 方向移动光标,会不会离开当前文本块。capturekeys.ts 用它决定方向键要不要由编辑器接管:已经在块边缘,左右键需要自定义逻辑去跨越原子节点或离开嵌套结构;不在边缘,直接交给浏览器处理。函数入口有一层缓存,cachedState 和 cachedDir 都相同就直接返回上次结果,方向键的重复触发不会反复触发布局查询。
纵向(up/down)的 endOfTextblockVertical 思路直接:先 domFromPos 拿到光标处的 DOM,再沿 nearestDesc 向上找到最近块级 ViewDesc 的 contentDOM,然后扫描它的所有子节点(文本节点先包成 range)的 client rects。只要上方(或下方)还存在一个有效高度、且与当前光标行的距离超过两倍行高差的矩形,就说明块内还有别的行,返回 false。它判断的是几何上的「上面还有没有行」,与文档结构无关。包裹它的也是后面要讲的 withFlushedState,原因相同:查询时 view 可能还没同步到目标 state。
横向(left/right/forward/backward)的 endOfTextblockHorizontal 是整个文件最绕的部分,值得拆开看。先走两条快速路径:$head.parent 不是 textblock,返回 false;文本块内容测不出 RTL 字符(maybeRTL 正则),或者浏览器不支持 Selection.modify,直接比较 parentOffset 是否等于 0 或 content.size。
真正麻烦的是混排 bidirectional 文本。纯 LTR 文本里逻辑顺序和视觉顺序一致,offset 为 0 就是行首。混入 RTL 片段之后,视觉上的行首可能对应文本中间的某个 offset,「再按一次左键会不会出块」没法靠 offset 判断,只能靠浏览器实测。做法分四步:
- 记录当前 DOM 选区的 focusNode/focusOffset/anchorNode/anchorOffset,Firefox 额外记录
caretBidiLevel。 - 调
Selection.modify("move", dir, "character"),让浏览器把焦点按方向移动一个字符。 - 读移动后的焦点,检查两件事:新焦点跑出了文本块对应的 DOM 范围(
parentDOM用domAfterPos($head.before())取,顶层则是 view.dom 本身),说明已经在块边缘;或者焦点原地没动,说明在文档尽头。两种情况都返回 true。 - 恢复现场:先
collapse回 anchor,再extend回 focus,Firefox 把 caretBidiLevel 也写回去。
源码注释自己承认这是 a huge hack。绕的点在于它直接操纵真实 DOM 选区,而调用方可能在 view 还没同步到目标 state 时查询,比如按键处理里传的是即将应用的 state。所以外层包了一个 withFlushedState:目标 state 和 view.state 不一致就先 updateState 对齐,编辑器没有焦点就先 focus,函数跑完在 finally 里把 state 和焦点都恢复回去。一次查询可能触发两次完整的 view 更新加一次布局,这是它必须配缓存的直接原因。
scrollRectIntoView:把光标滚进视口
最后一个出口是滚动。src/index.ts 的 scrollToSelection 在事务的 scrollToSelection 计数增加时触发(transaction.scrollIntoView() 会推高这个计数),先问 handleScrollToSelection 插件 prop 要不要接管,不管的话,NodeSelection 取节点 DOM 的 bounding rect,其余取 coordsAtPos(head, 1) 的光标矩形,然后交给 scrollRectIntoView(view, rect, startDOM)。
浏览器自带的 scrollIntoView 会一次滚动所有祖先容器,行为不可控。这里的实现是手动沿父链逐层处理:从 startDOM(缺省 view.dom)向上,每层先算容器的可见区间 bounding。body 用 windowRect,优先 visualViewport,移动端键盘弹起导致视口缩小时能被正确感知;普通元素用 clientRect,它专门排除了滚动条宽度,并按 transform: scale() 的比例修正。然后比较 rect 与 bounding:rect 越过某侧边缘加 scrollThreshold 时计算该方向的滚动量,对齐时留出 scrollMargin 的边距;rect 比容器还高时对齐顶部,避免为了露出底部把顶部推出视口。这两个值来自同名 view props,scrollMargin 默认 5。
非 body 容器直接改 scrollTop/scrollLeft,body 用 window.scrollBy。每次滚动后把 rect 按实际滚动量修正,再继续向上一层。爬升规则有两个特例:position 是 fixed 或 sticky 时停止,再往上滚不影响这个元素;absolute 时走 offsetParent。这里的 parentNode 来自 src/dom.ts,会跨 shadow DOM 边界向上。
文件里还有几组配套函数顺带记一下。storeScrollPos 在编辑器顶边横向中线上每隔 5 像素向下探测,找到视口内第一个元素作为锚点,记下它的 top 和整条祖先链的滚动位置;resetScrollPos 在文档上方内容高度变化后,用锚点的新旧 top 差值补偿各层 scrollTop。协作场景里别人在上方插入内容时,本地视口内容看起来没有跳动,靠的就是这对函数。另一个是 focusPreventScroll:focus 默认会把元素滚进视口,这里用特性探测确认浏览器支持 focus({preventScroll: true}),不支持就先存滚动位置、focus 完再恢复,保证程序化聚焦不打乱用户的阅读位置。
小结
这篇看完了 src/domcoords.ts 的四组能力。coordsAtPos 用相邻字符矩形压扁的策略把一维位置翻译成屏幕矩形,块级上下文切换为水平线形态;posAtCoords 在 caretFromPoint 的结果上打一串浏览器补丁,再沿 ViewDesc 链做块级修正,两条路都失效时逐字符探测;endOfTextblock 用真实移动选区再恢复的办法实测 bidi 文本的横向边界,外层包 state 同步和缓存控制成本;scrollRectIntoView 放弃浏览器原生行为,逐层容器手动滚动。view 阶段还剩最后一个基础模块,下一篇看 browser.ts 和 dom.ts:浏览器差异怎么探测,以及这一篇里那些补丁的判断依据从哪来。

