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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
151 changes: 142 additions & 9 deletions src/features/editor/components/EditorContextPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>("[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" };

Expand Down Expand Up @@ -57,20 +67,23 @@ 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(
<EditorContextPopup
request={POINTER_REQUEST}
request={{ anchor: { contextElement: document.body, getRect }, source: "pointer" }}
commandState={enabledPopupCommandState}
onClose={vi.fn()}
onExecute={vi.fn()}
onReturnFocus={vi.fn()}
/>,
);

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", () => {
Expand Down Expand Up @@ -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(
<EditorContextPopup
request={request}
commandState={enabledPopupCommandState}
onClose={vi.fn()}
onExecute={vi.fn()}
onReturnFocus={vi.fn()}
/>,
);

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(
<EditorContextPopup
request={{ ...request, source: "pointer" }}
commandState={enabledPopupCommandState}
onClose={vi.fn()}
onExecute={vi.fn()}
onReturnFocus={vi.fn()}
/>,
);

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(
<EditorContextPopup
request={{
anchor: { contextElement: document.body, getRect: () => 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(
<EditorContextPopup
request={{
anchor: {
contextElement: document.body,
// Mirrors the resolver: a pinned anchor stays inside the viewport, a live one
// follows the selection out of it.
getRect: (mode) => 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(
Expand All @@ -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 () => {
Expand Down
84 changes: 52 additions & 32 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, useRef, type KeyboardEvent } from "react";
import { useEffect, useLayoutEffect, useRef, type KeyboardEvent } from "react";

import { Button } from "@/components/ui/Button";
import {
Expand Down Expand Up @@ -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;
Expand All @@ -165,24 +172,48 @@ 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 canExecute = (commandId: EditorCommandId) => isCommandEnabled(commandId, commandState);

useEffect(() => {
if (!isOpen) {
return undefined;
}
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();
}

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.
Expand Down Expand Up @@ -237,33 +268,22 @@ export function EditorContextPopup({
return null;
}

const { anchor } = request;

return (
<Popover open={isOpen} onOpenChange={(nextIsOpen) => !nextIsOpen && onClose()}>
<PopoverAnchor asChild>
<span
aria-hidden
className="pointer-events-none fixed w-px"
style={{
left: anchor.x,
top: anchor.top,
height: Math.max(1, anchor.bottom - anchor.top),
}}
/>
</PopoverAnchor>
<PopoverAnchor virtualRef={virtualRef} />
<PopoverContent
align="center"
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();
}
}}
Expand All @@ -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);
Expand Down
7 changes: 2 additions & 5 deletions src/features/editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading