feat(keymap): support modified left-click shortcuts (#405)

The mouse shortcut recorder accepted only side buttons, so a modified left click such as Option+Command+click couldn't be assigned. Left clicks with Ctrl, Alt or Command now record and dispatch as mouse shortcuts (plain and Shift-only clicks, middle and right clicks keep their meaning; unbound combinations behave as before). In editors the caret moves to the clicked position before the action runs, so navigation targets the symbol under the pointer. The macOS keymap binds Option+Command+click to Go to Implementation(s).

Co-authored-by: huaqiangyang <[email protected]>
Цей коміт міститься в:
huaqiang yang authored and GitHub committed 2026-10-30 20:52:27 +08:00
1 джерело 9c31a27e30
коміт 669595e045
9 files changed
+257 -13

No files matched your search

+15 -2
Переглянути файл
@@ -55,7 +55,7 @@ import { requestHover, showPhpConstructDocumentation } from "./hover";
import { declarationSummary, renderCode } from "./markdown";
import { editorPrefs } from "@/platform/editorSettings";
import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs";
import { shortcutLabel } from "@/platform/keymap";
import { dispatchMouseShortcut, shortcutLabel } from "@/platform/keymap";
import { isNavigationGesture, onNavigationModifier } from "@/platform/navigationModifier";
import { create } from "zustand";
import { tx } from "@/platform/i18n";
@@ -489,7 +489,20 @@ const linkPlugin = ViewPlugin.fromClass(
this.show(false);
},
mousedown(e, view) {
if (e.button !== 0 || !isNavigationGesture(e)) return false;
if (e.button !== 0) return false;
if (
dispatchMouseShortcut(e, () => {
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
if (pos == null) return false;
view.dispatch({ selection: EditorSelection.cursor(pos), effects: setLink.of(null) });
view.focus();
return true;
})
) {
e.preventDefault();
return true;
}
if (!isNavigationGesture(e)) return false;
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
if (pos == null) return false;
e.preventDefault();
+156
Переглянути файл
@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { commands } from "@/platform/ipc";
import { usePlatform } from "@/platform/platformInfo";
import { resetNavigationModifier } from "@/platform/navigationModifier";
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
import { registerAction } from "@/platform/actions";
const lspGoto = vi.hoisted(() => vi.fn<typeof commands.lspGoto>());
vi.mock("@/platform/ipc", async (orig) => {
const real = await orig<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto } };
});
const { gotoTargets, navigationExtension } = await import("./navigation");
const { lspDoc } = await import("./lsp");
const { provideEditorService } = await import("@/workbench/contributions");
const openFile = vi.fn(async () => {});
provideEditorService({ openFile } as never);
let view: EditorView;
let uninstall: () => void;
let removeAction: () => void;
beforeEach(() => {
usePlatform.setState({ os: "macos" });
resetNavigationModifier();
vi.clearAllMocks();
useKeymap.setState({
preset: "intellij-macos",
overrides: {},
});
removeAction = registerAction({
id: "navigate.implementation",
title: "Go to Implementation(s)",
run: () => gotoTargets(view, "implementation"),
});
uninstall = installKeyboardDispatcher(window);
lspGoto.mockResolvedValue({
status: "ok",
data: [{ path: "/p/ServiceImpl.java", range: { start: { line: 4, col: 2 }, end: { line: 4, col: 5 } }, uri: null }],
});
view = new EditorView({
state: EditorState.create({
doc: "service.run()",
extensions: [
lspDoc.of({ doc: 1, path: "/p/Main.java", language: "java", uiMode: "Java", readOnly: false }),
navigationExtension(),
],
}),
parent: document.body,
});
vi.spyOn(view, "posAtCoords").mockReturnValue(9);
});
afterEach(() => {
uninstall();
removeAction();
useKeymap.setState({ overrides: {} });
view.destroy();
document.body.replaceChildren();
resetNavigationModifier();
usePlatform.setState({ os: "macos" });
vi.restoreAllMocks();
});
function click(init: MouseEventInit) {
const event = new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, ...init });
view.contentDOM.dispatchEvent(event);
return event;
}
describe("mouse navigation", () => {
it("opens the implementation of the symbol clicked with Option+Command", async () => {
const event = click({ metaKey: true, altKey: true });
await vi.waitFor(() =>
expect(openFile).toHaveBeenCalledWith("/p/ServiceImpl.java", {
selection: { line: 4, col: 2 },
focus: true,
}),
);
expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "implementation");
expect(view.state.selection.main.head).toBe(9);
expect(event.defaultPrevented).toBe(true);
});
it.each([
{ os: "macos", mods: { metaKey: true } },
{ os: "windows", mods: { ctrlKey: true } },
{ os: "linux", mods: { ctrlKey: true } },
] as const)("preserves declaration navigation on $os", async ({ os, mods }) => {
usePlatform.setState({ os });
click(mods);
await vi.waitFor(() => expect(openFile).toHaveBeenCalledOnce());
expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "definition");
});
it.each([
{ button: 1, metaKey: true, altKey: true },
{ button: 2, metaKey: true, altKey: true },
{ metaKey: true, altKey: true, shiftKey: true },
{ metaKey: true, altKey: true, ctrlKey: true },
])("ignores other button/modifier combinations: %o", async (mods) => {
click(mods);
await Promise.resolve();
expect(lspGoto).not.toHaveBeenCalled();
});
it("reports missing implementations without navigating to the declaration", async () => {
lspGoto.mockResolvedValue({ status: "ok", data: [] });
click({ metaKey: true, altKey: true });
await vi.waitFor(() =>
expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("No implementations found"),
);
expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "implementation");
expect(openFile).not.toHaveBeenCalled();
});
it("does not navigate with Option+Command when the binding is removed", async () => {
useKeymap.setState({ overrides: { "navigate.implementation": ["Meta+Alt+B"] } });
click({ metaKey: true, altKey: true });
await Promise.resolve();
expect(lspGoto).not.toHaveBeenCalled();
});
it("follows a different mouse binding assigned to the implementation action", async () => {
useKeymap.setState({ overrides: { "navigate.implementation": ["Ctrl+Alt+Button1"] } });
click({ metaKey: true, altKey: true });
expect(lspGoto).not.toHaveBeenCalled();
click({ ctrlKey: true, altKey: true });
await vi.waitFor(() => expect(openFile).toHaveBeenCalledOnce());
expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "implementation");
});
it("does not run the binding if the click has no document position", async () => {
vi.mocked(view.posAtCoords).mockReturnValue(null);
click({ metaKey: true, altKey: true });
await Promise.resolve();
expect(lspGoto).not.toHaveBeenCalled();
});
it("allows the same gesture to be reassigned to another action", async () => {
const run = vi.fn();
const remove = registerAction({ id: "test.mouse", title: "Custom action", run });
try {
useKeymap.setState({
overrides: { "navigate.implementation": ["Meta+Alt+B"], "test.mouse": ["Alt+Meta+Button1"] },
});
click({ metaKey: true, altKey: true });
await Promise.resolve();
expect(run).toHaveBeenCalledOnce();
expect(lspGoto).not.toHaveBeenCalled();
} finally {
remove();
}
});
});
+21
Переглянути файл
@@ -7,6 +7,27 @@ afterEach(cleanup);
const action = { id: "my.action", title: "My Action", category: "Test" };
describe("Mouse Shortcut dialog", () => {
it("records and submits Option+Command+left click", () => {
const onSubmit = vi.fn();
render(
<RecordShortcutDialog
mouse
action={action}
actions={[action]}
draft={{ preset: "intellij-macos", overrides: {} }}
onCancel={() => {}}
onSubmit={onSubmit}
/>,
);
fireEvent.mouseDown(screen.getByRole("textbox", { name: "Mouse shortcut" }), {
button: 0,
altKey: true,
metaKey: true,
});
fireEvent.click(screen.getByRole("button", { name: "OK" }));
expect(onSubmit).toHaveBeenCalledWith("Alt+Meta+Button1", [], false);
});
it("records a side-button click with modifiers and submits it", () => {
const onSubmit = vi.fn();
render(
+3 -3
Переглянути файл
@@ -22,7 +22,7 @@ export interface RecordShortcutDialogProps {
onCancel: () => void;
/** `removeConflicts`: also unbind the conflicting assignments. */
onSubmit: (binding: string, conflicts: Conflict[], removeConflicts: boolean) => void;
/** Record a mouse shortcut (side buttons with modifiers) instead of keys. */
/** Record a mouse shortcut (modified left clicks or side buttons) instead of keys. */
mouse?: boolean;
}
@@ -178,7 +178,7 @@ function KeyStrokes({ first, second, useSecond, setFirst, setSecond, setUseSecon
);
}
/** Pad that records a side-button click with its modifiers; shortcuts stay off while the dialog is open. */
/** Record modified left clicks and side buttons; shortcuts stay off while the dialog is open. */
function MouseRecorder({ value, onChange }: { value: string; onChange: (binding: string) => void }) {
useEffect(() => suspendKeyboardDispatch(), []);
const record = (e: MouseEvent<HTMLDivElement>) => {
@@ -202,7 +202,7 @@ function MouseRecorder({ value, onChange }: { value: string; onChange: (binding:
<span className={styles.recorded}>{formatBinding(value)}</span>
) : (
<span className={styles.recorderPlaceholder}>
<T>Click here with a mouse side button, holding any modifiers</T>
<T>Click here with Ctrl, Alt or Command held, or use a mouse side button</T>
</span>
)}
</div>
+29 -6
Переглянути файл
@@ -7,7 +7,7 @@
* Shift, `Ctrl Ctrl`…); pressed twice and held, then a key, `Alt Alt+Up`.
* Keys are matched by physical position (`KeyboardEvent.code`), so ⌥-letter
* combos work on macOS where `event.key` would be a symbol. Mouse shortcuts
* name the button 1-based (`Button4` = back side button, `Button5` = forward),
* name the button 1-based (`Button1` = left, `Button4` = back side button, `Button5` = forward),
* optionally with modifiers (`Alt+Button4`); they are single strokes.
*/
import { create } from "zustand";
@@ -83,14 +83,14 @@ export function isMouseBinding(binding: string): boolean {
}
/**
* The mouse stroke of a press of a non-primary button: DOM `button` 3 / 4
* (back / forward side buttons) → `Button4` / `Button5`. Left, middle and
* right buttons have their own meaning everywhere, so they are not shortcuts.
* Side buttons, or a left click with Ctrl/Alt/Meta. Plain and Shift-only
* left clicks keep selection semantics; middle/right clicks keep their own meaning.
*/
export function mouseStrokeOf(
e: Pick<MouseEvent, "button" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">,
): string | null {
if (!Number.isInteger(e.button) || e.button < 3) return null;
if (!Number.isInteger(e.button) || e.button < 0) return null;
if (e.button < 3 && !(e.button === 0 && (e.ctrlKey || e.altKey || e.metaKey))) return null;
const mods = [e.ctrlKey && "Ctrl", e.altKey && "Alt", e.shiftKey && "Shift", e.metaKey && "Meta"].filter(Boolean);
return [...mods, `Button${e.button + 1}`].join("+");
}
@@ -317,7 +317,8 @@ const KEY_LABELS: Record<string, string> = {
export function formatBinding(binding: string, mac = isMacHost()): string {
if (isMouseBinding(binding)) {
const parts = normalizeBinding(binding).split("+");
const button = `${parts.pop()} Click`;
const key = parts.pop();
const button = key === "Button1" ? "Left Click" : `${key} Click`;
return mac ? parts.map((p) => SYMBOLS[p] ?? p).join("") + button : [...parts, button].join("+");
}
if (!mac)
@@ -405,6 +406,19 @@ function runIfEnabled(id: string): boolean {
return true;
}
/** Run a configured mouse binding; editors prepare the clicked caret and focus first. */
export function dispatchMouseShortcut(e: MouseEvent, prepare?: () => boolean): boolean {
if (suspended > 0) return false;
const stroke = mouseStrokeOf(e);
if (!stroke) return false;
const ids = effectiveBindings()
.filter(([b]) => b === stroke)
.map(([, id]) => id);
if (!ids.length || (prepare && !prepare())) return false;
for (const id of ids.reverse()) if (runIfEnabled(id)) return true;
return false;
}
/**
* Installs the global keydown dispatcher (capture phase, so IDE shortcuts win
* over CodeMirror defaults). Returns an uninstall function. `now` is the clock
@@ -551,6 +565,15 @@ export function installKeyboardDispatcher(
if (suspended > 0) return;
const stroke = mouseStrokeOf(e);
if (!stroke) return;
if (e.button === 0) {
// The editor sets the caret under the pointer before dispatching the action.
if (e.target instanceof Element && e.target.closest(".cm-content")) return;
if (!dispatchMouseShortcut(e)) return;
e.preventDefault();
e.stopPropagation();
reset();
return;
}
// Side buttons never reach the target (no editor selection, no focus change).
e.preventDefault();
e.stopPropagation();
+30
Переглянути файл
@@ -39,6 +39,15 @@ describe("mouse shortcuts", () => {
expect(formatBinding("Ctrl+Button5", false)).toBe("Ctrl+Button5 Click");
});
it("records modified left clicks without capturing selection and context-menu clicks", () => {
expect(mouseStrokeOf({ ...noMods, button: 0, altKey: true, metaKey: true })).toBe("Alt+Meta+Button1");
expect(mouseStrokeOf({ ...noMods, button: 0, shiftKey: true })).toBeNull();
for (const button of [1, 2]) expect(mouseStrokeOf({ ...noMods, button, metaKey: true })).toBeNull();
expect(normalizeBinding("Meta+Alt+Button1")).toBe("Alt+Meta+Button1");
expect(formatBinding("Alt+Meta+Button1", true)).toBe("⌥⌘Left Click");
expect(formatBinding("Ctrl+Alt+Button1", false)).toBe("Ctrl+Alt+Left Click");
});
it("keeps mouse bindings out of menu / tooltip shortcuts", () => {
useKeymap.setState({ preset: "intellij-windows", overrides: { "t.mouseFirst": ["Button4", "Ctrl+Alt+Q"] } });
expect(primaryKeyBinding("t.mouseFirst")).toBe("Ctrl+Alt+Q");
@@ -129,5 +138,26 @@ describe("mouse shortcuts", () => {
await Promise.resolve();
expect(back).not.toHaveBeenCalled();
});
it("dispatches a bound modified left click outside the editor, but leaves an unbound one alone", async () => {
useKeymap.setState({ overrides: { "navigate.back": ["Alt+Meta+Button1"] } });
const tree = document.getElementById("tree")!;
const [unbound] = click(tree, 0, { altKey: true });
expect(unbound!.defaultPrevented).toBe(false);
const [bound] = click(tree, 0, { altKey: true, metaKey: true });
expect(bound!.defaultPrevented).toBe(true);
await Promise.resolve();
expect(back).toHaveBeenCalledOnce();
});
it("defers modified left clicks inside the editor to its caret-aware handler", () => {
useKeymap.setState({ overrides: { "navigate.back": ["Alt+Meta+Button1"] } });
const reached = vi.fn();
editor.addEventListener("mousedown", reached);
const [down] = click(editor, 0, { altKey: true, metaKey: true });
expect(down!.defaultPrevented).toBe(false);
expect(reached).toHaveBeenCalledOnce();
expect(back).not.toHaveBeenCalled();
});
});
});
+1
Переглянути файл
@@ -36,6 +36,7 @@ describe("IDEA keymap parity", () => {
["window.previous", ["Meta+Alt+Shift+`"], ["Ctrl+Alt+["]],
["code.commentBlock", ["Meta+Alt+/", "Ctrl+Shift+/"], ["Ctrl+Shift+/"]],
["navigate.typeDeclaration", ["Meta+Shift+B", "Ctrl+Shift+B"], ["Ctrl+Shift+B"]],
["navigate.implementation", ["Meta+Alt+B", "Meta+Alt+Button1"], ["Ctrl+Alt+B"]],
["find.next", ["Meta+G"], ["F3", "Ctrl+L"]],
["find.previous", ["Meta+Shift+G"], ["Shift+F3", "Ctrl+Shift+L"]],
["project.copy", ["Meta+C"], ["Ctrl+C", "Ctrl+Insert"]],
+1 -1
Переглянути файл
@@ -19,7 +19,7 @@ export const intellijMacos: Record<string, string[]> = {
"navigate.forward": ["Meta+]", "Meta+Alt+Right", "Button5"],
"navigate.gotoLine": ["Meta+L"],
"navigate.declaration": ["Meta+B"],
"navigate.implementation": ["Meta+Alt+B"],
"navigate.implementation": ["Meta+Alt+B", "Meta+Alt+Button1"],
"navigate.typeDeclaration": ["Meta+Shift+B", "Ctrl+Shift+B"],
"navigate.fileStructure": ["Meta+F12"],
"navigate.nextError": ["F2"],
+1 -1
Переглянути файл
@@ -4736,7 +4736,7 @@
"Sort by Type": "按类型排序",
"Sort by Modification Time": "按修改时间排序",
"Add Mouse Shortcut": "添加鼠标快捷键",
"Click here with a mouse side button, holding any modifiers": "按住任意修饰键,用鼠标侧键点击此处",
"Click here with Ctrl, Alt or Command held, or use a mouse side button": "按住 Ctrl、Alt 或 Command 在此点击,或使用鼠标侧键",
"Mouse shortcut": "鼠标快捷键",
"Mouse Shortcut: {action}": "鼠标快捷键:{action}",
"Accept Left Side": "接受左侧",