Skip to content

Context popup anchors to the whole selection and detaches from it on scroll #163

Description

@Azganoth

Summary

The context popup anchors to a fixed viewport point spanning the entire selection, computed once when it opens. Three defects follow from that one model: a large selection pushes the popup offscreen, scrolling leaves the popup behind while the text moves away, and a popup that scrolled out of view is closed even though the selection it acts on is still active.

The fix is to anchor to the visible part of the selection rather than to the whole of it, and to hide the popup while none of the selection is visible instead of closing it. All three follow from that one change, and fixing any one alone would be undone by the others.

Steps to reproduce

  1. Place the caret in a long document, press Mod+A, then press Shift+F10. A keyboard selection does not open the popup on its own, which is Keyboard selection does not open the context popup that the documents promise #164.
  2. Select a word normally, then scroll the document surface with the wheel while the popup is open from Shift+F10.
  3. Repeat step 2 and keep scrolling until the selected text leaves the viewport, then scroll back.

Expected behavior

The popup anchors to the part of the selection that is currently visible, so it stays beside the text it acts on in every case: a small selection, a selection taller than the viewport, and a selection scrolled partly out of view. It follows the text as the document scrolls. While no part of the selection is visible it is hidden rather than closed, and it returns when the selection scrolls back into view, because the selection stays active throughout and its commands stay applicable.

Must remain stable: the popup still closes on Escape, typing, clicking outside, and a collapsed selection; a popup holding focus is never hidden or moved out from under an in-progress keyboard interaction; and the focus ownership rules in architecture.md are unaffected.

Actual behavior

  1. The popup opens offscreen, above the top of the editor.
  2. Scrolling with the popup open leaves it where it was while the text moves away, so it points at unrelated content.
  3. Scrolling far enough closes the popup, discarding a live selection's command surface, while a popup holding focus stays open and drifts instead.

Related context

Done when

  • docs/specification.md and docs/reference.md — the popup follows the visible selection, hides while none of it is visible, and returns; the focus-dependent scroll rule they currently state is replaced.
  • A selection taller than the viewport, including Mod+A, places the popup inside the selection rather than offscreen, whichever of its edges are visible.
  • Scrolling moves the popup with the text it acts on.
  • A popup whose selection has scrolled entirely out of view is hidden and returns on scrolling back, with the selection and any held focus intact.
  • A popup holding focus is neither hidden nor repositioned out from under the user.
  • Regression coverage for the oversized selection, the follow-on-scroll path, and hide-and-return.

Notes, logs, screenshots

Diagnosis

getSelectionAnchor (contextPopup.ts) takes coordsAtPos at both selection ends and returns top from the start, bottom from the end, and x as the midpoint of the start's left edge and the end's right edge. EditorContextPopup.tsx renders that as a position: fixed one-pixel-wide span, which Radix positions against.

Two consequences:

  • The span is as tall as the selection. For Mod+A in a long document it is thousands of pixels, so side="bottom" places the popup below the document's end, collision handling flips it above the span's top, and that is at or above the viewport top.
  • The span is fixed at coordinates captured when the popup opened, so it does not move when the document scrolls. That is what the scroll-close listener (EditorContextPopup.tsx) was hiding, and why the popup drifts once Editor context popup cannot be reached or operated by keyboard #120 suppressed that close for a focus-holding popup.

x is also arbitrary for any multi-line selection, since the midpoint of the start's left edge and the end's right edge need not fall near either.

Implementation direction

Anchor to the visible part of the selection, measured on every reposition rather than captured once. The editor's scroll viewport is the ScrollArea in DocumentScreen.tsx.

Radix supplies the machinery, with one requirement the installed sources make non-optional:

  • PopoverAnchor accepts a virtualRef, a reference object with a getBoundingClientRect() under our control. Deriving that rect from the selection lets floating-ui re-query it as the document scrolls, with no scroll listener and no per-frame React state, and the fixed span goes away — PopperAnchor renders nothing at all once virtualRef is set.
  • The virtual element must carry a contextElement. autoUpdate resolves scroll ancestors through unwrapElement, which returns contextElement for a non-element reference; without one the ancestor list is empty and nothing re-queries on scroll. detectOverflow reads it the same way and otherwise falls back to the document element, which would measure against the window instead of the ScrollArea. view.dom serves for both.
  • PopperContent supports hideWhenDetached, which is floating-ui's hide({ strategy: "referenceHidden" }) and applies visibility rather than unmounting. That is the hide-instead-of-close behavior, and it keeps the popup's open state, its focus, and the selection.

Clamping to the visible selection is necessary but not sufficient, so the rect function resolves four cases from one measurement:

  • Intersects the viewport, with room for the popup on at least one side: the intersection.

  • Taller than the visible area, or filling it with room on neither side: the intersection collapsed to its first visible line, so side="bottom" lands inside the selection. Without this the popup ends up wherever the selection's visible edges happen to fall — below its last visible line, above its first, or nowhere at all — and collision handling cannot rescue it: side="bottom" overflows below, flip overflows above and falls back to a best fit that is still off the screen, and Radix configures shift with crossAxis: false so it only slides along the alignment axis. A selection shorter than the visible area with room beside it still anchors beside it as it does today.

  • Any anchor meant to be visible: at least a pixel of height. referenceHidden is overflow[side] - rect[dimension] >= 0, so a rect of no height resting on the clipping edge — which the collapse above produces for a selection that starts above the visible area — reads as fully clipped and hides the popup outright.

  • Does not intersect: the raw, unclamped selection rect. hide reports referenceHidden from overflow[side] - rect[dimension] >= 0, which a rect clamped inside the clipping boundary can never satisfy, so returning the clamp here would suppress hiding entirely. Clamping and hideWhenDetached are one seam, not two independent pieces.

  • Popup holds focus: frozen at its last strictly clamped value, so it is neither hidden nor moved. Shift+F10 pressed while the selection is already scrolled out of view has no prior rect to freeze, and pins to the strict clamp instead, which keeps a focused toolbar on screen rather than stranding it at an offscreen anchor.

Hiding must be visual only. Routing it through the existing close path would drop focus and the selection.

Out of scope

Metadata

Metadata

Assignees

Labels

BugSomething isn't working

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions