You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Select a word normally, then scroll the document surface with the wheel while the popup is open from Shift+F10.
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
The popup opens offscreen, above the top of the editor.
Scrolling with the popup open leaves it where it was while the text moves away, so it points at unrelated content.
Scrolling far enough closes the popup, discarding a live selection's command surface, while a popup holding focus stays open and drifts instead.
Documentation, decisions, or prior investigation: specification.md and reference.md state the current scroll behavior and will need rewriting. specification.md describes the open states this issue does not change.
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.
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
Which commands appear in the popup and their availability rules.
Whether a keyboard selection opens the popup at all, which is its own issue.
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
Mod+A, then pressShift+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.Shift+F10.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 inarchitecture.mdare unaffected.Actual behavior
Related context
specification.mdandreference.mdstate the current scroll behavior and will need rewriting.specification.mddescribes the open states this issue does not change.Done when
docs/specification.mdanddocs/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.Mod+A, places the popup inside the selection rather than offscreen, whichever of its edges are visible.Notes, logs, screenshots
Diagnosis
getSelectionAnchor(contextPopup.ts) takescoordsAtPosat both selection ends and returnstopfrom the start,bottomfrom the end, andxas the midpoint of the start's left edge and the end's right edge.EditorContextPopup.tsxrenders that as aposition: fixedone-pixel-wide span, which Radix positions against.Two consequences:
Mod+Ain a long document it is thousands of pixels, soside="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.fixedat 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.xis 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
ScrollAreainDocumentScreen.tsx.Radix supplies the machinery, with one requirement the installed sources make non-optional:
PopoverAnchoraccepts avirtualRef, a reference object with agetBoundingClientRect()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 —PopperAnchorrenders nothing at all oncevirtualRefis set.contextElement.autoUpdateresolves scroll ancestors throughunwrapElement, which returnscontextElementfor a non-element reference; without one the ancestor list is empty and nothing re-queries on scroll.detectOverflowreads it the same way and otherwise falls back to the document element, which would measure against the window instead of theScrollArea.view.domserves for both.PopperContentsupportshideWhenDetached, which is floating-ui'shide({ 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,flipoverflows above and falls back to a best fit that is still off the screen, and Radix configuresshiftwithcrossAxis: falseso 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.
referenceHiddenisoverflow[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.
hidereportsreferenceHiddenfromoverflow[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 andhideWhenDetachedare one seam, not two independent pieces.Popup holds focus: frozen at its last strictly clamped value, so it is neither hidden nor moved.
Shift+F10pressed 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