From 34ad008dc467b5a6ca9ee34072c8c324e1dea24f Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:13:39 +0530 Subject: [PATCH 1/2] feat(editor): select line on CodeMirror line number gutter click --- src/cm/lineNumberSelection.ts | 63 +++++++++++++++++++++++++++++++++++ src/lib/editorManager.js | 14 +++++++- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/cm/lineNumberSelection.ts diff --git a/src/cm/lineNumberSelection.ts b/src/cm/lineNumberSelection.ts new file mode 100644 index 000000000..acd31413d --- /dev/null +++ b/src/cm/lineNumberSelection.ts @@ -0,0 +1,63 @@ +import { EditorSelection } from "@codemirror/state"; +import type { BlockInfo, EditorView } from "@codemirror/view"; + +type LineInfo = Pick | null | undefined; + +type LineNumberClickEvent = Pick< + MouseEvent, + | "button" + | "shiftKey" + | "altKey" + | "ctrlKey" + | "metaKey" + | "preventDefault" + | "defaultPrevented" +>; + +/** + * Resolve the selection range for a clicked document line. + * Includes the trailing line break when one exists to mirror Ace's + * full-line selection behavior. + */ +export function getLineSelectionRange( + state: EditorView["state"], + line: LineInfo, +): { from: number; to: number } | null { + if (!line) return null; + const from = Math.max(0, Number(line.from) || 0); + const to = Math.max(from, Number(line.to) || from); + return { + from, + to: Math.min(to + 1, state.doc.length), + }; +} + +/** + * Select the clicked line from the line-number gutter. + * Ignores modified and non-primary clicks so it doesn't interfere with + * context menus or alternate selection gestures. + */ +export function handleLineNumberClick( + view: EditorView | null | undefined, + line: LineInfo, + event: LineNumberClickEvent | null | undefined, +): boolean { + if (!view || !event || event.defaultPrevented) return false; + if ((event.button ?? 0) !== 0) return false; + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) { + return false; + } + + const range = getLineSelectionRange(view.state, line); + if (!range) return false; + + event.preventDefault(); + view.dispatch({ + selection: EditorSelection.single(range.from, range.to), + userEvent: "select.pointer", + }); + view.focus(); + return true; +} + +export default handleLineNumberClick; diff --git a/src/lib/editorManager.js b/src/lib/editorManager.js index efae0a183..cc2ec4afa 100644 --- a/src/lib/editorManager.js +++ b/src/lib/editorManager.js @@ -31,6 +31,7 @@ import { registerExternalCommand, removeExternalCommand, } from "cm/commandRegistry"; +import { handleLineNumberClick } from "cm/lineNumberSelection"; import lspApi from "cm/lsp/api"; import lspClientManager from "cm/lsp/clientManager"; import { @@ -289,6 +290,13 @@ async function EditorManager($header, $body) { function makeLineNumberExtension() { const { linenumbers = true, relativeLineNumbers = false } = appSettings?.value || {}; + const lineNumberConfig = { + domEventHandlers: { + click(view, line, event) { + return handleLineNumberClick(view, line, event); + }, + }, + }; if (!linenumbers) return EditorView.theme({ ".cm-gutter": { @@ -299,9 +307,13 @@ async function EditorManager($header, $body) { }, }); if (!relativeLineNumbers) - return Prec.highest([lineNumbers(), highlightActiveLineGutter()]); + return Prec.highest([ + lineNumbers(lineNumberConfig), + highlightActiveLineGutter(), + ]); return Prec.highest([ lineNumbers({ + ...lineNumberConfig, formatNumber: (lineNo, state) => { try { const cur = state.doc.lineAt(state.selection.main.head).number; From 08d737d9368086e12db9a467a9fd951881de60a5 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:26:31 +0530 Subject: [PATCH 2/2] fix --- src/cm/lineNumberSelection.ts | 66 +++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/src/cm/lineNumberSelection.ts b/src/cm/lineNumberSelection.ts index acd31413d..f6ae02bdb 100644 --- a/src/cm/lineNumberSelection.ts +++ b/src/cm/lineNumberSelection.ts @@ -14,6 +14,14 @@ type LineNumberClickEvent = Pick< | "defaultPrevented" >; +function toDocumentOffset( + value: number | null | undefined, + fallback = 0, +): number { + const resolved = value != null ? Number(value) : fallback; + return Number.isFinite(resolved) ? resolved : fallback; +} + /** * Resolve the selection range for a clicked document line. * Includes the trailing line break when one exists to mirror Ace's @@ -24,18 +32,60 @@ export function getLineSelectionRange( line: LineInfo, ): { from: number; to: number } | null { if (!line) return null; - const from = Math.max(0, Number(line.from) || 0); - const to = Math.max(from, Number(line.to) || from); + const from = Math.max(0, toDocumentOffset(line.from)); + const to = Math.max(from, toDocumentOffset(line.to, from)); return { from, to: Math.min(to + 1, state.doc.length), }; } +function getCurrentSelectionLineRange(state: EditorView["state"]): { + from: number; + to: number; +} { + const selection = state.selection.main; + const startLine = state.doc.lineAt(selection.from); + const endPos = selection.empty + ? selection.head + : Math.max(selection.to - 1, selection.from); + const endLine = state.doc.lineAt(endPos); + const startRange = getLineSelectionRange(state, startLine); + const endRange = getLineSelectionRange(state, endLine); + + return { + from: startRange?.from ?? selection.from, + to: endRange?.to ?? selection.to, + }; +} + +function createLineSelection(range: { + from: number; + to: number; +}): EditorSelection { + return EditorSelection.single(range.to, range.from); +} + +function createExtendedLineSelection( + state: EditorView["state"], + clickedRange: { from: number; to: number }, +): EditorSelection { + const currentRange = getCurrentSelectionLineRange(state); + const from = Math.min(currentRange.from, clickedRange.from); + const to = Math.max(currentRange.to, clickedRange.to); + + if (clickedRange.from <= currentRange.from) { + return EditorSelection.single(to, from); + } + + return EditorSelection.single(from, to); +} + /** * Select the clicked line from the line-number gutter. - * Ignores modified and non-primary clicks so it doesn't interfere with - * context menus or alternate selection gestures. + * Shift-click extends the current selection by whole lines. + * Other modified or non-primary clicks are ignored so they don't interfere + * with context menus or alternate selection gestures. */ export function handleLineNumberClick( view: EditorView | null | undefined, @@ -44,7 +94,7 @@ export function handleLineNumberClick( ): boolean { if (!view || !event || event.defaultPrevented) return false; if ((event.button ?? 0) !== 0) return false; - if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) { + if (event.altKey || event.ctrlKey || event.metaKey) { return false; } @@ -53,8 +103,10 @@ export function handleLineNumberClick( event.preventDefault(); view.dispatch({ - selection: EditorSelection.single(range.from, range.to), - userEvent: "select.pointer", + selection: event.shiftKey + ? createExtendedLineSelection(view.state, range) + : createLineSelection(range), + userEvent: event.shiftKey ? "select.extend.pointer" : "select.pointer", }); view.focus(); return true;