豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Rect#getDomRangeRects() utility should set the DOM Range as a source for each Rect to improve UX of floating UIs #19705

@oleq

Description

@oleq

📝 Provide detailed reproduction steps (if any)

  1. Open any editor with an editing root that has a fixed height and overflow: scroll
  2. Enable balloon toolbar
  3. Scroll editing root

✔️ Expected result

The balloon toolbar hides as soon as its anchor goes off the visible editing root.

❌ Actual result

It floats beyond the boundaries of the editing root

Screen.Recording.2026-01-29.at.13.31.12.mov

❓ Possible solution

Here's why it happens:

  1. While figuring its position, the BT uses Rect#getDomRangeRects()

private _getBalloonPositionData() {
const editor = this.editor;
const view = editor.editing.view;
const viewDocument = view.document;
const viewSelection = viewDocument.selection;
// Get direction of the selection.
const isBackward = viewDocument.selection.isBackward;
return {
// Because the target for BalloonPanelView is a Rect (not DOMRange), it's geometry will stay fixed
// as the window scrolls. To let the BalloonPanelView follow such Rect, is must be continuously
// computed and hence, the target is defined as a function instead of a static value.
// https://github.com/ckeditor/ckeditor5-ui/issues/195
target: () => {
const range = isBackward ? viewSelection.getFirstRange() : viewSelection.getLastRange();
const rangeRects = Rect.getDomRangeRects( view.domConverter.viewRangeToDom( range! ) );
// Select the proper range rect depending on the direction of the selection.
if ( isBackward ) {
return rangeRects[ 0 ];
} else {
// Ditch the zero-width "orphan" rect in the next line for the forward selection if there's
// another one preceding it. It is not rendered as a selection by the web browser anyway.
// https://github.com/ckeditor/ckeditor5-ui/issues/308
if ( rangeRects.length > 1 && rangeRects[ rangeRects.length - 1 ].width === 0 ) {
rangeRects.pop();
}
return rangeRects[ rangeRects.length - 1 ];
}
},
positions: this._getBalloonPositions( isBackward )
};
}

  1. This method returns multiple Rect instances for a DOM Range.
  2. Those instances are passed to ContextualBalloon#updatePosition()
  3. Down the rabbit hole, BalloonPanelView#attachTo() is called, which has a fallback that moves the positioned element out of the viewport if it is invisible (e.g. cropped by parents).
    const optimalPosition = BalloonPanelView._getOptimalPosition( positionOptions ) || POSITION_OFF_SCREEN;
  4. The logic that decides whether an element is visible or not is called in getOptimalPosition()
    if ( !visibleTargetRect || !constrainedViewportRect.getIntersection( visibleTargetRect ) ) {
    return null;
    }
  5. Long story short, at some point, Rect#isVisible() is used. If the rect has no source that would pinpoint it on the Z-axis (ancestors-axis) in DOM, it always is visible because it's just an abstract rectangle in the space.
  6. If Rects produced by Rect#getDomRangeRects() have DOMRect as a source, there's no way to determine whether they may be cropped. But if we set DOMRange as source, the logic will extract the common range ancestor and figure out whether the cropping occurs in Range#getVisible().

Extra

Addressing this issue will also fix #19710


If you'd like to see this fixed sooner, add a 👍 reaction to this post.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions