дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(keymap): Navigate Back / Forward with the mouse side buttons (#76)
Keymaps only understood keystrokes, so the mouse back / forward side buttons did nothing. Keymaps now accept mouse shortcuts (Button4, Shift+Button5, …); every preset binds Button4 / Button5 to Navigate Back / Forward. The dispatcher handles side-button presses in the capture phase, so they work over the editor, tool windows and empty space, never change the editor selection and don't trigger the webview's own history navigation. Menus and tooltips keep showing the keyboard shortcut. Fixes #75 Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
1 джерело
e3dae02de7
коміт
d3f68ed180
8 files changed
+207
-15
No files matched your search
@@ -11,7 +11,7 @@ import { executeAction, getAction, isActionEnabled, useActions } from "@/platfor
|
||||
import { useContextKeys } from "@/platform/context";
|
||||
import { keysWithPrefix, t, tx, useI18n } from "@/platform/i18n";
|
||||
import { commands, isTauri, type DynamicItem, type MenuConfig, type MenuNode } from "@/platform/ipc";
|
||||
import { bindingsFor, effectiveBindings, formatBinding, useKeymap } from "@/platform/keymap";
|
||||
import { effectiveBindings, formatBinding, primaryKeyBinding, useKeymap } from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { useContributions } from "@/workbench/contributions";
|
||||
import { isDistractionFree, useDistractionFree } from "@/workbench/distractionFree";
|
||||
@@ -48,7 +48,7 @@ export function buildMenuConfig(os: HostOs): MenuConfig {
|
||||
const shortcuts: Record<string, string> = {};
|
||||
for (const [, id] of effectiveBindings()) {
|
||||
if (id in shortcuts) continue;
|
||||
const first = bindingsFor(id)[0];
|
||||
const first = primaryKeyBinding(id);
|
||||
if (first) shortcuts[id] = menuShortcut(first, os);
|
||||
}
|
||||
return { locale: useI18n.getState().locale, labels, shortcuts, dynamic: dynamicMenuItems() };
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* (`Meta+K Meta+C`). A modifier pressed twice is `Shift Shift` (IDEA's double
|
||||
* 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.
|
||||
* combos work on macOS where `event.key` would be a symbol. Mouse shortcuts
|
||||
* name the button 1-based (`Button4` = back side button, `Button5` = forward),
|
||||
* optionally with modifiers (`Alt+Button4`); they are single strokes.
|
||||
*/
|
||||
import { create } from "zustand";
|
||||
import { getAction, isActionKeyEnabled, executeAction } from "./actions";
|
||||
@@ -65,6 +67,27 @@ interface KeymapState {
|
||||
|
||||
export const useKeymap = create<KeymapState>(() => ({ preset: defaultPreset(), overrides: {} }));
|
||||
|
||||
const MOUSE_KEY_RE = /^Button(\d+)$/i;
|
||||
|
||||
/** A mouse shortcut (`Button4`, `Shift+Button5`) rather than a keystroke. */
|
||||
export function isMouseBinding(binding: string): boolean {
|
||||
const b = binding.trim();
|
||||
return !/\s/.test(b) && MOUSE_KEY_RE.test(b.split("+").pop()!);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function mouseStrokeOf(
|
||||
e: Pick<MouseEvent, "button" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">,
|
||||
): string | null {
|
||||
if (!Number.isInteger(e.button) || e.button < 3) 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("+");
|
||||
}
|
||||
|
||||
const STROKE_RE = /^(?:(?:Ctrl|Alt|Shift|Meta)\+)*[^\s+]+$|^(?:(?:Ctrl|Alt|Shift|Meta)\+)*\+$/i;
|
||||
|
||||
const MODIFIERS = ["Ctrl", "Alt", "Shift", "Meta"] as const;
|
||||
@@ -183,7 +206,8 @@ export function normalizeBinding(binding: string): string {
|
||||
const parts = stroke.split("+");
|
||||
const key = parts.pop()!;
|
||||
const mods = MOD_ORDER.filter((m) => parts.some((p) => p.toLowerCase() === m.toLowerCase()));
|
||||
return [...mods, key.length === 1 ? key.toUpperCase() : key].join("+");
|
||||
const mouse = MOUSE_KEY_RE.exec(key);
|
||||
return [...mods, mouse ? `Button${mouse[1]}` : key.length === 1 ? key.toUpperCase() : key].join("+");
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
@@ -264,6 +288,11 @@ const SYMBOLS: Record<string, string> = {
|
||||
* Windows/Linux: `Ctrl+Shift+N`, chords as `Ctrl+K, Ctrl+I`, `Shift, Shift`, `Ctrl, Ctrl+Up`.
|
||||
*/
|
||||
export function formatBinding(binding: string, mac = isMacHost()): string {
|
||||
if (isMouseBinding(binding)) {
|
||||
const parts = normalizeBinding(binding).split("+");
|
||||
const button = `${parts.pop()} Click`;
|
||||
return mac ? parts.map((p) => SYMBOLS[p] ?? p).join("") + button : [...parts, button].join("+");
|
||||
}
|
||||
if (!mac) return normalizeBinding(binding).split(" ").join(", ");
|
||||
const double = parseDoubleModifier(binding);
|
||||
if (double) return SYMBOLS[double.modifier]!.repeat(2) + (double.key ? (SYMBOLS[double.key] ?? double.key) : "");
|
||||
@@ -278,8 +307,13 @@ export function formatBinding(binding: string, mac = isMacHost()): string {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** The first keyboard binding of an action: what menus and tooltips show. */
|
||||
export function primaryKeyBinding(actionId: string): string | undefined {
|
||||
return bindingsFor(actionId).find((b) => !isMouseBinding(b));
|
||||
}
|
||||
|
||||
export function shortcutLabel(actionId: string): string | undefined {
|
||||
const b = bindingsFor(actionId)[0];
|
||||
const b = primaryKeyBinding(actionId);
|
||||
return b ? formatBinding(b) : undefined;
|
||||
}
|
||||
|
||||
@@ -463,8 +497,26 @@ export function installKeyboardDispatcher(target: Window = window, now: () => nu
|
||||
for (const id of ids.reverse()) if (runIfEnabled(id)) return;
|
||||
};
|
||||
|
||||
// Shift+click (extend a selection) is not half of a double Shift.
|
||||
const onMouseDown = () => resetTap();
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
// Shift+click (extend a selection) is not half of a double Shift.
|
||||
resetTap();
|
||||
if (suspended > 0) return;
|
||||
const stroke = mouseStrokeOf(e);
|
||||
if (!stroke) return;
|
||||
// Side buttons never reach the target (no editor selection, no focus change).
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
reset();
|
||||
const ids = effectiveBindings().filter(([b]) => b === stroke).map(([, id]) => id);
|
||||
for (const id of ids.reverse()) if (runIfEnabled(id)) return;
|
||||
};
|
||||
|
||||
/** The release of a side button: swallowed so the webview doesn't navigate its own history. */
|
||||
const onMouseRelease = (e: MouseEvent) => {
|
||||
if (e.button < 3) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
reset();
|
||||
@@ -474,11 +526,15 @@ export function installKeyboardDispatcher(target: Window = window, now: () => nu
|
||||
target.addEventListener("keydown", onKeyDown, true);
|
||||
target.addEventListener("keyup", onKeyUp, true);
|
||||
target.addEventListener("mousedown", onMouseDown, true);
|
||||
target.addEventListener("mouseup", onMouseRelease, true);
|
||||
target.addEventListener("auxclick", onMouseRelease, true);
|
||||
target.addEventListener("blur", onBlur);
|
||||
return () => {
|
||||
target.removeEventListener("keydown", onKeyDown, true);
|
||||
target.removeEventListener("keyup", onKeyUp, true);
|
||||
target.removeEventListener("mousedown", onMouseDown, true);
|
||||
target.removeEventListener("mouseup", onMouseRelease, true);
|
||||
target.removeEventListener("auxclick", onMouseRelease, true);
|
||||
target.removeEventListener("blur", onBlur);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
formatBinding,
|
||||
installKeyboardDispatcher,
|
||||
isMouseBinding,
|
||||
isValidBinding,
|
||||
mouseStrokeOf,
|
||||
normalizeBinding,
|
||||
PRESETS,
|
||||
primaryKeyBinding,
|
||||
shortcutLabel,
|
||||
suspendKeyboardDispatch,
|
||||
useKeymap,
|
||||
} from "./keymap";
|
||||
import { registerAction } from "./actions";
|
||||
|
||||
const noMods = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false };
|
||||
|
||||
describe("mouse shortcuts", () => {
|
||||
it.each(Object.keys(PRESETS))("%s binds the side buttons to Back / Forward", (preset) => {
|
||||
expect(PRESETS[preset]!["navigate.back"]).toContain("Button4");
|
||||
expect(PRESETS[preset]!["navigate.forward"]).toContain("Button5");
|
||||
});
|
||||
|
||||
it("names DOM side buttons 1-based, with modifiers", () => {
|
||||
expect(mouseStrokeOf({ ...noMods, button: 3 })).toBe("Button4");
|
||||
expect(mouseStrokeOf({ ...noMods, button: 4 })).toBe("Button5");
|
||||
expect(mouseStrokeOf({ ...noMods, button: 3, altKey: true, shiftKey: true })).toBe("Alt+Shift+Button4");
|
||||
for (const button of [0, 1, 2]) expect(mouseStrokeOf({ ...noMods, button })).toBeNull();
|
||||
});
|
||||
|
||||
it("parses, normalizes and formats mouse bindings", () => {
|
||||
expect(isValidBinding("Button4")).toBe(true);
|
||||
expect(isMouseBinding("shift+button5")).toBe(true);
|
||||
expect(isMouseBinding("Meta+B")).toBe(false);
|
||||
expect(normalizeBinding("shift+button5")).toBe("Shift+Button5");
|
||||
expect(formatBinding("Button4", true)).toBe("Button4 Click");
|
||||
expect(formatBinding("Shift+Button4", true)).toBe("⇧Button4 Click");
|
||||
expect(formatBinding("Ctrl+Button5", false)).toBe("Ctrl+Button5 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");
|
||||
useKeymap.setState({ overrides: { "t.mouseFirst": ["Button4"] } });
|
||||
expect(shortcutLabel("t.mouseFirst")).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("dispatch", () => {
|
||||
let uninstall = () => {};
|
||||
const back = vi.fn();
|
||||
const forward = vi.fn();
|
||||
let editor: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
back.mockReset();
|
||||
forward.mockReset();
|
||||
registerAction({ id: "navigate.back", title: "Back", run: back });
|
||||
registerAction({ id: "navigate.forward", title: "Forward", run: forward });
|
||||
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
|
||||
document.body.innerHTML = `<div class="cm-editor"><div class="cm-content" contenteditable="true">x</div></div><div id="tree"></div>`;
|
||||
editor = document.querySelector(".cm-content")!;
|
||||
uninstall = installKeyboardDispatcher(window);
|
||||
});
|
||||
afterEach(() => {
|
||||
uninstall();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const click = (target: Element, button: number, init: MouseEventInit = {}) => {
|
||||
const events = (["mousedown", "mouseup", "auxclick"] as const).map(
|
||||
(type) => new MouseEvent(type, { button, bubbles: true, cancelable: true, ...init }),
|
||||
);
|
||||
for (const e of events) target.dispatchEvent(e);
|
||||
return events;
|
||||
};
|
||||
|
||||
it.each([
|
||||
["the editor", () => editor],
|
||||
["another component", () => document.getElementById("tree")!],
|
||||
["the body", () => document.body],
|
||||
])("runs Back / Forward from %s", async (_, target) => {
|
||||
click(target(), 3);
|
||||
await Promise.resolve();
|
||||
expect(back).toHaveBeenCalledOnce();
|
||||
expect(forward).not.toHaveBeenCalled();
|
||||
click(target(), 4);
|
||||
await Promise.resolve();
|
||||
expect(forward).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps side-button events from the target and from webview history navigation", () => {
|
||||
const reached = vi.fn();
|
||||
editor.addEventListener("mousedown", reached);
|
||||
editor.addEventListener("mouseup", reached);
|
||||
editor.addEventListener("auxclick", reached);
|
||||
const events = click(editor, 3);
|
||||
expect(reached).not.toHaveBeenCalled();
|
||||
expect(events.every((e) => e.defaultPrevented)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves left, middle and right clicks alone", async () => {
|
||||
const reached = vi.fn();
|
||||
editor.addEventListener("mousedown", reached);
|
||||
for (const button of [0, 1, 2]) {
|
||||
const [down] = click(editor, button);
|
||||
expect(down!.defaultPrevented).toBe(false);
|
||||
}
|
||||
await Promise.resolve();
|
||||
expect(reached).toHaveBeenCalledTimes(3);
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(forward).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("follows keymap overrides and modifiers", async () => {
|
||||
useKeymap.setState({ overrides: { "navigate.back": ["Meta+["], "navigate.forward": ["Shift+Button4"] } });
|
||||
click(editor, 3);
|
||||
await Promise.resolve();
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
click(editor, 3, { shiftKey: true });
|
||||
await Promise.resolve();
|
||||
expect(forward).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does nothing while shortcuts are being recorded", async () => {
|
||||
const resume = suspendKeyboardDispatch();
|
||||
click(editor, 3);
|
||||
resume();
|
||||
await Promise.resolve();
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,9 @@ describe("IDEA keymap parity", () => {
|
||||
["usages.rerun", ["Meta+R"], ["Ctrl+F5"]],
|
||||
// Vcs.Log.GoToRef: Find in the Git Log table.
|
||||
["vcs.log.goToRef", ["Meta+F"], ["Ctrl+F"]],
|
||||
// Back / Forward also carry the mouse side buttons (`<mouse-shortcut keystroke="button4"/>`).
|
||||
["navigate.back", ["Meta+[", "Meta+Alt+Left", "Button4"], ["Ctrl+Alt+Left", "Button4"]],
|
||||
["navigate.forward", ["Meta+]", "Meta+Alt+Right", "Button5"], ["Ctrl+Alt+Right", "Button5"]],
|
||||
])("%s", (id, mac, win) => {
|
||||
expect(intellijMacos[id]).toEqual(mac);
|
||||
expect(intellijWindows[id]).toEqual(win);
|
||||
|
||||
@@ -15,8 +15,8 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"navigate.switcherBack": ["Ctrl+Shift+Tab"],
|
||||
"navigate.recentFiles": ["Meta+E"],
|
||||
"navigate.recentLocations": ["Meta+Shift+E"],
|
||||
"navigate.back": ["Meta+[", "Meta+Alt+Left"],
|
||||
"navigate.forward": ["Meta+]", "Meta+Alt+Right"],
|
||||
"navigate.back": ["Meta+[", "Meta+Alt+Left", "Button4"],
|
||||
"navigate.forward": ["Meta+]", "Meta+Alt+Right", "Button5"],
|
||||
"navigate.gotoLine": ["Meta+L"],
|
||||
"navigate.declaration": ["Meta+B"],
|
||||
"navigate.implementation": ["Meta+Alt+B"],
|
||||
|
||||
@@ -16,8 +16,8 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"navigate.switcherBack": ["Ctrl+Shift+Tab"],
|
||||
"navigate.recentFiles": ["Ctrl+E"],
|
||||
"navigate.recentLocations": ["Ctrl+Shift+E"],
|
||||
"navigate.back": ["Ctrl+Alt+Left"],
|
||||
"navigate.forward": ["Ctrl+Alt+Right"],
|
||||
"navigate.back": ["Ctrl+Alt+Left", "Button4"],
|
||||
"navigate.forward": ["Ctrl+Alt+Right", "Button5"],
|
||||
"navigate.gotoLine": ["Ctrl+G"],
|
||||
"navigate.declaration": ["Ctrl+B"],
|
||||
"navigate.implementation": ["Ctrl+Alt+B"],
|
||||
|
||||
@@ -15,8 +15,8 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
"navigate.switcherBack": ["Ctrl+Shift+Tab"],
|
||||
"navigate.recentFiles": [],
|
||||
"navigate.recentLocations": [],
|
||||
"navigate.back": ["Ctrl+-"],
|
||||
"navigate.forward": ["Ctrl+Shift+-"],
|
||||
"navigate.back": ["Ctrl+-", "Button4"],
|
||||
"navigate.forward": ["Ctrl+Shift+-", "Button5"],
|
||||
"navigate.gotoLine": ["Ctrl+G"],
|
||||
"navigate.declaration": ["F12"],
|
||||
"navigate.implementation": ["Meta+F12"],
|
||||
|
||||
@@ -20,8 +20,8 @@ const translated = Object.fromEntries(
|
||||
|
||||
export const vscodeWindows: Record<string, string[]> = {
|
||||
...translated,
|
||||
"navigate.back": ["Alt+Left"],
|
||||
"navigate.forward": ["Alt+Right"],
|
||||
"navigate.back": ["Alt+Left", "Button4"],
|
||||
"navigate.forward": ["Alt+Right", "Button5"],
|
||||
"find.replaceInFile": ["Ctrl+H"],
|
||||
"find.next": ["F3"],
|
||||
"find.previous": ["Shift+F3"],
|
||||
|
||||
Reference in new issue
Заблокувати користувача