11import { useState , useCallback , useRef , useEffect } from 'react' ;
2- import { CodeAnnotation , SelectedLineRange , CodeAnnotationType } from '@plannotator/ui/types' ;
2+ import { CodeAnnotation , SelectedLineRange , CodeAnnotationType , TokenAnnotationMeta } from '@plannotator/ui/types' ;
33import { useDismissOnOutsideAndEscape } from '@plannotator/ui/hooks/useDismissOnOutsideAndEscape' ;
44import { extractLinesFromPatch } from '../utils/patchParser' ;
5+ import type { DiffTokenEventBaseProps } from '@pierre/diffs' ;
6+
7+ export interface TokenMeta {
8+ lineNumber : number ;
9+ charStart : number ;
10+ charEnd : number ;
11+ tokenText : string ;
12+ side : 'deletions' | 'additions' ;
13+ }
14+
15+ export interface TokenSelection {
16+ anchor : TokenMeta ;
17+ fullText : string ;
18+ }
519
620export interface ToolbarState {
721 position : { top : number ; left : number } ;
822 range : SelectedLineRange ;
23+ tokenSelection ?: TokenSelection ;
924}
1025
1126interface UseAnnotationToolbarArgs {
1227 patch : string ;
1328 filePath : string ;
1429 isFocused : boolean ;
1530 onLineSelection : ( range : SelectedLineRange | null ) => void ;
16- onAddAnnotation : ( type : CodeAnnotationType , text ?: string , suggestedCode ?: string , originalCode ?: string ) => void ;
31+ onAddAnnotation : ( type : CodeAnnotationType , text ?: string , suggestedCode ?: string , originalCode ?: string , tokenMeta ?: TokenAnnotationMeta ) => void ;
1732 onEditAnnotation : ( id : string , text ?: string , suggestedCode ?: string , originalCode ?: string ) => void ;
1833}
1934
@@ -24,6 +39,7 @@ interface Draft {
2439 showSuggestedCode : boolean ;
2540 range : SelectedLineRange ;
2641 position : { top : number ; left : number } ;
42+ tokenSelection ?: TokenSelection ;
2743}
2844
2945const draftStore = new Map < string , Draft > ( ) ;
@@ -38,6 +54,7 @@ function draftKey(filePath: string, range: SelectedLineRange): string {
3854export function useAnnotationToolbar ( { patch, filePath, isFocused, onLineSelection, onAddAnnotation, onEditAnnotation } : UseAnnotationToolbarArgs ) {
3955 const toolbarRef = useRef < HTMLDivElement > ( null ) ;
4056 const lastMousePosition = useRef < { x : number ; y : number } > ( { x : 0 , y : 0 } ) ;
57+ const tokenAnchorRef = useRef < TokenMeta | null > ( null ) ;
4158
4259 const [ toolbarState , setToolbarState ] = useState < ToolbarState | null > ( null ) ;
4360 const [ commentText , setCommentText ] = useState ( '' ) ;
@@ -68,6 +85,7 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
6885 ...form ,
6986 range,
7087 position : toolbarStateRef . current ?. position ?? { top : 0 , left : 0 } ,
88+ tokenSelection : toolbarStateRef . current ?. tokenSelection ,
7189 } ) ;
7290 currentDraftKeyRef . current = key ;
7391 } else {
@@ -94,6 +112,11 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
94112 return ( ) => saveDraft ( ) ;
95113 } , [ saveDraft ] ) ;
96114
115+ // Clear token anchor on file switch
116+ useEffect ( ( ) => {
117+ tokenAnchorRef . current = null ;
118+ } , [ filePath ] ) ;
119+
97120 const resetForm = useCallback ( ( ) => {
98121 setToolbarState ( null ) ;
99122 setCommentText ( '' ) ;
@@ -109,19 +132,15 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
109132 lastMousePosition . current = { x : e . clientX , y : e . clientY } ;
110133 } , [ ] ) ;
111134
112- // Handle line selection end
113- const handleLineSelectionEnd = useCallback ( ( range : SelectedLineRange | null ) => {
114- if ( ! range ) {
115- setToolbarState ( null ) ;
116- onLineSelection ( null ) ;
117- return ;
118- }
119-
120- // Save current draft before switching
135+ // Shared: save current draft, restore form for new range, set toolbar state, notify parent
136+ const openToolbar = useCallback ( (
137+ range : SelectedLineRange ,
138+ position : { top : number ; left : number } ,
139+ tokenSelection ?: TokenSelection ,
140+ ) => {
121141 saveDraft ( ) ;
122142 setEditingAnnotationId ( null ) ;
123143
124- // Restore draft for new range or start fresh
125144 const draft = draftStore . get ( draftKey ( filePath , range ) ) ;
126145 if ( draft ) {
127146 setCommentText ( draft . commentText ) ;
@@ -133,18 +152,10 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
133152 setShowSuggestedCode ( false ) ;
134153 }
135154
136- const mousePos = lastMousePosition . current ;
137- setToolbarState ( {
138- position : {
139- top : mousePos . y + 10 ,
140- left : mousePos . x ,
141- } ,
142- range,
143- } ) ;
155+ setToolbarState ( { position, range, tokenSelection } ) ;
144156 currentDraftKeyRef . current = draftKey ( filePath , range ) ;
145157 restoreDraftKeyByFilePath . delete ( filePath ) ;
146158
147- // Pre-extract original code from selected lines
148159 const side = range . side === 'additions' ? 'new' : 'old' ;
149160 const start = Math . min ( range . start , range . end ) ;
150161 const end = Math . max ( range . start , range . end ) ;
@@ -153,6 +164,20 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
153164 onLineSelection ( range ) ;
154165 } , [ patch , filePath , onLineSelection , saveDraft ] ) ;
155166
167+ // Handle line selection end (gutter clicks)
168+ const handleLineSelectionEnd = useCallback ( ( range : SelectedLineRange | null ) => {
169+ tokenAnchorRef . current = null ;
170+
171+ if ( ! range ) {
172+ setToolbarState ( null ) ;
173+ onLineSelection ( null ) ;
174+ return ;
175+ }
176+
177+ const mousePos = lastMousePosition . current ;
178+ openToolbar ( range , { top : mousePos . y + 10 , left : mousePos . x } ) ;
179+ } , [ onLineSelection , openToolbar ] ) ;
180+
156181 // Handle annotation submission (create or update)
157182 const handleSubmitAnnotation = useCallback ( ( ) => {
158183 const hasComment = commentText . trim ( ) . length > 0 ;
@@ -166,7 +191,13 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
166191 if ( editingAnnotationId ) {
167192 onEditAnnotation ( editingAnnotationId , text , code , original ) ;
168193 } else {
169- onAddAnnotation ( 'comment' , text , code , original ) ;
194+ const tokenSel = toolbarState . tokenSelection ;
195+ const tokenMeta = tokenSel ? {
196+ charStart : tokenSel . anchor . charStart ,
197+ charEnd : tokenSel . anchor . charEnd ,
198+ tokenText : tokenSel . fullText ,
199+ } : undefined ;
200+ onAddAnnotation ( 'comment' , text , code , original , tokenMeta ) ;
170201 }
171202
172203 clearDraft ( ) ;
@@ -239,6 +270,7 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
239270 setToolbarState ( {
240271 position : draft . position ,
241272 range : draft . range ,
273+ tokenSelection : draft . tokenSelection ,
242274 } ) ;
243275 currentDraftKeyRef . current = key ;
244276 restoreDraftKeyByFilePath . delete ( filePath ) ;
@@ -251,6 +283,35 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
251283 }
252284 } , [ filePath , isFocused , onLineSelection , patch ] ) ;
253285
286+ // Handle single token click — opens toolbar for one token
287+ const handleTokenClick = useCallback ( ( props : DiffTokenEventBaseProps , event : MouseEvent ) => {
288+ const clickedToken : TokenMeta = {
289+ lineNumber : props . lineNumber ,
290+ charStart : props . lineCharStart ,
291+ charEnd : props . lineCharEnd ,
292+ tokenText : props . tokenText ,
293+ side : props . side ,
294+ } ;
295+
296+ // Same token clicked twice → deselect
297+ const anchor = tokenAnchorRef . current ;
298+ if ( anchor && anchor . lineNumber === clickedToken . lineNumber
299+ && anchor . charStart === clickedToken . charStart
300+ && anchor . side === clickedToken . side ) {
301+ tokenAnchorRef . current = null ;
302+ setToolbarState ( null ) ;
303+ onLineSelection ( null ) ;
304+ return ;
305+ }
306+
307+ tokenAnchorRef . current = clickedToken ;
308+ openToolbar (
309+ { start : clickedToken . lineNumber , end : clickedToken . lineNumber , side : clickedToken . side } ,
310+ { top : event . clientY + 10 , left : event . clientX } ,
311+ { anchor : clickedToken , fullText : clickedToken . tokenText } ,
312+ ) ;
313+ } , [ onLineSelection , openToolbar ] ) ;
314+
254315 return {
255316 // State
256317 toolbarState,
@@ -271,6 +332,7 @@ export function useAnnotationToolbar({ patch, filePath, isFocused, onLineSelecti
271332 // Handlers
272333 handleMouseMove,
273334 handleLineSelectionEnd,
335+ handleTokenClick,
274336 handleSubmitAnnotation,
275337 handleDismiss,
276338 handleCancel,
0 commit comments