Compare commits

...
Author SHA1 Message Date
l0ng-ai 727249e143 fix(keymap): Navigate Back / Forward with the mouse side buttons
The keymap had no notion of mouse buttons, so the back / forward side
buttons did nothing anywhere in the window.

Keymaps can now hold mouse shortcuts (`Button4`, `Shift+Button5`, buttons
numbered 1-based as DOM `button` + 1). Every preset binds Button4 to
Navigate Back and Button5 to Navigate Forward. The global dispatcher runs
them on mousedown from any component, keeps side-button press / release /
auxclick away from the target (no editor selection or focus change) and
cancels the webview's own history navigation. Menus and tooltips keep
showing the first keyboard binding; the keymap list shows "Button4 Click".

Fixes #75

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 14:53:31 +08:00
8 changed files with 207 additions and 15 deletions

No files matched your search

+2 -2
View File
@@ -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() };
+61 -5
View File
@@ -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);
};
}
+133
View File
@@ -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();
});
});
});
+3
View File
@@ -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);
+2 -2
View File
@@ -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"],
+2 -2
View File
@@ -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"],
+2 -2
View File
@@ -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"],
+2 -2
View File
@@ -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"],