Skip to content

Commit d97ce3c

Browse files
authored
feat(editor): select line on CodeMirror line number gutter click (#2052)
1 parent bb7ebec commit d97ce3c

File tree

2 files changed

+128
-1
lines changed

2 files changed

+128
-1
lines changed

src/cm/lineNumberSelection.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { EditorSelection } from "@codemirror/state";
2+
import type { BlockInfo, EditorView } from "@codemirror/view";
3+
4+
type LineInfo = Pick<BlockInfo, "from" | "to"> | null | undefined;
5+
6+
type LineNumberClickEvent = Pick<
7+
MouseEvent,
8+
| "button"
9+
| "shiftKey"
10+
| "altKey"
11+
| "ctrlKey"
12+
| "metaKey"
13+
| "preventDefault"
14+
| "defaultPrevented"
15+
>;
16+
17+
function toDocumentOffset(
18+
value: number | null | undefined,
19+
fallback = 0,
20+
): number {
21+
const resolved = value != null ? Number(value) : fallback;
22+
return Number.isFinite(resolved) ? resolved : fallback;
23+
}
24+
25+
/**
26+
* Resolve the selection range for a clicked document line.
27+
* Includes the trailing line break when one exists to mirror Ace's
28+
* full-line selection behavior.
29+
*/
30+
export function getLineSelectionRange(
31+
state: EditorView["state"],
32+
line: LineInfo,
33+
): { from: number; to: number } | null {
34+
if (!line) return null;
35+
const from = Math.max(0, toDocumentOffset(line.from));
36+
const to = Math.max(from, toDocumentOffset(line.to, from));
37+
return {
38+
from,
39+
to: Math.min(to + 1, state.doc.length),
40+
};
41+
}
42+
43+
function getCurrentSelectionLineRange(state: EditorView["state"]): {
44+
from: number;
45+
to: number;
46+
} {
47+
const selection = state.selection.main;
48+
const startLine = state.doc.lineAt(selection.from);
49+
const endPos = selection.empty
50+
? selection.head
51+
: Math.max(selection.to - 1, selection.from);
52+
const endLine = state.doc.lineAt(endPos);
53+
const startRange = getLineSelectionRange(state, startLine);
54+
const endRange = getLineSelectionRange(state, endLine);
55+
56+
return {
57+
from: startRange?.from ?? selection.from,
58+
to: endRange?.to ?? selection.to,
59+
};
60+
}
61+
62+
function createLineSelection(range: {
63+
from: number;
64+
to: number;
65+
}): EditorSelection {
66+
return EditorSelection.single(range.to, range.from);
67+
}
68+
69+
function createExtendedLineSelection(
70+
state: EditorView["state"],
71+
clickedRange: { from: number; to: number },
72+
): EditorSelection {
73+
const currentRange = getCurrentSelectionLineRange(state);
74+
const from = Math.min(currentRange.from, clickedRange.from);
75+
const to = Math.max(currentRange.to, clickedRange.to);
76+
77+
if (clickedRange.from <= currentRange.from) {
78+
return EditorSelection.single(to, from);
79+
}
80+
81+
return EditorSelection.single(from, to);
82+
}
83+
84+
/**
85+
* Select the clicked line from the line-number gutter.
86+
* Shift-click extends the current selection by whole lines.
87+
* Other modified or non-primary clicks are ignored so they don't interfere
88+
* with context menus or alternate selection gestures.
89+
*/
90+
export function handleLineNumberClick(
91+
view: EditorView | null | undefined,
92+
line: LineInfo,
93+
event: LineNumberClickEvent | null | undefined,
94+
): boolean {
95+
if (!view || !event || event.defaultPrevented) return false;
96+
if ((event.button ?? 0) !== 0) return false;
97+
if (event.altKey || event.ctrlKey || event.metaKey) {
98+
return false;
99+
}
100+
101+
const range = getLineSelectionRange(view.state, line);
102+
if (!range) return false;
103+
104+
event.preventDefault();
105+
view.dispatch({
106+
selection: event.shiftKey
107+
? createExtendedLineSelection(view.state, range)
108+
: createLineSelection(range),
109+
userEvent: event.shiftKey ? "select.extend.pointer" : "select.pointer",
110+
});
111+
view.focus();
112+
return true;
113+
}
114+
115+
export default handleLineNumberClick;

src/lib/editorManager.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
registerExternalCommand,
3232
removeExternalCommand,
3333
} from "cm/commandRegistry";
34+
import { handleLineNumberClick } from "cm/lineNumberSelection";
3435
import lspApi from "cm/lsp/api";
3536
import lspClientManager from "cm/lsp/clientManager";
3637
import {
@@ -289,6 +290,13 @@ async function EditorManager($header, $body) {
289290
function makeLineNumberExtension() {
290291
const { linenumbers = true, relativeLineNumbers = false } =
291292
appSettings?.value || {};
293+
const lineNumberConfig = {
294+
domEventHandlers: {
295+
click(view, line, event) {
296+
return handleLineNumberClick(view, line, event);
297+
},
298+
},
299+
};
292300
if (!linenumbers)
293301
return EditorView.theme({
294302
".cm-gutter": {
@@ -299,9 +307,13 @@ async function EditorManager($header, $body) {
299307
},
300308
});
301309
if (!relativeLineNumbers)
302-
return Prec.highest([lineNumbers(), highlightActiveLineGutter()]);
310+
return Prec.highest([
311+
lineNumbers(lineNumberConfig),
312+
highlightActiveLineGutter(),
313+
]);
303314
return Prec.highest([
304315
lineNumbers({
316+
...lineNumberConfig,
305317
formatNumber: (lineNo, state) => {
306318
try {
307319
const cur = state.doc.lineAt(state.selection.main.head).number;

0 commit comments

Comments
 (0)