From bf5d9c3f72cc42512f023172059097c434595b18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Mon, 3 Aug 2026 21:04:41 -0300 Subject: [PATCH] Ease context popup selection movement --- CHANGELOG.md | 1 + .../components/EditorContextPopup.test.tsx | 57 ++++++++++++++++--- .../editor/components/EditorContextPopup.tsx | 53 +++++++++++++++++ .../editor/components/MilkdownEditor.css | 6 ++ 4 files changed, 109 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e95720e..079e841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/features/editor/components/EditorContextPopup.test.tsx b/src/features/editor/components/EditorContextPopup.test.tsx index 4bd7169..97ea488 100644 --- a/src/features/editor/components/EditorContextPopup.test.tsx +++ b/src/features/editor/components/EditorContextPopup.test.tsx @@ -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"; @@ -20,6 +20,14 @@ const createAnchorRect = (top = 60): DOMRect => { const popperWrapper = () => document.querySelector("[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 = () => { @@ -92,15 +100,18 @@ describe("EditorContextPopup", () => { const getRect = vi.fn((_mode: ContextPopupAnchorMode) => createAnchorRect()); render( - , + + + , ); + expectRepositioning(false); await waitFor(() => { expect(getRect).toHaveBeenCalled(); }); @@ -643,6 +654,7 @@ describe("EditorContextPopup", () => { rect = createAnchorRect(movedTo); view.rerender(renderPopup()); + expectRepositioning(true); await waitFor(() => { expect(wrapperTranslateY()).toBe(placedAt + movedTo - openedAt); @@ -676,6 +688,7 @@ describe("EditorContextPopup", () => { expect(modesUsed(getRect)).toEqual(new Set(["pinned"])); expect(getRect).toHaveBeenCalledTimes(1); expect(wrapperTranslateY()).toBe(placedAt); + expectRepositioning(false); }); }); @@ -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 = () => ( + + ); + 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(); diff --git a/src/features/editor/components/EditorContextPopup.tsx b/src/features/editor/components/EditorContextPopup.tsx index 8d72462..d3853fc 100644 --- a/src/features/editor/components/EditorContextPopup.tsx +++ b/src/features/editor/components/EditorContextPopup.tsx @@ -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. @@ -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(null); + const previousRequestRef = useRef(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, @@ -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(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 diff --git a/src/features/editor/components/MilkdownEditor.css b/src/features/editor/components/MilkdownEditor.css index 0b11973..34fb6d4 100644 --- a/src/features/editor/components/MilkdownEditor.css +++ b/src/features/editor/components/MilkdownEditor.css @@ -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; + } +}