Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0
- Open the editor context popup with `Shift+F10` or the `Menu` key and operate every command in it from the keyboard.
- Open the editor context popup from a selection made with the keyboard, `Select all` included, instead of only from a pointer selection.
- Keep the editor context popup beside the text it acts on while the document scrolls, and inside a selection too tall to sit beside.
- Move the editor context popup onto the selection as it changes, instead of leaving it where it opened.
- 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,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.
- 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.
- The popup anchors to the part of its selection that is visible in the document surface and follows that text as the selection changes and 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.
Expand Down
72 changes: 68 additions & 4 deletions src/features/editor/components/EditorContextPopup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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 { act, render, renderWithUser, screen, waitFor } from "@/test/utils/react";

import type { ContextPopupRequest } from "../plugins/contextPopup";
import type { ContextPopupAnchorMode } from "../utils/contextPopupAnchor";
Expand All @@ -20,6 +20,27 @@ const createAnchorRect = (top = 60): DOMRect => {
const popperWrapper = () =>
document.querySelector<HTMLElement>("[data-radix-popper-content-wrapper]");

// Radix parks the wrapper at a percentage translate until Floating UI has placed it, so a pixel
// offset is also the signal that a placement has happened.
const wrapperTranslateY = () => {
const transform = popperWrapper()?.style.transform ?? "";
const placed = /translate\([^,]+,\s*(-?[\d.]+)px\)/u.exec(transform);

if (!placed) {
throw new Error(`Expected a placed popper wrapper, got: ${transform || "no transform"}`);
}

return Number(placed[1]);
};

const flushPlacement = () =>
act(
() =>
new Promise<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
}),
);

const ANCHOR = { contextElement: document.body, getRect: () => createAnchorRect() };
const POINTER_REQUEST: ContextPopupRequest = { anchor: ANCHOR, source: "pointer" };
const KEYBOARD_REQUEST: ContextPopupRequest = { anchor: ANCHOR, source: "keyboard" };
Expand Down Expand Up @@ -555,8 +576,11 @@ describe("EditorContextPopup", () => {
});

describe("anchor mode", () => {
const renderWithSpiedAnchor = (source: ContextPopupRequest["source"]) => {
const getRect = vi.fn((_mode: ContextPopupAnchorMode) => createAnchorRect());
const renderWithSpiedAnchor = (
source: ContextPopupRequest["source"],
measure: () => DOMRect = () => createAnchorRect(),
) => {
const getRect = vi.fn((_mode: ContextPopupAnchorMode) => measure());
const request = { anchor: { contextElement: document.body, getRect }, source };
const view = render(
<EditorContextPopup
Expand Down Expand Up @@ -592,13 +616,51 @@ describe("EditorContextPopup", () => {
expect(modesUsed(getRect)).toEqual(new Set(["pinned"]));
});

it("moves onto the selection when a fresh request measures it elsewhere", async () => {
const openedAt = 60;
const movedTo = 400;
let rect = createAnchorRect(openedAt);
const request: ContextPopupRequest = {
anchor: { contextElement: document.body, getRect: () => rect },
source: "pointer",
};
const renderPopup = () => (
<EditorContextPopup
request={{ ...request }}
commandState={enabledPopupCommandState}
onClose={vi.fn()}
onExecute={vi.fn()}
onReturnFocus={vi.fn()}
/>
);
const view = render(renderPopup());

await waitFor(() => {
expect(wrapperTranslateY()).toEqual(expect.any(Number));
});

const placedAt = wrapperTranslateY();

rect = createAnchorRect(movedTo);
view.rerender(renderPopup());

await waitFor(() => {
expect(wrapperTranslateY()).toBe(placedAt + movedTo - openedAt);
});
});

it("holds one rect for as long as focus stays inside the popup", async () => {
const { getRect, request, view } = renderWithSpiedAnchor("keyboard");
let rect = createAnchorRect(60);
const { getRect, request, view } = renderWithSpiedAnchor("keyboard", () => rect);

await waitFor(() => {
expect(screen.getByLabelText("Cut")).toHaveFocus();
});

const placedAt = wrapperTranslateY();

rect = createAnchorRect(400);

// A fresh request would otherwise re-measure; a popup being worked in must not move.
view.rerender(
<EditorContextPopup
Expand All @@ -609,9 +671,11 @@ describe("EditorContextPopup", () => {
onReturnFocus={vi.fn()}
/>,
);
await flushPlacement();

expect(modesUsed(getRect)).toEqual(new Set(["pinned"]));
expect(getRect).toHaveBeenCalledTimes(1);
expect(wrapperTranslateY()).toBe(placedAt);
});
});

Expand Down
56 changes: 28 additions & 28 deletions src/features/editor/components/EditorContextPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
Trash2Icon,
type LucideIcon,
} from "lucide-react";
import { useEffect, useLayoutEffect, useRef, type KeyboardEvent } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, type KeyboardEvent } from "react";

import { Button } from "@/components/ui/Button";
import {
Expand Down Expand Up @@ -172,32 +172,34 @@ export function EditorContextPopup({
const contentRef = useRef<HTMLDivElement>(null);
// Sticky for one open popup, so that focus moving into a portalled submenu does not clear it.
const hasHeldFocusRef = useRef(false);
const requestRef = useRef<ContextPopupRequest | null>(null);
const pinnedRectRef = useRef<DOMRect | null>(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<VirtualAnchor>({
get 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;
},
});
// Radix registers the anchor once per object identity and Floating UI measures only when it
// does, so scroll and resize aside, a fresh identity is the one thing that moves the popup
// onto a selection that has changed. Held against the render rather than created during one,
// or every unrelated render would re-register it.
const virtualRef = useMemo<{ current: VirtualAnchor }>(
() => ({
current: {
contextElement: request?.anchor.contextElement,
getBoundingClientRect: () => {
if (!request) {
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 && request.source !== "keyboard") {
return request.anchor.getRect("live");
}

pinnedRectRef.current ??= request.anchor.getRect("pinned");

return pinnedRectRef.current;
},
},
}),
[request],
);
const canExecute = (commandId: EditorCommandId) => isCommandEnabled(commandId, commandState);
const releaseHeldFocus = () => {
hasHeldFocusRef.current = false;
Expand All @@ -207,8 +209,6 @@ export function EditorContextPopup({
// 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;
Expand Down