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 @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
70 changes: 67 additions & 3 deletions src/features/editor/plugins/contextPopup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => {
window.requestAnimationFrame(() => resolve());
});

const mockCoordinates = (mounted: MountedMilkdownEditor) =>
vi.spyOn(mounted.view, "coordsAtPos").mockImplementation((pos) => ({
bottom: 40 + pos,
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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<void>((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();
});
Expand Down
69 changes: 38 additions & 31 deletions src/features/editor/plugins/contextPopup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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)) {
Expand Down