From ffe34ae6d1559e10a64bda42d394b5a4588b3999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ademir=20Jos=C3=A9=20Ferreira=20J=C3=BAnior?= Date: Mon, 10 Aug 2026 03:31:35 -0300 Subject: [PATCH] Complete a pointer selection wherever the release lands ProseMirror registers `handleDOMEvents` on `view.dom` and tracks its own drags from `view.root`, so a release over the navigator, over other chrome, or outside the window ended the drag without opening the popup. The latch on `mousedown` keeps the root listener to gestures the editor started. --- CHANGELOG.md | 1 + docs/specification.md | 2 +- .../editor/plugins/contextPopup.test.tsx | 70 ++++++++++++++++++- src/features/editor/plugins/contextPopup.ts | 69 ++++++++++-------- 4 files changed, 107 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da56895..54bd9c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Leafdown uses lightweight [Keep a Changelog](https://keepachangelog.com/en/1.1.0 - 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. - Open the editor context popup from a selection made with the keyboard, `Select all` included, instead of only from a pointer selection. +- Open the editor context popup from a selection dragged out of the editor and released over the article navigator, other application chrome, or outside the window. - 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. diff --git a/docs/specification.md b/docs/specification.md index 7094a13..35016e7 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -167,7 +167,7 @@ The editor is a unified hybrid Markdown surface. Behavior is governed by renderi - `Shift+Tab`: Moves focus to the cell to the left. - `Enter`: Moves focus to the cell directly below. If pressed in the bottom row, inserts a new row below and focuses it. - `ArrowDown` (in the bottom row of a table): Exits the table downwards and moves the caret to the block below (creating a new empty paragraph block if none exists). -- Making a selection opens the context popup, whether it was made with the pointer, extended with `Shift+Arrow` or `Mod+Shift+Arrow`, or made whole by `Select all`. A pointer selection opens it on release, a keyboard one as the selection changes; extending further keeps the open popup rather than reopening it. +- Making a selection opens the context popup, whether it was made with the pointer, extended with `Shift+Arrow` or `Mod+Shift+Arrow`, or made whole by `Select all`. A pointer selection opens it on release, wherever the release lands, and a keyboard one as the selection changes; extending further keeps the open popup rather than reopening it. A pointer gesture that begins outside the editor leaves the popup as it is, whatever the editor's selection. - `Escape` dismisses a popup that does not hold focus, leaving the selection standing, and it stays dismissed until the selection collapses. - `Shift+F10` and the `Menu` key open the context popup around the caret or selection and move focus into it. A popup opened by right-click or by a selection leaves focus in the editor. - The popup is one command toolbar, and focus enters it on its first available command: diff --git a/src/features/editor/plugins/contextPopup.test.tsx b/src/features/editor/plugins/contextPopup.test.tsx index b14dd84..35408c1 100644 --- a/src/features/editor/plugins/contextPopup.test.tsx +++ b/src/features/editor/plugins/contextPopup.test.tsx @@ -8,12 +8,21 @@ import { dispatchMouseUp, type TestKeyboardEventOptions, } from "@/test/utils/events"; -import { setupMilkdownEditorMount, type MountedMilkdownEditor } from "@/test/utils/milkdown"; +import { + mountMilkdownEditor, + setupMilkdownEditorMount, + type MountedMilkdownEditor, +} from "@/test/utils/milkdown"; import { runKeyDownHandlers, setTextSelection, typeText } from "@/test/utils/prosemirror"; import { waitFor } from "@/test/utils/react"; const mountEditor = setupMilkdownEditorMount(); +const settleAnimationFrame = () => + new Promise((resolve) => { + window.requestAnimationFrame(() => resolve()); + }); + const mockCoordinates = (mounted: MountedMilkdownEditor) => vi.spyOn(mounted.view, "coordsAtPos").mockImplementation((pos) => ({ bottom: 40 + pos, @@ -72,6 +81,7 @@ describe("context popup plugin", () => { const mounted = await mountEditor(HELLO_WORLD_TEXT, { onContextPopupRequested }); const coordsAtPos = mockCoordinates(mounted); + dispatchMouseDown(mounted.view.dom, { button: 0 }); setTextSelection(mounted.view, 1, 6); dispatchMouseUp(mounted.view.dom, { button: 0 }); @@ -198,12 +208,66 @@ describe("context popup plugin", () => { const onContextPopupRequested = vi.fn(); const mounted = await mountEditor(HELLO_WORLD_TEXT, { onContextPopupRequested }); + dispatchMouseDown(mounted.view.dom, { button: 0 }); setTextSelection(mounted.view, 8); dispatchMouseUp(mounted.view.dom, { button: 0 }); - await new Promise((resolve) => { - window.requestAnimationFrame(() => resolve()); + await settleAnimationFrame(); + + expect(onContextPopupRequested).not.toHaveBeenCalled(); + }); + + it("opens from a selection released outside the editor", async () => { + const onContextPopupRequested = vi.fn(); + const mounted = await mountEditor(HELLO_WORLD_TEXT, { onContextPopupRequested }); + + mockCoordinates(mounted); + dispatchMouseDown(mounted.view.dom, { button: 0 }); + setTextSelection(mounted.view, 1, 6); + dispatchMouseUp(document.body, { button: 0 }); + + await waitFor(() => { + expect(onContextPopupRequested).toHaveBeenCalledWith(popupRequest("pointer")); }); + }); + + it("stays closed for a pointer gesture that begins outside the editor", async () => { + const popupState = trackPopupOpenState(); + const mounted = await mountEditor(HELLO_WORLD_TEXT, popupState); + + mockCoordinates(mounted); + setTextSelection(mounted.view, 1, 6); + runKeyDownHandlers(mounted.view, "Escape"); + popupState.onContextPopupRequested.mockClear(); + + const elsewhere = document.createElement("button"); + document.body.append(elsewhere); + + try { + dispatchMouseDown(elsewhere, { button: 0 }); + dispatchMouseUp(elsewhere, { button: 0 }); + + await settleAnimationFrame(); + } finally { + elsewhere.remove(); + } + + expect(popupState.onContextPopupRequested).not.toHaveBeenCalled(); + }); + + it("stops completing pointer gestures once the editor is gone", async () => { + const onContextPopupRequested = vi.fn(); + const mounted = await mountMilkdownEditor(HELLO_WORLD_TEXT, { onContextPopupRequested }); + + mockCoordinates(mounted); + dispatchMouseDown(mounted.view.dom, { button: 0 }); + setTextSelection(mounted.view, 1, 6); + onContextPopupRequested.mockClear(); + await mounted.destroy(); + + dispatchMouseUp(document.body, { button: 0 }); + + await settleAnimationFrame(); expect(onContextPopupRequested).not.toHaveBeenCalled(); }); diff --git a/src/features/editor/plugins/contextPopup.ts b/src/features/editor/plugins/contextPopup.ts index 095542d..fda9132 100644 --- a/src/features/editor/plugins/contextPopup.ts +++ b/src/features/editor/plugins/contextPopup.ts @@ -101,11 +101,42 @@ export const createLeafdownContextPopupPlugin = (options: LeafdownContextPopupPl return new Plugin({ key: leafdownContextPopupPluginKey, - view: () => ({ - update: (view, previousState) => { - syncPopupToSelection(view, previousState); - }, - }), + view: (editorView) => { + const handleRootMouseUp = (event: Event) => { + if (!(event instanceof MouseEvent) || event.button !== 0 || !pointerSelecting) { + return; + } + + window.requestAnimationFrame(() => { + pointerSelecting = false; + + if (editorView.isDestroyed) { + return; + } + + if (editorView.state.selection.empty) { + closePopup(options); + return; + } + + if (!requestSelectionPopup(editorView, "pointer")) { + options.onClose?.(); + } + }); + }; + + const root = editorView.root; + root.addEventListener("mouseup", handleRootMouseUp); + + return { + update: (view, previousState) => { + syncPopupToSelection(view, previousState); + }, + destroy: () => { + root.removeEventListener("mouseup", handleRootMouseUp); + }, + }; + }, props: { handleDOMEvents: { contextmenu: (view, event) => { @@ -127,36 +158,12 @@ export const createLeafdownContextPopupPlugin = (options: LeafdownContextPopupPl pointerSelecting = true; } - return false; - }, - mouseup: (view, event) => { - if (!(event instanceof MouseEvent) || event.button !== 0) { - return false; - } - - window.requestAnimationFrame(() => { - pointerSelecting = false; - - if (view.isDestroyed) { - return; - } - - if (view.state.selection.empty) { - closePopup(options); - return; - } - - if (!requestSelectionPopup(view, "pointer")) { - options.onClose?.(); - } - }); - return false; }, }, handleKeyDown: (view, event) => { - // A keystroke means the drag is over, including one whose release the handler above - // never saw because it landed outside the editor. + // A keystroke means the drag is over, including one whose release the page never + // received at all. pointerSelecting = false; if (isContextMenuKey(event)) {