feat(editor): make caret mouse gestures configurable (#2143)

The gesture that adds a caret was hardcoded to Alt+Shift+Click, so it could not be changed in the Keymap settings.

Adds the "Add or Remove Caret" action to Editor Actions, with Alt+Shift+Click as the default in every preset. `multiCaret.ts` now matches the event against that action's effective bindings, so rebinding and removal apply at once. A stroke bound to it takes precedence over Alt-drag column selection and over mouse-shortcut dispatch in the editor; column selection is unchanged otherwise.

Closes #2127
Refs #2126
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-08 20:33:37 +08:00
1 джерело 7f62011a08
коміт 6394f5904a
10 files changed
+149 -11

No files matched your search

+8
Переглянути файл
@@ -778,6 +778,14 @@ export function registerEditorActions(): () => void {
text("code.autoIndentLines", "Auto-Indent Lines", (v) => !v.state.readOnly && indentSelection(v), {
keywords: ["reindent", "fix indentation"],
}),
{
id: "editor.addOrRemoveCaret",
title: "Add or Remove Caret",
category: "Editor Actions",
when: "textEditorActive",
// The mouse selection style handles the pointer position and dragging.
run: () => {},
},
text("editor.cloneCaretAbove", "Clone Caret Above", (v) => cloneCaretAbove(v), { category: "Editor Actions" }),
text("editor.cloneCaretBelow", "Clone Caret Below", (v) => cloneCaretBelow(v), { category: "Editor Actions" }),
text("editor.matchBrace", "Move Caret to Matching Brace", (v) => cursorMatchBrace(v), {
+84
Переглянути файл
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { PRESETS, useKeymap } from "@/platform/keymap";
import { usePlatform } from "@/platform/platformInfo";
import { multiCaretMouse } from "./multiCaret";
let view: EditorView;
const originalKeymap = useKeymap.getState();
const originalPlatform = usePlatform.getState();
const event = (init: MouseEventInit = {}) => new MouseEvent("mousedown", { button: 0, altKey: true, ...init });
const styles = (e: MouseEvent) => view.state.facet(EditorView.mouseSelectionStyle).map((style) => style(view, e));
beforeEach(() => {
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
usePlatform.setState({ os: "macos" });
view = new EditorView({
state: EditorState.create({
doc: "first\nsecond\nthird",
extensions: [EditorState.allowMultipleSelections.of(true), multiCaretMouse],
}),
parent: document.body,
});
vi.spyOn(view, "posAtCoords").mockImplementation((coords) => coords.x);
vi.spyOn(view, "coordsAtPos").mockReturnValue(null);
});
afterEach(() => {
view.destroy();
useKeymap.setState(originalKeymap);
usePlatform.setState(originalPlatform);
});
describe("caret mouse bindings", () => {
it.each(Object.keys(PRESETS))("%s keeps the default gesture", (preset) => {
useKeymap.setState({ preset });
expect(PRESETS[preset]!["editor.addOrRemoveCaret"]).toEqual(["Alt+Shift+Button1"]);
expect(styles(event({ shiftKey: true }))[0]).not.toBeNull();
});
it("matches the complete stroke and only the left button", () => {
expect(styles(event({ shiftKey: true }))[0]).not.toBeNull();
for (const init of [{}, { shiftKey: true, ctrlKey: true }, { shiftKey: true, metaKey: true }, { button: 1 }]) {
expect(styles(event(init))[0]).toBeNull();
}
});
it("uses rebinding immediately and yields column selection to the caret gesture", () => {
expect(styles(event())[1]).not.toBeNull();
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["Alt+Button1"] } });
expect(styles(event({ shiftKey: true }))[0]).toBeNull();
const [caret, column] = styles(event({ clientX: 2 }));
expect(column).toBeNull();
expect(caret!.get(event({ clientX: 2 }), false, false).ranges.map((r) => r.head)).toEqual([0, 2]);
const drag = caret!.get(event({ clientX: 8 }), false, false);
expect(drag.ranges.map((r) => [r.anchor, r.head])).toEqual([
[0, 0],
[2, 8],
]);
view.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(2)]) });
const remove = styles(event({ clientX: 2 }))[0]!;
expect(remove.get(event({ clientX: 2 }), false, false).ranges.map((r) => r.head)).toEqual([0]);
});
it("honours removal and restores Alt-drag column selection", () => {
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": [] } });
expect(styles(event({ shiftKey: true }))[0]).toBeNull();
expect(styles(event())[0]).toBeNull();
expect(styles(event())[1]).not.toBeNull();
useKeymap.setState({ overrides: {} });
expect(styles(event({ shiftKey: true }))[0]).not.toBeNull();
});
it("matches normalized Ctrl+Alt strokes as reported for right Alt on Windows", () => {
useKeymap.setState({ overrides: { "editor.addOrRemoveCaret": ["alt+ctrl+button1"] } });
expect(styles(event({ ctrlKey: true }))[0]).not.toBeNull();
expect(styles(event())[0]).toBeNull();
});
it("preserves middle-button column selection except on Linux", () => {
expect(styles(event({ button: 1, altKey: false }))[1]).not.toBeNull();
usePlatform.setState({ os: "linux" });
expect(styles(event({ button: 1, altKey: false }))[1]).toBeNull();
});
});
+13 -9
Переглянути файл
@@ -1,15 +1,11 @@
/**
* IDEA mouse multi-caret gestures (macOS keymap): ⌥⇧click adds a caret (or
* removes the one under the pointer), ⌥⇧drag adds a selection, ⌥drag and
* middle-button drag make a column (rectangular) selection. Middle-button
* drag is left alone on Linux, where a middle click pastes the primary selection.
*/
/** Mouse caret gestures follow the keymap; Alt and middle-button drags select columns. */
import { EditorSelection, type Extension } from "@codemirror/state";
import { EditorView, type MouseSelectionStyle } from "@codemirror/view";
import { bindingsFor, mouseStrokeOf, normalizeBinding } from "@/platform/keymap";
import { usePlatform } from "@/platform/platformInfo";
import { blockMouseSelection } from "./columnMode";
/** The selection after ⌥⇧click / drag from `anchor` to `head` on top of `start`. */
/** The selection after a caret click / drag from `anchor` to `head` on top of `start`. */
export function toggleCaret(start: EditorSelection, anchor: number, head: number): EditorSelection {
if (anchor === head) {
const hit = start.ranges.findIndex((r) => r.empty && r.head === head);
@@ -21,8 +17,16 @@ export function toggleCaret(start: EditorSelection, anchor: number, head: number
return start.addRange(EditorSelection.range(anchor, head));
}
export function isAddCaretGesture(event: MouseEvent): boolean {
if (event.button !== 0) return false;
const stroke = mouseStrokeOf(event);
return (
stroke !== null && bindingsFor("editor.addOrRemoveCaret").some((binding) => normalizeBinding(binding) === stroke)
);
}
const addCaretStyle = EditorView.mouseSelectionStyle.of((view, event): MouseSelectionStyle | null => {
if (event.button !== 0 || !event.altKey || !event.shiftKey || event.metaKey || event.ctrlKey) return null;
if (!isAddCaretGesture(event)) return null;
let start = view.state.selection;
const anchor = view.posAtCoords({ x: event.clientX, y: event.clientY }, false);
return {
@@ -41,7 +45,7 @@ export const multiCaretMouse: Extension = [
// CodeMirror's default (⌘click) would clash with ⌘click navigation.
EditorView.clickAddsSelectionRange.of(() => false),
blockMouseSelection((e) => {
if (e.shiftKey || e.metaKey || e.ctrlKey) return false;
if (isAddCaretGesture(e) || e.shiftKey || e.metaKey || e.ctrlKey) return false;
if (e.button === 0) return e.altKey;
return e.button === 1 && !e.altKey && usePlatform.getState().os !== "linux";
}),
+2
Переглянути файл
@@ -74,6 +74,7 @@ import { requestHover, showPhpConstructDocumentation } from "./hover";
import { declarationSummary, renderCode } from "./markdown";
import { editorPrefs } from "@/platform/editorSettings";
import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs";
import { isAddCaretGesture } from "@/editor/core/cm/multiCaret";
import { dispatchMouseShortcut, shortcutLabel } from "@/platform/keymap";
import { isNavigationGesture, onNavigationModifier, navigationModifierHeld } from "@/platform/navigationModifier";
import { create } from "zustand";
@@ -710,6 +711,7 @@ const linkPlugin = ViewPlugin.fromClass(
},
mousedown(e, view) {
if (e.button !== 0 && e.button !== 1) return false;
if (isAddCaretGesture(e)) return false;
if (
dispatchMouseShortcut(e, () => {
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false);