11import { openSearchPanel } from "@codemirror/search" ;
2- import { EditorView } from "@codemirror/view" ;
2+ import { RangeSetBuilder , StateField } from "@codemirror/state" ;
3+ import { Decoration , type DecorationSet , EditorView } from "@codemirror/view" ;
34import type { SerializedEnrichment } from "@posthog/shared" ;
45import { Box , Flex , Text } from "@radix-ui/themes" ;
5- import { useEffect , useMemo } from "react" ;
6+ import { useEffect , useMemo , useRef } from "react" ;
67import { setEnrichmentEffect } from "../extensions/postHogEnrichment" ;
78import { useCodeMirror } from "../hooks/useCodeMirror" ;
89import { useEditorExtensions } from "../hooks/useEditorExtensions" ;
910import { usePendingScrollStore } from "../pendingScrollStore" ;
1011
12+ const selectedLineDecoration = Decoration . line ( { class : "cm-selected-lines" } ) ;
13+ const selectedLinesField = StateField . define < DecorationSet > ( {
14+ create : ( ) => Decoration . none ,
15+ update ( _value , tr ) {
16+ const sel = tr . state . selection . main ;
17+ if ( sel . empty ) return Decoration . none ;
18+ const builder = new RangeSetBuilder < Decoration > ( ) ;
19+ const from = tr . state . doc . lineAt ( sel . from ) . number ;
20+ const to = tr . state . doc . lineAt ( sel . to ) . number ;
21+ for ( let n = from ; n <= to ; n ++ ) {
22+ const pos = tr . state . doc . line ( n ) . from ;
23+ builder . add ( pos , pos , selectedLineDecoration ) ;
24+ }
25+ return builder . finish ( ) ;
26+ } ,
27+ provide : ( f ) => EditorView . decorations . from ( f ) ,
28+ } ) ;
29+ const selectedLinesTheme = EditorView . theme ( {
30+ ".cm-selected-lines" : {
31+ backgroundColor : "var(--accent-a3)" ,
32+ boxShadow : "inset 2px 0 0 0 var(--accent-8)" ,
33+ } ,
34+ "& .cm-selectionBackground, &.cm-focused .cm-selectionBackground" : {
35+ background : "transparent !important" ,
36+ } ,
37+ } ) ;
38+
39+ export interface EditorSelection {
40+ text : string ;
41+ /** 1-based line numbers. */
42+ fromLine : number ;
43+ toLine : number ;
44+ /** Viewport pixel anchor below the selection, or null when off-screen. */
45+ anchor : { top : number ; left : number } | null ;
46+ }
47+
1148interface CodeMirrorEditorProps {
1249 content : string ;
1350 filePath ?: string ;
1451 relativePath ?: string ;
1552 readOnly ?: boolean ;
1653 enrichment ?: SerializedEnrichment | null ;
54+ /** Fires on every selection (or doc) change with the current selection. */
55+ onSelectionChange ?: ( selection : EditorSelection ) => void ;
56+ /** Highlight the active selection as full lines (code-review style). */
57+ highlightSelectedLines ?: boolean ;
1758}
1859
1960export function CodeMirrorEditor ( {
@@ -22,9 +63,66 @@ export function CodeMirrorEditor({
2263 relativePath,
2364 readOnly = false ,
2465 enrichment,
66+ onSelectionChange,
67+ highlightSelectedLines = false ,
2568} : CodeMirrorEditorProps ) {
2669 const enrichmentEnabled = enrichment !== undefined ;
27- const extensions = useEditorExtensions ( filePath , readOnly , enrichmentEnabled ) ;
70+ const baseExtensions = useEditorExtensions (
71+ filePath ,
72+ readOnly ,
73+ enrichmentEnabled ,
74+ ) ;
75+
76+ // Ref-stable listener: a changing extension would tear down the editor.
77+ const onSelectionChangeRef = useRef ( onSelectionChange ) ;
78+ onSelectionChangeRef . current = onSelectionChange ;
79+ const selectionExtension = useMemo (
80+ ( ) =>
81+ EditorView . updateListener . of ( ( update ) => {
82+ const cb = onSelectionChangeRef . current ;
83+ if ( ! cb ) return ;
84+ const changed = update . selectionSet || update . docChanged ;
85+ // Also refire on scroll/resize so the anchor tracks the selection.
86+ const moved = update . viewportChanged || update . geometryChanged ;
87+ if ( ! changed && ! moved ) return ;
88+ const sel = update . state . selection . main ;
89+ const doc = update . state . doc ;
90+ if ( sel . empty ) {
91+ // No coords for an empty caret — just notify so consumers can hide.
92+ if ( changed ) {
93+ cb ( {
94+ text : "" ,
95+ fromLine : doc . lineAt ( sel . from ) . number ,
96+ toLine : doc . lineAt ( sel . to ) . number ,
97+ anchor : null ,
98+ } ) ;
99+ }
100+ return ;
101+ }
102+ const endRect = update . view . coordsAtPos ( sel . to ) ;
103+ const startRect = update . view . coordsAtPos ( doc . lineAt ( sel . to ) . from ) ;
104+ cb ( {
105+ text : doc . sliceString ( sel . from , sel . to ) ,
106+ fromLine : doc . lineAt ( sel . from ) . number ,
107+ toLine : doc . lineAt ( sel . to ) . number ,
108+ anchor : endRect
109+ ? { top : endRect . bottom , left : ( startRect ?? endRect ) . left }
110+ : null ,
111+ } ) ;
112+ } ) ,
113+ [ ] ,
114+ ) ;
115+ const extensions = useMemo (
116+ ( ) => [
117+ ...baseExtensions ,
118+ selectionExtension ,
119+ ...( highlightSelectedLines
120+ ? [ selectedLinesField , selectedLinesTheme ]
121+ : [ ] ) ,
122+ ] ,
123+ [ baseExtensions , selectionExtension , highlightSelectedLines ] ,
124+ ) ;
125+
28126 const options = useMemo (
29127 ( ) => ( { doc : content , extensions, filePath } ) ,
30128 [ content , extensions , filePath ] ,
0 commit comments