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 @@ -14,6 +14,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0
### Changed

- Sort article directories before articles in the article navigator.
- Ease the editor context popup between selection-driven positions while keeping scroll tracking immediate.
- Separate global application shortcuts, focused editor command shortcuts, and native text-input and clipboard gestures by ownership.
- Extend seamless in-document Markdown source projection to strikethrough, inline code, links, autolinks, and footnote references.
- Present active mixed-format link labels as one coordinated source range.
Expand Down
57 changes: 49 additions & 8 deletions src/features/editor/components/EditorContextPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import { StrictMode, useState } from "react";
import { describe, expect, it, vi, type Mock } from "vitest";

import { createActiveEditorCommandState, createEditorCommandState } from "@/test/factories/editor";
Expand All @@ -20,6 +20,14 @@ const createAnchorRect = (top = 60): DOMRect => {
const popperWrapper = () =>
document.querySelector<HTMLElement>("[data-radix-popper-content-wrapper]");

const expectRepositioning = (expected: boolean) => {
if (expected) {
expect(popperWrapper()).toHaveAttribute("data-leafdown-context-popup-repositioning", "");
} else {
expect(popperWrapper()).not.toHaveAttribute("data-leafdown-context-popup-repositioning");
}
};

// 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 = () => {
Expand Down Expand Up @@ -92,15 +100,18 @@ describe("EditorContextPopup", () => {
const getRect = vi.fn((_mode: ContextPopupAnchorMode) => createAnchorRect());

render(
<EditorContextPopup
request={{ anchor: { contextElement: document.body, getRect }, source: "pointer" }}
commandState={enabledPopupCommandState}
onClose={vi.fn()}
onExecute={vi.fn()}
onReturnFocus={vi.fn()}
/>,
<StrictMode>
<EditorContextPopup
request={{ anchor: { contextElement: document.body, getRect }, source: "pointer" }}
commandState={enabledPopupCommandState}
onClose={vi.fn()}
onExecute={vi.fn()}
onReturnFocus={vi.fn()}
/>
</StrictMode>,
);

expectRepositioning(false);
await waitFor(() => {
expect(getRect).toHaveBeenCalled();
});
Expand Down Expand Up @@ -643,6 +654,7 @@ describe("EditorContextPopup", () => {

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

await waitFor(() => {
expect(wrapperTranslateY()).toBe(placedAt + movedTo - openedAt);
Expand Down Expand Up @@ -676,6 +688,7 @@ describe("EditorContextPopup", () => {
expect(modesUsed(getRect)).toEqual(new Set(["pinned"]));
expect(getRect).toHaveBeenCalledTimes(1);
expect(wrapperTranslateY()).toBe(placedAt);
expectRepositioning(false);
});
});

Expand Down Expand Up @@ -738,6 +751,34 @@ describe("EditorContextPopup", () => {
});

describe("scroll", () => {
it("cancels selection easing before a scroll is positioned", async () => {
const request: ContextPopupRequest = {
anchor: ANCHOR,
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));
});

view.rerender(renderPopup());
expectRepositioning(true);

dispatchDOMEvent(document, "scroll");

expectRepositioning(false);
});

it("stays open on a scroll while focus is still in the editor", () => {
const onClose = vi.fn();

Expand Down
53 changes: 53 additions & 0 deletions src/features/editor/components/EditorContextPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ const isCommandEnabled = (commandId: EditorCommandId, commandState: EditorComman
commandState.status === "ready" && commandState.enabledCommands[commandId];

const CONTEXT_POPUP_LABEL = "Context actions";
const POPPER_WRAPPER_SELECTOR = "[data-radix-popper-content-wrapper]";
const REPOSITIONING_ATTRIBUTE = "data-leafdown-context-popup-repositioning";
const REPOSITIONING_SETTLE_MS = 150;

// Radix's roving focus only walks controls in document order, so vertical movement across the
// wrapped rows is worked out from these.
Expand Down Expand Up @@ -173,6 +176,7 @@ export function EditorContextPopup({
// Sticky for one open popup, so that focus moving into a portalled submenu does not clear it.
const hasHeldFocusRef = useRef(false);
const pinnedRectRef = useRef<DOMRect | null>(null);
const previousRequestRef = useRef<ContextPopupRequest | null>(null);
// 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,
Expand Down Expand Up @@ -206,13 +210,62 @@ export function EditorContextPopup({
pinnedRectRef.current = null;
};

// Strict Mode must not mistake an opening placement for a later selection request.
useEffect(() => {
previousRequestRef.current = request;

return () => {
if (previousRequestRef.current === request) {
previousRequestRef.current = null;
}
};
}, [request]);

// Layout is early enough: Radix registers the anchor from a passive effect, and Floating UI
// measures later still.
useLayoutEffect(() => {
const previousRequest = previousRequestRef.current;

// A popup the user is working in keeps the rect it was pinned to.
if (!hasHeldFocusRef.current) {
pinnedRectRef.current = null;
}

// Opening from Radix's parked position must remain immediate, and held popups must stay pinned.
if (!previousRequest || !request || hasHeldFocusRef.current) {
return undefined;
}

const wrapper = contentRef.current?.closest<HTMLElement>(POPPER_WRAPPER_SELECTOR);

if (!wrapper) {
return undefined;
}

let settleTimeout: number | undefined;
const stopRepositioning = () => {
wrapper.removeAttribute(REPOSITIONING_ATTRIBUTE);
wrapper.removeEventListener("transitionend", handleTransitionEnd);
window.removeEventListener("scroll", stopRepositioning, true);

if (settleTimeout !== undefined) {
window.clearTimeout(settleTimeout);
settleTimeout = undefined;
}
};
const handleTransitionEnd = (event: TransitionEvent) => {
if (event.target === wrapper && event.propertyName === "transform") {
stopRepositioning();
}
};

wrapper.setAttribute(REPOSITIONING_ATTRIBUTE, "");
wrapper.addEventListener("transitionend", handleTransitionEnd);
// Scrolling must not inherit selection easing while a transition is settling.
window.addEventListener("scroll", stopRepositioning, true);
settleTimeout = window.setTimeout(stopRepositioning, REPOSITIONING_SETTLE_MS);

return stopRepositioning;
}, [request]);

// Only for a keyboard request landing on an already open popup. A fresh open cannot be served
Expand Down
6 changes: 6 additions & 0 deletions src/features/editor/components/MilkdownEditor.css
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,9 @@
.leafdown-context-popup {
@apply border border-border/70 bg-popover text-popover-foreground shadow-lg;
}

@media (prefers-reduced-motion: no-preference) {
[data-radix-popper-content-wrapper][data-leafdown-context-popup-repositioning] {
@apply transition-transform duration-100 ease-out;
}
}