diff --git a/CHANGELOG.md b/CHANGELOG.md index dee8fb7..3269fb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ 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 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. - 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..98378e2 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -176,7 +176,10 @@ 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. 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. - 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..77cbb6b 100644 --- a/src/features/editor/components/EditorContextPopup.test.tsx +++ b/src/features/editor/components/EditorContextPopup.test.tsx @@ -1,16 +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 ANCHOR = { x: 40, top: 60, bottom: 80 }; +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 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" }; @@ -57,10 +67,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((_mode: ContextPopupAnchorMode) => 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", () => { @@ -541,8 +554,127 @@ describe("EditorContextPopup", () => { }); }); + 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("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 +689,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..9e8a1ef 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 { @@ -145,6 +145,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 +172,48 @@ 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 canExecute = (commandId: EditorCommandId) => isCommandEnabled(commandId, commandState); - - useEffect(() => { - if (!isOpen) { - return undefined; - } + 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 requestRef.current?.anchor.contextElement; + }, + getBoundingClientRect: () => { + const currentRequest = requestRef.current; + + if (!currentRequest) { + return new DOMRect(); + } - const handleScroll = () => { - if (!hasHeldFocusRef.current) { - onClose(); + // 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"); } - }; - document.addEventListener("scroll", handleScroll, true); - return () => { - document.removeEventListener("scroll", handleScroll, true); - }; - }, [onClose, isOpen]); + pinnedRectRef.current ??= currentRequest.anchor.getRect("pinned"); + + return pinnedRectRef.current; + }, + }); + 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(() => { + 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 // here, because the content ref fills a microtask after this runs. @@ -237,33 +268,22 @@ export function EditorContextPopup({ return null; } - const { anchor } = request; - return ( !nextIsOpen && onClose()}> - - - + { // 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(); } }} @@ -273,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/index.ts b/src/features/editor/index.ts index 8d68ea9..640ebb5 100644 --- a/src/features/editor/index.ts +++ b/src/features/editor/index.ts @@ -14,11 +14,8 @@ export { type MilkdownEditorBridge, type MilkdownEditorProps, } from "./components/MilkdownEditor"; -export type { - ContextPopupAnchor, - ContextPopupRequest, - ContextPopupSource, -} from "./plugins/contextPopup"; +export type { ContextPopupRequest, ContextPopupSource } from "./plugins/contextPopup"; +export type { ContextPopupAnchor } from "./utils/contextPopupAnchor"; export { createMilkdownEditor, getMilkdownEditorMarkdown, diff --git a/src/features/editor/plugins/contextPopup.test.tsx b/src/features/editor/plugins/contextPopup.test.tsx index 1855dbd..ee62256 100644 --- a/src/features/editor/plugins/contextPopup.test.tsx +++ b/src/features/editor/plugins/contextPopup.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; +import type { ContextPopupRequest, ContextPopupSource } from "@/features/editor"; import { HELLO_WORLD_TEXT } from "@/test/fixtures/editorMarkdown"; import { dispatchContextMenu, dispatchMouseUp } from "@/test/utils/events"; import { setupMilkdownEditorMount, type MountedMilkdownEditor } from "@/test/utils/milkdown"; @@ -16,6 +17,22 @@ const mockCoordinates = (mounted: MountedMilkdownEditor) => 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("live")).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("live")).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..870ecfa --- /dev/null +++ b/src/features/editor/utils/contextPopupAnchor.test.ts @@ -0,0 +1,94 @@ +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, "live"); + +const resolvePinned = (selection: DOMRect) => + resolveContextPopupAnchorRect(selection, VIEWPORT, "pinned"); + +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 a selection that runs above the viewport", () => { + expect(resolve(createRect(120, 40, 260, 500))).toMatchObject({ top: 100, bottom: 500 }); + }); + + 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 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 }); + }); + + 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("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", () => { + 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 measurable at the viewport's top edge", () => { + expect(resolvePinned(createRect(120, -400, 260, -300))).toMatchObject({ + top: 100, + bottom: 101, + }); + }); + + it("keeps a pinned anchor measurable at the viewport's bottom edge", () => { + expect(resolvePinned(createRect(120, 3000, 260, 3100))).toMatchObject({ + top: 1099, + bottom: 1100, + }); + }); + }); + + it("clamps a pinned anchor for a selection that is still partly visible", () => { + 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 new file mode 100644 index 0000000..f90b6b0 --- /dev/null +++ b/src/features/editor/utils/contextPopupAnchor.ts @@ -0,0 +1,143 @@ +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 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; + +/** + * `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: (mode: ContextPopupAnchorMode) => 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; + +const isOutsideViewport = (selection: DOMRect, viewport: DOMRect) => + selection.bottom < viewport.top || + selection.top > viewport.bottom || + selection.right < viewport.left || + selection.left > viewport.right; + +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, + 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); + const right = clamp(selection.right, left, viewport.right); + + // 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 withMinimumHeight(left, top, right, bottom, viewport); +}; + +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: (mode) => { + const selection = view.isDestroyed ? null : getSelectionRect(view); + + return selection + ? 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, + }); +}