From bd217a20e7ed0aa5234680865a5dcde5b2cbec57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 2 Aug 2026 13:28:03 -0300 Subject: [PATCH 1/3] Anchor the context popup to the visible part of its selection The popup positioned against a fixed span measured once when it opened, so it drifted from its text as the document scrolled and, for a selection taller than the viewport, landed past the top of the editor. A virtual anchor measuring the live selection replaces it. Floating UI re-queries that anchor through its context element, which is also what resolves the scroll ancestors it listens on. The scroll listener that closed the popup existed only to hide the stale anchor, so it goes with it. --- CHANGELOG.md | 1 + docs/reference.md | 2 +- docs/specification.md | 2 +- .../components/EditorContextPopup.test.tsx | 26 +++-- .../editor/components/EditorContextPopup.tsx | 54 +++++----- src/features/editor/index.ts | 7 +- .../editor/plugins/contextPopup.test.tsx | 94 +++++++++++----- src/features/editor/plugins/contextPopup.ts | 36 ++----- .../editor/utils/contextPopupAnchor.test.ts | 70 ++++++++++++ .../editor/utils/contextPopupAnchor.ts | 101 ++++++++++++++++++ 10 files changed, 294 insertions(+), 99 deletions(-) create mode 100644 src/features/editor/utils/contextPopupAnchor.test.ts create mode 100644 src/features/editor/utils/contextPopupAnchor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index dee8fb7..70dbcdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Announce the article navigator as a tree, with the nesting depth, sibling position, and expanded state of every row. - Keep empty folders in the article navigator reachable instead of skipping them. - Open the editor context popup with `Shift+F10` or the `Menu` key and operate every command in it from the keyboard. +- Keep the editor context popup beside the text it acts on while the document scrolls, and inside the editor for a selection taller than the visible area. - Announce the editor context popup as a named toolbar instead of an unnamed dialog. - Announce recent files and recent folders under their own headings in the `Open recent` menu. - Disable a submenu instead of opening it empty when every command inside it is unavailable. diff --git a/docs/reference.md b/docs/reference.md index dd5cc8e..d49809d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -239,7 +239,7 @@ The context popup is a contextual menu triggered by selection, right-click, or ` - Right-click inside an existing selection keeps the selection. - Right-click outside a selection uses the editor's normal pointer handling to place the caret at the clicked location; the popup does not perform a second coordinate-based caret move. -- `Escape`, typing, or clicking outside closes it, as does `Tab` while focus is inside it. Scrolling the popup out of view closes it only while focus is in the editor; a popup holding focus stays open. +- `Escape`, typing, or clicking outside closes it, as does `Tab` while focus is inside it. Scrolling does not close it. #### Popup Command Groups diff --git a/docs/specification.md b/docs/specification.md index d81c414..adbca5d 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -176,7 +176,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - `Escape`: Closes the popup, or an open submenu first, returning focus to the command that opened it. - `Tab`: Closes the popup as well, rather than moving to another control. - Closing a popup that holds focus returns focus to the editor with its selection intact, whichever path closed it. -- A scroll closes the popup while focus is in the editor. While focus is inside it the popup stays open and may drift from the text it anchors to. +- The popup anchors to the part of its selection that is visible in the document surface and follows that text as the document scrolls. A selection taller than the visible area anchors the popup inside it rather than past its edge. Scrolling does not close the popup. - Structural editing and native text gestures retain their normal editor behavior. Leafdown commands provide the same semantic operations across menus, keyboard shortcuts, and the context popup. - The app intercepts and disables default webview reload and navigation shortcuts, including `Mod+R` and `Mod+Shift+R`, to prevent accidental state resets. diff --git a/src/features/editor/components/EditorContextPopup.test.tsx b/src/features/editor/components/EditorContextPopup.test.tsx index 7576d77..a983332 100644 --- a/src/features/editor/components/EditorContextPopup.test.tsx +++ b/src/features/editor/components/EditorContextPopup.test.tsx @@ -10,7 +10,13 @@ import { EditorContextPopup } from "./EditorContextPopup"; const noop = () => {}; -const ANCHOR = { x: 40, top: 60, bottom: 80 }; +const createAnchorRect = (): DOMRect => { + const rect = { bottom: 80, height: 20, left: 40, right: 41, top: 60, width: 1, x: 40, y: 60 }; + + return { ...rect, toJSON: () => rect }; +}; + +const ANCHOR = { contextElement: document.body, getRect: createAnchorRect }; const POINTER_REQUEST: ContextPopupRequest = { anchor: ANCHOR, source: "pointer" }; const KEYBOARD_REQUEST: ContextPopupRequest = { anchor: ANCHOR, source: "keyboard" }; @@ -57,10 +63,12 @@ const enabledPopupCommandState = createActiveEditorCommandState({ }); describe("EditorContextPopup", () => { - it("uses the selection range as the collision-aware popup anchor", () => { + it("positions against the measured selection instead of a rendered anchor element", async () => { + const getRect = vi.fn(createAnchorRect); + render( { />, ); - const anchor = document.querySelector('[data-slot="popover-anchor"]'); - - expect(anchor).toHaveStyle({ height: "20px", left: "40px", top: "60px" }); + await waitFor(() => { + expect(getRect).toHaveBeenCalled(); + }); + expect(document.querySelector('[data-slot="popover-anchor"]')).toBeNull(); }); it("renders the initial five-row context UI", () => { @@ -542,7 +551,7 @@ describe("EditorContextPopup", () => { }); describe("scroll", () => { - it("closes on a scroll while focus is still in the editor", () => { + it("stays open on a scroll while focus is still in the editor", () => { const onClose = vi.fn(); render( @@ -557,7 +566,8 @@ describe("EditorContextPopup", () => { dispatchDOMEvent(document, "scroll"); - expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + expect(screen.getByTestId("editor-context-popup")).toBeInTheDocument(); }); it("stays open on a scroll while focus is inside it", async () => { diff --git a/src/features/editor/components/EditorContextPopup.tsx b/src/features/editor/components/EditorContextPopup.tsx index 36b0d8a..d8f01cd 100644 --- a/src/features/editor/components/EditorContextPopup.tsx +++ b/src/features/editor/components/EditorContextPopup.tsx @@ -23,7 +23,7 @@ import { Trash2Icon, type LucideIcon, } from "lucide-react"; -import { useEffect, useRef, type KeyboardEvent } from "react"; +import { useEffect, useLayoutEffect, useRef, type KeyboardEvent } from "react"; import { Button } from "@/components/ui/Button"; import { @@ -40,6 +40,7 @@ import { cn } from "@/lib/cn"; import type { EditorCommandId, EditorCommandState } from "../commands"; import { EDITOR_COMMAND_LABELS } from "../commands/metadata"; import type { ContextPopupRequest } from "../plugins/contextPopup"; +import type { ContextPopupAnchor } from "../utils/contextPopupAnchor"; interface ContextButtonCommand { commandId: EditorCommandId; @@ -145,6 +146,13 @@ const focusAdjacentRow = (toolbar: HTMLElement, control: HTMLElement, step: 1 | return true; }; +// Radix's `Measurable`, plus the element Floating UI resolves scroll ancestors and clipping +// through for a virtual reference. +interface VirtualAnchor { + contextElement: Element | undefined; + getBoundingClientRect: () => DOMRect; +} + interface EditorContextPopupProps { commandState: EditorCommandState; onClose: () => void; @@ -165,24 +173,22 @@ export function EditorContextPopup({ const contentRef = useRef(null); // Sticky for one open popup, so that focus moving into a portalled submenu does not clear it. const hasHeldFocusRef = useRef(false); + const anchorRef = useRef(null); + // Radix reads the virtual anchor on every render and re-registers it whenever its identity + // changes, which would re-render this component in turn. It has to be created once. + const virtualRef = useRef({ + get contextElement() { + return anchorRef.current?.contextElement; + }, + getBoundingClientRect: () => anchorRef.current?.getRect() ?? new DOMRect(), + }); const canExecute = (commandId: EditorCommandId) => isCommandEnabled(commandId, commandState); - useEffect(() => { - if (!isOpen) { - return undefined; - } - - const handleScroll = () => { - if (!hasHeldFocusRef.current) { - onClose(); - } - }; - - document.addEventListener("scroll", handleScroll, true); - return () => { - document.removeEventListener("scroll", handleScroll, true); - }; - }, [onClose, isOpen]); + // Layout is early enough: Radix registers the anchor from a passive effect, and Floating UI + // measures later still. + useLayoutEffect(() => { + anchorRef.current = request?.anchor ?? null; + }, [request]); // Only for a keyboard request landing on an already open popup. A fresh open cannot be served // here, because the content ref fills a microtask after this runs. @@ -237,21 +243,9 @@ export function EditorContextPopup({ return null; } - const { anchor } = request; - return ( !nextIsOpen && onClose()}> - - - + top: 30 + pos, })); +const popupRequest = (source: ContextPopupSource) => ({ + anchor: expect.objectContaining({ getRect: expect.any(Function) }), + source, +}); + +const collectRequests = () => { + const requests: ContextPopupRequest[] = []; + + return { + onContextPopupRequested: vi.fn((request: ContextPopupRequest) => { + requests.push(request); + }), + lastRequest: () => requests[requests.length - 1], + }; +}; + describe("context popup plugin", () => { it("opens below the selected visual range without exposing markers through selection state", async () => { const onContextPopupRequested = vi.fn(); @@ -26,15 +43,54 @@ describe("context popup plugin", () => { dispatchMouseUp(mounted.view.dom, { button: 0 }); await waitFor(() => { - expect(onContextPopupRequested).toHaveBeenCalledWith({ - anchor: { x: 19, top: 31, bottom: 46 }, - source: "pointer", - }); + expect(onContextPopupRequested).toHaveBeenCalledWith(popupRequest("pointer")); }); expect(coordsAtPos).toHaveBeenNthCalledWith(1, 1, 1); expect(coordsAtPos).toHaveBeenNthCalledWith(2, 6, -1); }); + it("anchors to the box spanning the selection's visible ends", async () => { + const { onContextPopupRequested, lastRequest } = collectRequests(); + const mounted = await mountEditor(HELLO_WORLD_TEXT, { onContextPopupRequested }); + + mockCoordinates(mounted); + setTextSelection(mounted.view, 1, 6); + runKeyDownHandlers(mounted.view, "ContextMenu"); + + expect(lastRequest().anchor.getRect()).toMatchObject({ + left: 11, + top: 31, + right: 26, + bottom: 46, + }); + }); + + it("measures the selection as it stands rather than as it stood when the popup opened", async () => { + const { onContextPopupRequested, lastRequest } = collectRequests(); + const mounted = await mountEditor(HELLO_WORLD_TEXT, { onContextPopupRequested }); + + mockCoordinates(mounted); + setTextSelection(mounted.view, 1, 6); + runKeyDownHandlers(mounted.view, "ContextMenu"); + + const { anchor } = lastRequest(); + + setTextSelection(mounted.view, 3, 8); + + expect(anchor.getRect()).toMatchObject({ top: 33, bottom: 48 }); + }); + + it("anchors against the editor it belongs to", async () => { + const { onContextPopupRequested, lastRequest } = collectRequests(); + const mounted = await mountEditor(HELLO_WORLD_TEXT, { onContextPopupRequested }); + + mockCoordinates(mounted); + setTextSelection(mounted.view, 1, 6); + runKeyDownHandlers(mounted.view, "ContextMenu"); + + expect(lastRequest().anchor.contextElement).toBe(mounted.view.dom); + }); + it("opens from the existing selection instead of the right-click pointer coordinates", async () => { const onContextPopupRequested = vi.fn(); const mounted = await mountEditor(HELLO_WORLD_TEXT, { onContextPopupRequested }); @@ -44,10 +100,7 @@ describe("context popup plugin", () => { setTextSelection(mounted.view, 1, 6); dispatchContextMenu(mounted.view.dom, { clientX: 80, clientY: 42 }); - expect(onContextPopupRequested).toHaveBeenCalledWith({ - anchor: { x: 19, top: 31, bottom: 46 }, - source: "pointer", - }); + expect(onContextPopupRequested).toHaveBeenCalledWith(popupRequest("pointer")); expect(posAtCoords).not.toHaveBeenCalled(); expect(mounted.view.state.selection.empty).toBe(false); expect(mounted.view.state.selection.from).toBe(1); @@ -62,10 +115,7 @@ describe("context popup plugin", () => { setTextSelection(mounted.view, 8); dispatchContextMenu(mounted.view.dom, { clientX: 80, clientY: 42 }); - expect(onContextPopupRequested).toHaveBeenCalledWith({ - anchor: { x: 23, top: 38, bottom: 48 }, - source: "pointer", - }); + expect(onContextPopupRequested).toHaveBeenCalledWith(popupRequest("pointer")); expect(mounted.view.state.selection.empty).toBe(true); expect(mounted.view.state.selection.from).toBe(8); }); @@ -83,10 +133,7 @@ describe("context popup plugin", () => { expect(handled).toBe(true); expect(event.defaultPrevented).toBe(true); - expect(onContextPopupRequested).toHaveBeenCalledWith({ - anchor: { x: 19, top: 31, bottom: 46 }, - source: "keyboard", - }); + expect(onContextPopupRequested).toHaveBeenCalledWith(popupRequest("keyboard")); }); it("opens from the keyboard around a caret with no selection", async () => { @@ -97,10 +144,7 @@ describe("context popup plugin", () => { setTextSelection(mounted.view, 8); runKeyDownHandlers(mounted.view, "ContextMenu"); - expect(onContextPopupRequested).toHaveBeenCalledWith({ - anchor: { x: 23, top: 38, bottom: 48 }, - source: "keyboard", - }); + expect(onContextPopupRequested).toHaveBeenCalledWith(popupRequest("keyboard")); }); it("leaves an unmodified F10 to the rest of the editor", async () => { @@ -168,10 +212,7 @@ describe("context popup plugin", () => { popupOpen = true; setTextSelection(mounted.view, 1, 6); - expect(onContextPopupRequested).toHaveBeenCalledWith({ - anchor: { x: 19, top: 31, bottom: 46 }, - source: "pointer", - }); + expect(onContextPopupRequested).toHaveBeenCalledWith(popupRequest("pointer")); expect(onContextPopupClosed).not.toHaveBeenCalled(); setTextSelection(mounted.view, 3); @@ -232,9 +273,6 @@ describe("context popup plugin", () => { runKeyDownHandlers(mounted.view, "ContextMenu"); setTextSelection(mounted.view, 2, 7); - expect(onContextPopupRequested).toHaveBeenLastCalledWith({ - anchor: { x: 20, top: 32, bottom: 47 }, - source: "keyboard", - }); + expect(onContextPopupRequested).toHaveBeenLastCalledWith(popupRequest("keyboard")); }); }); diff --git a/src/features/editor/plugins/contextPopup.ts b/src/features/editor/plugins/contextPopup.ts index 403ee64..2d96884 100644 --- a/src/features/editor/plugins/contextPopup.ts +++ b/src/features/editor/plugins/contextPopup.ts @@ -2,13 +2,13 @@ import { Plugin, PluginKey } from "@milkdown/kit/prose/state"; import type { EditorView } from "@milkdown/kit/prose/view"; import { $prose } from "@milkdown/kit/utils"; -export const leafdownContextPopupPluginKey = new PluginKey("leafdownContextPopup"); +import { + canMeasureSelection, + createContextPopupAnchor, + type ContextPopupAnchor, +} from "../utils/contextPopupAnchor"; -export interface ContextPopupAnchor { - x: number; - top: number; - bottom: number; -} +export const leafdownContextPopupPluginKey = new PluginKey("leafdownContextPopup"); export type ContextPopupSource = "keyboard" | "pointer"; @@ -23,23 +23,6 @@ export interface LeafdownContextPopupPluginOptions { onRequest?: (request: ContextPopupRequest) => void; } -const getSelectionAnchor = (view: EditorView): ContextPopupAnchor | null => { - const { selection } = view.state; - - try { - const from = view.coordsAtPos(selection.from, 1); - const to = selection.empty ? from : view.coordsAtPos(selection.to, -1); - - return { - x: Math.round((from.left + to.right) / 2), - top: Math.round(from.top), - bottom: Math.round(to.bottom), - }; - } catch { - return null; - } -}; - const closePopup = ({ isOpen, onClose }: LeafdownContextPopupPluginOptions) => { if (!isOpen?.()) { return false; @@ -60,14 +43,15 @@ export const createLeafdownContextPopupPlugin = (options: LeafdownContextPopupPl $prose(() => { // Held so that refreshing an open popup's anchor cannot downgrade it to a pointer open. let openSource: ContextPopupSource = "pointer"; + // The anchor measures the live selection, so one per editor serves every request. + let anchor: ContextPopupAnchor | null = null; const requestSelectionPopup = (view: EditorView, source: ContextPopupSource) => { - const anchor = getSelectionAnchor(view); - - if (!anchor) { + if (!canMeasureSelection(view)) { return false; } + anchor ??= createContextPopupAnchor(view); openSource = source; options.onRequest?.({ anchor, source }); diff --git a/src/features/editor/utils/contextPopupAnchor.test.ts b/src/features/editor/utils/contextPopupAnchor.test.ts new file mode 100644 index 0000000..7dc6da4 --- /dev/null +++ b/src/features/editor/utils/contextPopupAnchor.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { resolveContextPopupAnchorRect } from "./contextPopupAnchor"; + +const createRect = (left: number, top: number, right: number, bottom: number): DOMRect => { + const rect = { + bottom, + height: bottom - top, + left, + right, + top, + width: right - left, + x: left, + y: top, + }; + + return { ...rect, toJSON: () => rect }; +}; + +// Tall enough that both ends of a selection can clear the popup. +const VIEWPORT = createRect(0, 100, 800, 1100); + +const resolve = (selection: DOMRect) => resolveContextPopupAnchorRect(selection, VIEWPORT); + +describe("resolveContextPopupAnchorRect", () => { + it("anchors to a fully visible selection unchanged", () => { + expect(resolve(createRect(120, 400, 260, 420))).toMatchObject({ + left: 120, + top: 400, + right: 260, + bottom: 420, + }); + }); + + it("trims the part of the selection above the viewport", () => { + expect(resolve(createRect(120, 40, 260, 500))).toMatchObject({ top: 100, bottom: 500 }); + }); + + it("trims the part of the selection below the viewport", () => { + expect(resolve(createRect(120, 600, 260, 4000))).toMatchObject({ top: 600, bottom: 1100 }); + }); + + it("collapses to the visible top edge when the selection leaves no room on either side", () => { + const anchor = resolve(createRect(120, -3000, 260, 6000)); + + expect(anchor).toMatchObject({ top: 100, bottom: 100, height: 0 }); + }); + + it("keeps a selection that clears the popup on one side only", () => { + expect(resolve(createRect(120, 700, 260, 6000))).toMatchObject({ top: 700, bottom: 1100 }); + }); + + it("collapses a selection ending too close to the viewport bottom to clear it", () => { + const anchor = resolve(createRect(120, 150, 260, 1000)); + + expect(anchor).toMatchObject({ top: 150, bottom: 150 }); + }); + + it("pins a selection scrolled off the top to the viewport's top edge", () => { + expect(resolve(createRect(120, -400, 260, -300))).toMatchObject({ top: 100, bottom: 100 }); + }); + + it("pins a selection scrolled off the bottom to the viewport's bottom edge", () => { + expect(resolve(createRect(120, 3000, 260, 3100))).toMatchObject({ top: 1100, bottom: 1100 }); + }); + + it("clamps a selection wider than the viewport to its horizontal bounds", () => { + expect(resolve(createRect(-50, 400, 900, 420))).toMatchObject({ left: 0, right: 800 }); + }); +}); diff --git a/src/features/editor/utils/contextPopupAnchor.ts b/src/features/editor/utils/contextPopupAnchor.ts new file mode 100644 index 0000000..2148744 --- /dev/null +++ b/src/features/editor/utils/contextPopupAnchor.ts @@ -0,0 +1,101 @@ +import type { EditorView } from "@milkdown/kit/prose/view"; + +// Roughly the popup's height. It only gates whether there is room beside the selection, so +// measuring the real thing would buy nothing. +const POPUP_CLEARANCE = 200; + +// Floating UI's own overflow test, so the popup is clamped to the element it listens on. +const OVERFLOW_PATTERN = /auto|scroll|overlay|hidden|clip/u; + +export interface ContextPopupAnchor { + contextElement: Element; + getRect: () => DOMRect; +} + +const createRect = (left: number, top: number, right: number, bottom: number): DOMRect => { + const rect = { + bottom, + height: bottom - top, + left, + right, + top, + width: right - left, + x: left, + y: top, + }; + + return { ...rect, toJSON: () => rect }; +}; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const getSelectionRect = (view: EditorView): DOMRect | null => { + const { selection } = view.state; + + try { + const from = view.coordsAtPos(selection.from, 1); + const to = selection.empty ? from : view.coordsAtPos(selection.to, -1); + + return createRect( + Math.min(from.left, to.left), + Math.min(from.top, to.top), + Math.max(from.right, to.right), + Math.max(from.bottom, to.bottom), + ); + } catch { + return null; + } +}; + +const findScrollViewport = (element: Element) => { + for (let current = element.parentElement; current; current = current.parentElement) { + const { display, overflow, overflowX, overflowY } = getComputedStyle(current); + + if ( + OVERFLOW_PATTERN.test(overflow + overflowY + overflowX) && + display !== "inline" && + display !== "contents" + ) { + return current; + } + } + + return null; +}; + +export const canMeasureSelection = (view: EditorView) => getSelectionRect(view) !== null; + +/** Resolves the rect the popup positions against, beside the visible part of the selection. */ +export const resolveContextPopupAnchorRect = (selection: DOMRect, viewport: DOMRect): DOMRect => { + const top = clamp(selection.top, viewport.top, viewport.bottom); + const left = clamp(selection.left, viewport.left, viewport.right); + const bottom = clamp(selection.bottom, top, viewport.bottom); + const right = clamp(selection.right, left, viewport.right); + + // Collision handling cannot rescue a selection that fills the visible area: both sides + // overflow, and Radix shifts along the alignment axis only. + if (viewport.bottom - bottom < POPUP_CLEARANCE && top - viewport.top < POPUP_CLEARANCE) { + return createRect(left, top, right, top); + } + + return createRect(left, top, right, bottom); +}; + +export const createContextPopupAnchor = (view: EditorView): ContextPopupAnchor => { + const scrollViewport = findScrollViewport(view.dom); + const getViewportRect = () => + scrollViewport + ? scrollViewport.getBoundingClientRect() + : createRect(0, 0, window.innerWidth, window.innerHeight); + + return { + contextElement: view.dom, + getRect: () => { + const selection = view.isDestroyed ? null : getSelectionRect(view); + + return selection + ? resolveContextPopupAnchorRect(selection, getViewportRect()) + : createRect(0, 0, 0, 0); + }, + }; +}; From 0740c736bd8e8693748804bd1a790dd3f0e895fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 2 Aug 2026 13:40:08 -0300 Subject: [PATCH 2/3] Hide the context popup while its selection is out of view Scrolling the selection away used to close the popup, discarding a command surface for a selection that is still live. The anchor now reports the selection where it actually is once none of it is visible, so Floating UI's referenceHidden hides the popup and returns it when the selection scrolls back; open state, focus, and the selection are all untouched. A popup the user is working in pins to a rect inside the viewport instead, so a scroll can neither hide it nor move it out from under a keyboard interaction. A keyboard popup pins from the moment it opens rather than from the focus it is about to take. happy-dom performs no layout, so the document element measured 0x0 and every reference read as fully clipped. The test setup now reports the window size there, which is what makes any of this assertable. --- CHANGELOG.md | 1 + docs/specification.md | 2 + .../components/EditorContextPopup.test.tsx | 133 +++++++++++++++++- .../editor/components/EditorContextPopup.tsx | 42 ++++-- .../editor/plugins/contextPopup.test.tsx | 4 +- .../editor/utils/contextPopupAnchor.test.ts | 39 ++++- .../editor/utils/contextPopupAnchor.ts | 30 +++- src/test/setup/dom.ts | 14 ++ 8 files changed, 239 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70dbcdb..009b1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Keep empty folders in the article navigator reachable instead of skipping them. - Open the editor context popup with `Shift+F10` or the `Menu` key and operate every command in it from the keyboard. - Keep the editor context popup beside the text it acts on while the document scrolls, and inside the editor for a selection taller than the visible area. +- Hide the editor context popup while its selection is scrolled out of view instead of closing it, and bring it back with the selection. - Announce the editor context popup as a named toolbar instead of an unnamed dialog. - Announce recent files and recent folders under their own headings in the `Open recent` menu. - Disable a submenu instead of opening it empty when every command inside it is unavailable. diff --git a/docs/specification.md b/docs/specification.md index adbca5d..8f52539 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -177,6 +177,8 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - `Tab`: Closes the popup as well, rather than moving to another control. - Closing a popup that holds focus returns focus to the editor with its selection intact, whichever path closed it. - The popup anchors to the part of its selection that is visible in the document surface and follows that text as the document scrolls. A selection taller than the visible area anchors the popup inside it rather than past its edge. Scrolling does not close the popup. +- While no part of the selection is visible the popup is hidden rather than closed, and it returns when the selection scrolls back into view. +- A popup opened from the keyboard, or holding focus for any other reason, stays visible and stays where it is. - Structural editing and native text gestures retain their normal editor behavior. Leafdown commands provide the same semantic operations across menus, keyboard shortcuts, and the context popup. - The app intercepts and disables default webview reload and navigation shortcuts, including `Mod+R` and `Mod+Shift+R`, to prevent accidental state resets. diff --git a/src/features/editor/components/EditorContextPopup.test.tsx b/src/features/editor/components/EditorContextPopup.test.tsx index a983332..77cbb6b 100644 --- a/src/features/editor/components/EditorContextPopup.test.tsx +++ b/src/features/editor/components/EditorContextPopup.test.tsx @@ -1,22 +1,26 @@ import { useState } from "react"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi, type Mock } from "vitest"; import { createActiveEditorCommandState, createEditorCommandState } from "@/test/factories/editor"; import { dispatchDOMEvent } from "@/test/utils/events"; import { render, renderWithUser, screen, waitFor } from "@/test/utils/react"; import type { ContextPopupRequest } from "../plugins/contextPopup"; +import type { ContextPopupAnchorMode } from "../utils/contextPopupAnchor"; import { EditorContextPopup } from "./EditorContextPopup"; const noop = () => {}; -const createAnchorRect = (): DOMRect => { - const rect = { bottom: 80, height: 20, left: 40, right: 41, top: 60, width: 1, x: 40, y: 60 }; +const createAnchorRect = (top = 60): DOMRect => { + const rect = { bottom: top + 20, height: 20, left: 40, right: 41, top, width: 1, x: 40, y: top }; return { ...rect, toJSON: () => rect }; }; -const ANCHOR = { contextElement: document.body, getRect: createAnchorRect }; +const popperWrapper = () => + document.querySelector("[data-radix-popper-content-wrapper]"); + +const ANCHOR = { contextElement: document.body, getRect: () => createAnchorRect() }; const POINTER_REQUEST: ContextPopupRequest = { anchor: ANCHOR, source: "pointer" }; const KEYBOARD_REQUEST: ContextPopupRequest = { anchor: ANCHOR, source: "keyboard" }; @@ -64,7 +68,7 @@ const enabledPopupCommandState = createActiveEditorCommandState({ describe("EditorContextPopup", () => { it("positions against the measured selection instead of a rendered anchor element", async () => { - const getRect = vi.fn(createAnchorRect); + const getRect = vi.fn((_mode: ContextPopupAnchorMode) => createAnchorRect()); render( { }); }); + describe("anchor mode", () => { + const renderWithSpiedAnchor = (source: ContextPopupRequest["source"]) => { + const getRect = vi.fn((_mode: ContextPopupAnchorMode) => createAnchorRect()); + const request = { anchor: { contextElement: document.body, getRect }, source }; + const view = render( + , + ); + + return { getRect, request, view }; + }; + + const modesUsed = (getRect: Mock<(mode: ContextPopupAnchorMode) => DOMRect>) => + new Set(getRect.mock.calls.map(([mode]) => mode)); + + it("follows the selection out of view for a popup focus is not in", async () => { + const { getRect } = renderWithSpiedAnchor("pointer"); + + await waitFor(() => { + expect(getRect).toHaveBeenCalled(); + }); + expect(modesUsed(getRect)).toEqual(new Set(["live"])); + }); + + it("pins a keyboard popup from the moment it opens", async () => { + const { getRect } = renderWithSpiedAnchor("keyboard"); + + await waitFor(() => { + expect(getRect).toHaveBeenCalled(); + }); + expect(modesUsed(getRect)).toEqual(new Set(["pinned"])); + }); + + it("holds one rect for as long as focus stays inside the popup", async () => { + const { getRect, request, view } = renderWithSpiedAnchor("keyboard"); + + await waitFor(() => { + expect(screen.getByLabelText("Cut")).toHaveFocus(); + }); + + // A fresh request would otherwise re-measure; a popup being worked in must not move. + view.rerender( + , + ); + + expect(modesUsed(getRect)).toEqual(new Set(["pinned"])); + expect(getRect).toHaveBeenCalledTimes(1); + }); + }); + + describe("visibility", () => { + it("hides while none of the selection is visible and returns when it scrolls back", async () => { + let rect = createAnchorRect(-5000); + const onClose = vi.fn(); + + render( + rect }, + source: "pointer", + }} + commandState={enabledPopupCommandState} + onClose={onClose} + onExecute={vi.fn()} + onReturnFocus={vi.fn()} + />, + ); + + await waitFor(() => { + expect(popperWrapper()).toHaveStyle({ visibility: "hidden" }); + }); + expect(screen.getByTestId("editor-context-popup")).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + + rect = createAnchorRect(); + dispatchDOMEvent(window, "resize"); + + await waitFor(() => { + expect(popperWrapper()).not.toHaveStyle({ visibility: "hidden" }); + }); + }); + + it("keeps a popup holding focus visible when its selection leaves the viewport", async () => { + render( + createAnchorRect(mode === "pinned" ? 0 : -5000), + }, + source: "keyboard", + }} + commandState={enabledPopupCommandState} + onClose={vi.fn()} + onExecute={vi.fn()} + onReturnFocus={vi.fn()} + />, + ); + + await waitFor(() => { + expect(screen.getByLabelText("Cut")).toHaveFocus(); + }); + expect(popperWrapper()).not.toHaveStyle({ visibility: "hidden" }); + }); + }); + describe("scroll", () => { it("stays open on a scroll while focus is still in the editor", () => { const onClose = vi.fn(); diff --git a/src/features/editor/components/EditorContextPopup.tsx b/src/features/editor/components/EditorContextPopup.tsx index d8f01cd..9e8a1ef 100644 --- a/src/features/editor/components/EditorContextPopup.tsx +++ b/src/features/editor/components/EditorContextPopup.tsx @@ -40,7 +40,6 @@ import { cn } from "@/lib/cn"; import type { EditorCommandId, EditorCommandState } from "../commands"; import { EDITOR_COMMAND_LABELS } from "../commands/metadata"; import type { ContextPopupRequest } from "../plugins/contextPopup"; -import type { ContextPopupAnchor } from "../utils/contextPopupAnchor"; interface ContextButtonCommand { commandId: EditorCommandId; @@ -173,21 +172,47 @@ export function EditorContextPopup({ const contentRef = useRef(null); // Sticky for one open popup, so that focus moving into a portalled submenu does not clear it. const hasHeldFocusRef = useRef(false); - const anchorRef = useRef(null); + const requestRef = useRef(null); + const pinnedRectRef = useRef(null); // Radix reads the virtual anchor on every render and re-registers it whenever its identity // changes, which would re-render this component in turn. It has to be created once. const virtualRef = useRef({ get contextElement() { - return anchorRef.current?.contextElement; + return requestRef.current?.anchor.contextElement; + }, + getBoundingClientRect: () => { + const currentRequest = requestRef.current; + + if (!currentRequest) { + return new DOMRect(); + } + + // A keyboard popup pins from the start rather than from the focus it is about to take, + // so it cannot hide in the moment between the two. + if (!hasHeldFocusRef.current && currentRequest.source !== "keyboard") { + return currentRequest.anchor.getRect("live"); + } + + pinnedRectRef.current ??= currentRequest.anchor.getRect("pinned"); + + return pinnedRectRef.current; }, - getBoundingClientRect: () => anchorRef.current?.getRect() ?? new DOMRect(), }); const canExecute = (commandId: EditorCommandId) => isCommandEnabled(commandId, commandState); + const releaseHeldFocus = () => { + hasHeldFocusRef.current = false; + pinnedRectRef.current = null; + }; // Layout is early enough: Radix registers the anchor from a passive effect, and Floating UI // measures later still. useLayoutEffect(() => { - anchorRef.current = request?.anchor ?? null; + requestRef.current = request; + + // A popup the user is working in keeps the rect it was pinned to. + if (!hasHeldFocusRef.current) { + pinnedRectRef.current = null; + } }, [request]); // Only for a keyboard request landing on an already open popup. A fresh open cannot be served @@ -251,13 +276,14 @@ export function EditorContextPopup({ asChild className="leafdown-context-popup w-auto gap-1 rounded-md p-1" data-testid="editor-context-popup" + hideWhenDetached onCloseAutoFocus={(event) => { // Radix restores focus to a trigger, and this popup only has an anchor, so its restore // is a no-op that leaves focus on the body. event.preventDefault(); if (hasHeldFocusRef.current) { - hasHeldFocusRef.current = false; + releaseHeldFocus(); onReturnFocus(); } }} @@ -267,12 +293,12 @@ export function EditorContextPopup({ onInteractOutside={() => { // Radix defers this dismissal past the click, so returning focus would take it back // from whatever was just clicked. - hasHeldFocusRef.current = false; + releaseHeldFocus(); }} onOpenAutoFocus={(event) => { // Radix would take focus on every open, including the pointer ones that must not. event.preventDefault(); - hasHeldFocusRef.current = false; + releaseHeldFocus(); if (source === "keyboard" && event.currentTarget instanceof HTMLElement) { focusFirstControl(event.currentTarget); diff --git a/src/features/editor/plugins/contextPopup.test.tsx b/src/features/editor/plugins/contextPopup.test.tsx index 207ef32..ee62256 100644 --- a/src/features/editor/plugins/contextPopup.test.tsx +++ b/src/features/editor/plugins/contextPopup.test.tsx @@ -57,7 +57,7 @@ describe("context popup plugin", () => { setTextSelection(mounted.view, 1, 6); runKeyDownHandlers(mounted.view, "ContextMenu"); - expect(lastRequest().anchor.getRect()).toMatchObject({ + expect(lastRequest().anchor.getRect("live")).toMatchObject({ left: 11, top: 31, right: 26, @@ -77,7 +77,7 @@ describe("context popup plugin", () => { setTextSelection(mounted.view, 3, 8); - expect(anchor.getRect()).toMatchObject({ top: 33, bottom: 48 }); + expect(anchor.getRect("live")).toMatchObject({ top: 33, bottom: 48 }); }); it("anchors against the editor it belongs to", async () => { diff --git a/src/features/editor/utils/contextPopupAnchor.test.ts b/src/features/editor/utils/contextPopupAnchor.test.ts index 7dc6da4..2c0b56f 100644 --- a/src/features/editor/utils/contextPopupAnchor.test.ts +++ b/src/features/editor/utils/contextPopupAnchor.test.ts @@ -20,7 +20,10 @@ const createRect = (left: number, top: number, right: number, bottom: number): D // Tall enough that both ends of a selection can clear the popup. const VIEWPORT = createRect(0, 100, 800, 1100); -const resolve = (selection: DOMRect) => resolveContextPopupAnchorRect(selection, VIEWPORT); +const resolve = (selection: DOMRect) => resolveContextPopupAnchorRect(selection, VIEWPORT, "live"); + +const resolvePinned = (selection: DOMRect) => + resolveContextPopupAnchorRect(selection, VIEWPORT, "pinned"); describe("resolveContextPopupAnchorRect", () => { it("anchors to a fully visible selection unchanged", () => { @@ -56,15 +59,37 @@ describe("resolveContextPopupAnchorRect", () => { expect(anchor).toMatchObject({ top: 150, bottom: 150 }); }); - it("pins a selection scrolled off the top to the viewport's top edge", () => { - expect(resolve(createRect(120, -400, 260, -300))).toMatchObject({ top: 100, bottom: 100 }); + it("clamps a selection wider than the viewport to its horizontal bounds", () => { + expect(resolve(createRect(-50, 400, 900, 420))).toMatchObject({ left: 0, right: 800 }); }); - it("pins a selection scrolled off the bottom to the viewport's bottom edge", () => { - expect(resolve(createRect(120, 3000, 260, 3100))).toMatchObject({ top: 1100, bottom: 1100 }); + describe("a selection with no visible part", () => { + it.each([ + ["above", createRect(120, -400, 260, -300)], + ["below", createRect(120, 3000, 260, 3100)], + ])("reports a live anchor beyond the viewport when the selection is %s it", (_where, off) => { + expect(resolve(off)).toBe(off); + }); + + it("keeps a pinned anchor at the viewport's top edge for a selection above it", () => { + expect(resolvePinned(createRect(120, -400, 260, -300))).toMatchObject({ + top: 100, + bottom: 100, + }); + }); + + it("keeps a pinned anchor at the viewport's bottom edge for a selection below it", () => { + expect(resolvePinned(createRect(120, 3000, 260, 3100))).toMatchObject({ + top: 1100, + bottom: 1100, + }); + }); }); - it("clamps a selection wider than the viewport to its horizontal bounds", () => { - expect(resolve(createRect(-50, 400, 900, 420))).toMatchObject({ left: 0, right: 800 }); + it("clamps a pinned anchor for a selection that is still partly visible", () => { + expect(resolvePinned(createRect(120, 600, 260, 4000))).toMatchObject({ + top: 600, + bottom: 1100, + }); }); }); diff --git a/src/features/editor/utils/contextPopupAnchor.ts b/src/features/editor/utils/contextPopupAnchor.ts index 2148744..aeacb96 100644 --- a/src/features/editor/utils/contextPopupAnchor.ts +++ b/src/features/editor/utils/contextPopupAnchor.ts @@ -7,9 +7,15 @@ const POPUP_CLEARANCE = 200; // Floating UI's own overflow test, so the popup is clamped to the element it listens on. const OVERFLOW_PATTERN = /auto|scroll|overlay|hidden|clip/u; +/** + * `live` leaves the viewport with the selection, so the popup hides once none of it is visible. + * `pinned` never leaves it, so a popup the user is working in stays visible and still. + */ +export type ContextPopupAnchorMode = "live" | "pinned"; + export interface ContextPopupAnchor { contextElement: Element; - getRect: () => DOMRect; + getRect: (mode: ContextPopupAnchorMode) => DOMRect; } const createRect = (left: number, top: number, right: number, bottom: number): DOMRect => { @@ -65,8 +71,24 @@ const findScrollViewport = (element: Element) => { export const canMeasureSelection = (view: EditorView) => getSelectionRect(view) !== null; +const isOutsideViewport = (selection: DOMRect, viewport: DOMRect) => + selection.bottom < viewport.top || + selection.top > viewport.bottom || + selection.right < viewport.left || + selection.left > viewport.right; + /** Resolves the rect the popup positions against, beside the visible part of the selection. */ -export const resolveContextPopupAnchorRect = (selection: DOMRect, viewport: DOMRect): DOMRect => { +export const resolveContextPopupAnchorRect = ( + selection: DOMRect, + viewport: DOMRect, + mode: ContextPopupAnchorMode, +): DOMRect => { + // Floating UI calls a reference hidden only once it is fully clipped, which a clamped rect + // never is, so an invisible selection has to be reported where it actually is. + if (mode === "live" && isOutsideViewport(selection, viewport)) { + return selection; + } + const top = clamp(selection.top, viewport.top, viewport.bottom); const left = clamp(selection.left, viewport.left, viewport.right); const bottom = clamp(selection.bottom, top, viewport.bottom); @@ -90,11 +112,11 @@ export const createContextPopupAnchor = (view: EditorView): ContextPopupAnchor = return { contextElement: view.dom, - getRect: () => { + getRect: (mode) => { const selection = view.isDestroyed ? null : getSelectionRect(view); return selection - ? resolveContextPopupAnchorRect(selection, getViewportRect()) + ? resolveContextPopupAnchorRect(selection, getViewportRect(), mode) : createRect(0, 0, 0, 0); }, }; diff --git a/src/test/setup/dom.ts b/src/test/setup/dom.ts index d9bd346..cd645e6 100644 --- a/src/test/setup/dom.ts +++ b/src/test/setup/dom.ts @@ -32,3 +32,17 @@ if (typeof Text !== "undefined") { textPrototype.getClientRects ??= createTestDomRectList; } + +// happy-dom performs no layout, so the document element measures 0x0 and anything clipping +// against the viewport reads every element as fully off screen. A browser reports the layout +// viewport here. +if (typeof document !== "undefined") { + Object.defineProperty(document.documentElement, "clientWidth", { + configurable: true, + get: () => window.innerWidth, + }); + Object.defineProperty(document.documentElement, "clientHeight", { + configurable: true, + get: () => window.innerHeight, + }); +} From ef32406cf3747c2715fde7396e611ed69f4356e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Sun, 2 Aug 2026 14:35:29 -0300 Subject: [PATCH 3/3] Place the popup inside a selection it cannot sit beside A selection taller than the visible area has no outside within reach, and one that fills the visible area leaves no room on either side. Both now anchor the popup to the first line of the selection the reader can see, so it lands inside the selection rather than below its last visible line, above its first, or past an edge that collision handling only pushes it further out of. The anchor also keeps a pixel of height. Floating UI reads a rect of no height resting on the clipping edge as fully clipped, so a selection spanning the whole visible area was hiding the popup outright, and a pinned anchor for a selection scrolled out of view would have hidden it too. --- CHANGELOG.md | 2 +- docs/specification.md | 3 +- .../editor/utils/contextPopupAnchor.test.ts | 45 +++++++++---------- .../editor/utils/contextPopupAnchor.ts | 32 ++++++++++--- 4 files changed, 51 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 009b1ae..3269fb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - Announce the article navigator as a tree, with the nesting depth, sibling position, and expanded state of every row. - Keep empty folders in the article navigator reachable instead of skipping them. - Open the editor context popup with `Shift+F10` or the `Menu` key and operate every command in it from the keyboard. -- Keep the editor context popup beside the text it acts on while the document scrolls, and inside the editor for a selection taller than the visible area. +- Keep the editor context popup beside the text it acts on while the document scrolls, and inside a selection too tall to sit beside. - Hide the editor context popup while its selection is scrolled out of view instead of closing it, and bring it back with the selection. - Announce the editor context popup as a named toolbar instead of an unnamed dialog. - Announce recent files and recent folders under their own headings in the `Open recent` menu. diff --git a/docs/specification.md b/docs/specification.md index 8f52539..98378e2 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -176,7 +176,8 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - `Escape`: Closes the popup, or an open submenu first, returning focus to the command that opened it. - `Tab`: Closes the popup as well, rather than moving to another control. - Closing a popup that holds focus returns focus to the editor with its selection intact, whichever path closed it. -- The popup anchors to the part of its selection that is visible in the document surface and follows that text as the document scrolls. A selection taller than the visible area anchors the popup inside it rather than past its edge. Scrolling does not close the popup. +- The popup anchors to the part of its selection that is visible in the document surface and follows that text as the document scrolls. Scrolling does not close the popup. +- A selection taller than the visible area, or one that fills it, has no room beside it, so the popup sits inside the selection at its first visible line. - While no part of the selection is visible the popup is hidden rather than closed, and it returns when the selection scrolls back into view. - A popup opened from the keyboard, or holding focus for any other reason, stays visible and stays where it is. - Structural editing and native text gestures retain their normal editor behavior. Leafdown commands provide the same semantic operations across menus, keyboard shortcuts, and the context popup. diff --git a/src/features/editor/utils/contextPopupAnchor.test.ts b/src/features/editor/utils/contextPopupAnchor.test.ts index 2c0b56f..870ecfa 100644 --- a/src/features/editor/utils/contextPopupAnchor.test.ts +++ b/src/features/editor/utils/contextPopupAnchor.test.ts @@ -35,32 +35,31 @@ describe("resolveContextPopupAnchorRect", () => { }); }); - it("trims the part of the selection above the viewport", () => { + it("trims the part of a selection that runs above the viewport", () => { expect(resolve(createRect(120, 40, 260, 500))).toMatchObject({ top: 100, bottom: 500 }); }); - it("trims the part of the selection below the viewport", () => { - expect(resolve(createRect(120, 600, 260, 4000))).toMatchObject({ top: 600, bottom: 1100 }); - }); - - it("collapses to the visible top edge when the selection leaves no room on either side", () => { - const anchor = resolve(createRect(120, -3000, 260, 6000)); - - expect(anchor).toMatchObject({ top: 100, bottom: 100, height: 0 }); + it("clamps a selection wider than the viewport to its horizontal bounds", () => { + expect(resolve(createRect(-50, 400, 900, 420))).toMatchObject({ left: 0, right: 800 }); }); - it("keeps a selection that clears the popup on one side only", () => { - expect(resolve(createRect(120, 700, 260, 6000))).toMatchObject({ top: 700, bottom: 1100 }); + it("anchors above a selection that leaves room only there", () => { + // Runs off the bottom, but starts far enough down that the popup still fits above it. + expect(resolve(createRect(120, 700, 260, 1300))).toMatchObject({ top: 700, bottom: 1100 }); }); - it("collapses a selection ending too close to the viewport bottom to clear it", () => { - const anchor = resolve(createRect(120, 150, 260, 1000)); - - expect(anchor).toMatchObject({ top: 150, bottom: 150 }); - }); + describe("a selection the popup cannot sit beside", () => { + it.each([ + ["spans the whole viewport", createRect(120, -3000, 260, 6000), 100], + ["starts above it and ends inside it", createRect(120, -3000, 260, 500), 100], + ["starts inside it and runs past the bottom", createRect(120, 300, 260, 4000), 300], + ])("anchors inside the visible selection when it %s", (_case, selection, expectedTop) => { + expect(resolve(selection)).toMatchObject({ top: expectedTop, bottom: expectedTop + 1 }); + }); - it("clamps a selection wider than the viewport to its horizontal bounds", () => { - expect(resolve(createRect(-50, 400, 900, 420))).toMatchObject({ left: 0, right: 800 }); + it("anchors inside a fully visible selection that fills the viewport", () => { + expect(resolve(createRect(120, 150, 260, 1000))).toMatchObject({ top: 150, bottom: 151 }); + }); }); describe("a selection with no visible part", () => { @@ -71,23 +70,23 @@ describe("resolveContextPopupAnchorRect", () => { expect(resolve(off)).toBe(off); }); - it("keeps a pinned anchor at the viewport's top edge for a selection above it", () => { + it("keeps a pinned anchor measurable at the viewport's top edge", () => { expect(resolvePinned(createRect(120, -400, 260, -300))).toMatchObject({ top: 100, - bottom: 100, + bottom: 101, }); }); - it("keeps a pinned anchor at the viewport's bottom edge for a selection below it", () => { + it("keeps a pinned anchor measurable at the viewport's bottom edge", () => { expect(resolvePinned(createRect(120, 3000, 260, 3100))).toMatchObject({ - top: 1100, + top: 1099, bottom: 1100, }); }); }); it("clamps a pinned anchor for a selection that is still partly visible", () => { - expect(resolvePinned(createRect(120, 600, 260, 4000))).toMatchObject({ + expect(resolvePinned(createRect(120, 600, 260, 1300))).toMatchObject({ top: 600, bottom: 1100, }); diff --git a/src/features/editor/utils/contextPopupAnchor.ts b/src/features/editor/utils/contextPopupAnchor.ts index aeacb96..f90b6b0 100644 --- a/src/features/editor/utils/contextPopupAnchor.ts +++ b/src/features/editor/utils/contextPopupAnchor.ts @@ -4,6 +4,10 @@ import type { EditorView } from "@milkdown/kit/prose/view"; // measuring the real thing would buy nothing. const POPUP_CLEARANCE = 200; +// Floating UI reads a rect of no height resting on the clipping edge as fully clipped, which +// would hide a popup meant to be visible. +const MINIMUM_ANCHOR_HEIGHT = 1; + // Floating UI's own overflow test, so the popup is clamped to the element it listens on. const OVERFLOW_PATTERN = /auto|scroll|overlay|hidden|clip/u; @@ -77,7 +81,22 @@ const isOutsideViewport = (selection: DOMRect, viewport: DOMRect) => selection.right < viewport.left || selection.left > viewport.right; -/** Resolves the rect the popup positions against, beside the visible part of the selection. */ +const hasRoomBeside = (top: number, bottom: number, viewport: DOMRect) => + viewport.bottom - bottom >= POPUP_CLEARANCE || top - viewport.top >= POPUP_CLEARANCE; + +const withMinimumHeight = ( + left: number, + top: number, + right: number, + bottom: number, + viewport: DOMRect, +): DOMRect => { + const anchorTop = Math.min(top, viewport.bottom - MINIMUM_ANCHOR_HEIGHT); + + return createRect(left, anchorTop, right, Math.max(bottom, anchorTop + MINIMUM_ANCHOR_HEIGHT)); +}; + +/** Resolves the rect the popup positions against: beside the visible selection, or inside it. */ export const resolveContextPopupAnchorRect = ( selection: DOMRect, viewport: DOMRect, @@ -94,13 +113,14 @@ export const resolveContextPopupAnchorRect = ( const bottom = clamp(selection.bottom, top, viewport.bottom); const right = clamp(selection.right, left, viewport.right); - // Collision handling cannot rescue a selection that fills the visible area: both sides - // overflow, and Radix shifts along the alignment axis only. - if (viewport.bottom - bottom < POPUP_CLEARANCE && top - viewport.top < POPUP_CLEARANCE) { - return createRect(left, top, right, top); + // A selection taller than the visible area has no outside within reach, and one that fills the + // area leaves no room either side. Both anchor to its first visible line, which puts the popup + // inside the selection; collision handling would only push it further out. + if (selection.height > viewport.height || !hasRoomBeside(top, bottom, viewport)) { + return withMinimumHeight(left, top, right, top, viewport); } - return createRect(left, top, right, bottom); + return withMinimumHeight(left, top, right, bottom, viewport); }; export const createContextPopupAnchor = (view: EditorView): ContextPopupAnchor => {