11import React , { useState , useEffect , useRef } from "react" ;
2- import { AnnotationType , type ImageAttachment } from "../types" ;
2+ import { AnnotationType } from "../types" ;
33import { createPortal } from "react-dom" ;
4- import { AttachmentsButton } from "./AttachmentsButton" ;
54import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape" ;
65
76type PositionMode = 'center-above' | 'top-right' ;
@@ -16,49 +15,38 @@ const isEditableElement = (node: EventTarget | Element | null): boolean => {
1615interface AnnotationToolbarProps {
1716 element : HTMLElement ;
1817 positionMode : PositionMode ;
19- onAnnotate : ( type : AnnotationType , text ?: string , images ?: ImageAttachment [ ] ) => void ;
18+ onAnnotate : ( type : AnnotationType ) => void ;
2019 onClose : ( ) => void ;
20+ /** Called when user wants to write a comment (opens CommentPopover in parent) */
21+ onRequestComment ?: ( initialChar ?: string ) => void ;
2122 /** Text to copy (for text selection, pass source.text) */
2223 copyText ?: string ;
23- /** Close toolbar when element scrolls out of viewport (only in menu step) */
24+ /** Close toolbar when element scrolls out of viewport */
2425 closeOnScrollOut ?: boolean ;
2526 /** Exit animation state */
2627 isExiting ?: boolean ;
2728 /** Hover callbacks for code block behavior */
2829 onMouseEnter ?: ( ) => void ;
2930 onMouseLeave ?: ( ) => void ;
30- onLockChange ?: ( locked : boolean ) => void ;
31- /** Start in input step instead of menu (for comment mode) */
32- initialStep ?: 'menu' | 'input' ;
33- /** Pre-set annotation type when starting in input step */
34- initialType ?: AnnotationType ;
3531}
3632
3733export const AnnotationToolbar : React . FC < AnnotationToolbarProps > = ( {
3834 element,
3935 positionMode,
4036 onAnnotate,
4137 onClose,
38+ onRequestComment,
4239 copyText,
4340 closeOnScrollOut = false ,
4441 isExiting = false ,
4542 onMouseEnter,
4643 onMouseLeave,
47- onLockChange,
48- initialStep = 'menu' ,
49- initialType,
5044} ) => {
51- const [ step , setStep ] = useState < "menu" | "input" > ( initialStep ) ;
52- const [ activeType , setActiveType ] = useState < AnnotationType | null > ( initialType ?? null ) ;
53- const [ inputValue , setInputValue ] = useState ( "" ) ;
54- const [ images , setImages ] = useState < ImageAttachment [ ] > ( [ ] ) ;
5545 const [ position , setPosition ] = useState < { top : number ; left ?: number ; right ?: number } | null > ( null ) ;
5646 const [ copied , setCopied ] = useState ( false ) ;
57- const inputRef = useRef < HTMLTextAreaElement > ( null ) ;
5847 const toolbarRef = useRef < HTMLDivElement > ( null ) ;
5948
6049 const handleCopy = async ( ) => {
61- // Use provided copyText, or fall back to code element / element text
6250 let textToCopy = copyText ;
6351 if ( ! textToCopy ) {
6452 const codeEl = element . querySelector ( 'code' ) ;
@@ -69,43 +57,17 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
6957 setTimeout ( ( ) => setCopied ( false ) , 1500 ) ;
7058 } ;
7159
72- // Focus input when entering input step (including on mount with initialStep='input')
60+ // Reset copied state when element changes
7361 useEffect ( ( ) => {
74- if ( step === "input" ) {
75- // Use setTimeout to ensure DOM is fully ready (portals can have timing issues)
76- const timeoutId = setTimeout ( ( ) => {
77- const input = inputRef . current ;
78- if ( input ) {
79- input . focus ( ) ;
80- // Move cursor to end (for type-to-comment with pre-populated char)
81- input . selectionStart = input . selectionEnd = input . value . length ;
82- }
83- } , 0 ) ;
84- return ( ) => clearTimeout ( timeoutId ) ;
85- }
86- } , [ step , element ] ) ; // Also re-run when element changes (new selection)
87-
88- // Reset state when element changes
89- useEffect ( ( ) => {
90- setStep ( initialStep ) ;
91- setActiveType ( initialType ?? null ) ;
92- setInputValue ( "" ) ;
93- setImages ( [ ] ) ;
9462 setCopied ( false ) ;
95- } , [ element , initialStep , initialType ] ) ;
96-
97- // Notify parent when locked (in input mode)
98- useEffect ( ( ) => {
99- onLockChange ?.( step === "input" ) ;
100- } , [ step , onLockChange ] ) ;
63+ } , [ element ] ) ;
10164
10265 // Update position on scroll/resize
10366 useEffect ( ( ) => {
10467 const updatePosition = ( ) => {
10568 const rect = element . getBoundingClientRect ( ) ;
10669
107- // Close if scrolled out of viewport (only in menu step if enabled)
108- if ( closeOnScrollOut && step === "menu" && ( rect . bottom < 0 || rect . top > window . innerHeight ) ) {
70+ if ( closeOnScrollOut && ( rect . bottom < 0 || rect . top > window . innerHeight ) ) {
10971 onClose ( ) ;
11072 return ;
11173 }
@@ -131,34 +93,24 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
13193 window . removeEventListener ( "scroll" , updatePosition , true ) ;
13294 window . removeEventListener ( "resize" , updatePosition ) ;
13395 } ;
134- } , [ element , positionMode , closeOnScrollOut , step , onClose ] ) ;
96+ } , [ element , positionMode , closeOnScrollOut , onClose ] ) ;
13597
136- // Type-to-comment: start typing in menu step → auto-transition to comment input
98+ // Type-to-comment: typing opens CommentPopover via parent
13799 useEffect ( ( ) => {
138- if ( step !== "menu" ) return ;
139-
140100 const handleKeyDown = ( e : KeyboardEvent ) => {
141- // Protect global comment and any editable field focus from type-to-comment capture.
142101 if ( e . isComposing ) return ;
143102 if ( isEditableElement ( e . target ) || isEditableElement ( document . activeElement ) ) return ;
144- // Escape closes the toolbar
145103 if ( e . key === "Escape" ) { onClose ( ) ; return ; }
146- // Ignore if modifier keys are held (except shift for capitals)
147104 if ( e . ctrlKey || e . metaKey || e . altKey ) return ;
148- // Ignore special keys
149105 if ( e . key === "Tab" || e . key === "Enter" ) return ;
150- // Only trigger on printable characters (single char keys)
151106 if ( e . key . length !== 1 ) return ;
152107
153- // Transition to comment mode with the typed character
154- setActiveType ( AnnotationType . COMMENT ) ;
155- setInputValue ( e . key ) ;
156- setStep ( "input" ) ;
108+ onRequestComment ?.( e . key ) ;
157109 } ;
158110
159111 window . addEventListener ( "keydown" , handleKeyDown ) ;
160112 return ( ) => window . removeEventListener ( "keydown" , handleKeyDown ) ;
161- } , [ step , onClose ] ) ;
113+ } , [ onClose , onRequestComment ] ) ;
162114
163115 useDismissOnOutsideAndEscape ( {
164116 enabled : true ,
@@ -172,15 +124,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
172124 if ( type === AnnotationType . DELETION ) {
173125 onAnnotate ( type ) ;
174126 } else {
175- setActiveType ( type ) ;
176- setStep ( "input" ) ;
177- }
178- } ;
179-
180- const handleSubmit = ( e : React . FormEvent ) => {
181- e . preventDefault ( ) ;
182- if ( activeType && ( inputValue . trim ( ) || images . length > 0 ) ) {
183- onAnnotate ( activeType , inputValue || undefined , images . length > 0 ? images : undefined ) ;
127+ onRequestComment ?.( ) ;
184128 }
185129 } ;
186130
@@ -216,77 +160,34 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
216160 to { opacity: 0; transform: translateY(8px)${ translateX } ; }
217161 }
218162 ` } </ style >
219- { step === "menu" ? (
220- < div className = "flex items-center p-1 gap-0.5" >
221- < ToolbarButton
222- onClick = { handleCopy }
223- icon = { copied ? < CheckIcon /> : < CopyIcon /> }
224- label = { copied ? "Copied!" : "Copy" }
225- className = { copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground" }
226- />
227- < div className = "w-px h-5 bg-border mx-0.5" />
228- < ToolbarButton
229- onClick = { ( ) => handleTypeSelect ( AnnotationType . DELETION ) }
230- icon = { < TrashIcon /> }
231- label = "Delete"
232- className = "text-destructive hover:bg-destructive/10"
233- />
234- < ToolbarButton
235- onClick = { ( ) => handleTypeSelect ( AnnotationType . COMMENT ) }
236- icon = { < CommentIcon /> }
237- label = "Comment"
238- className = "text-accent hover:bg-accent/10"
239- />
240- < div className = "w-px h-5 bg-border mx-0.5" />
241- < ToolbarButton
242- onClick = { onClose }
243- icon = { < CloseIcon /> }
244- label = "Cancel"
245- className = "text-muted-foreground hover:bg-muted"
246- />
247- </ div >
248- ) : (
249- < form onSubmit = { handleSubmit } className = "flex items-start gap-1.5 p-1.5 pl-3" >
250- < textarea
251- ref = { inputRef }
252- rows = { 1 }
253- className = "bg-transparent text-sm min-w-44 max-w-80 max-h-32 placeholder:text-muted-foreground resize-none px-2 py-1.5 focus:outline-none focus:bg-muted/30"
254- style = { { fieldSizing : "content" } as React . CSSProperties }
255- placeholder = "Add a comment..."
256- value = { inputValue }
257- onChange = { ( e ) => setInputValue ( e . target . value ) }
258- onKeyDown = { ( e ) => {
259- if ( e . key === "Escape" ) setStep ( "menu" ) ;
260- if ( e . key === "Enter" && ! e . shiftKey && ! e . nativeEvent . isComposing ) {
261- e . preventDefault ( ) ;
262- if ( inputValue . trim ( ) || images . length > 0 ) {
263- onAnnotate ( activeType ! , inputValue || undefined , images . length > 0 ? images : undefined ) ;
264- }
265- }
266- } }
267- />
268- < AttachmentsButton
269- images = { images }
270- onAdd = { ( img ) => setImages ( ( prev ) => [ ...prev , img ] ) }
271- onRemove = { ( path ) => setImages ( ( prev ) => prev . filter ( ( i ) => i . path !== path ) ) }
272- variant = "inline"
273- />
274- < button
275- type = "submit"
276- disabled = { ! inputValue . trim ( ) && images . length === 0 }
277- className = "px-[15px] py-1 text-xs font-medium rounded bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-opacity self-stretch"
278- >
279- Save
280- </ button >
281- < button
282- type = "button"
283- onClick = { ( ) => setStep ( "menu" ) }
284- className = "p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
285- >
286- < CloseIcon small />
287- </ button >
288- </ form >
289- ) }
163+ < div className = "flex items-center p-1 gap-0.5" >
164+ < ToolbarButton
165+ onClick = { handleCopy }
166+ icon = { copied ? < CheckIcon /> : < CopyIcon /> }
167+ label = { copied ? "Copied!" : "Copy" }
168+ className = { copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground" }
169+ />
170+ < div className = "w-px h-5 bg-border mx-0.5" />
171+ < ToolbarButton
172+ onClick = { ( ) => handleTypeSelect ( AnnotationType . DELETION ) }
173+ icon = { < TrashIcon /> }
174+ label = "Delete"
175+ className = "text-destructive hover:bg-destructive/10"
176+ />
177+ < ToolbarButton
178+ onClick = { ( ) => handleTypeSelect ( AnnotationType . COMMENT ) }
179+ icon = { < CommentIcon /> }
180+ label = "Comment"
181+ className = "text-accent hover:bg-accent/10"
182+ />
183+ < div className = "w-px h-5 bg-border mx-0.5" />
184+ < ToolbarButton
185+ onClick = { onClose }
186+ icon = { < CloseIcon /> }
187+ label = "Cancel"
188+ className = "text-muted-foreground hover:bg-muted"
189+ />
190+ </ div >
290191 </ div > ,
291192 document . body
292193 ) ;
@@ -317,8 +218,8 @@ const CommentIcon = () => (
317218 </ svg >
318219) ;
319220
320- const CloseIcon : React . FC < { small ?: boolean } > = ( { small } ) => (
321- < svg className = { small ? "w-3.5 h-3.5" : "w- 4 h-4"} fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } >
221+ const CloseIcon = ( ) => (
222+ < svg className = "w-4 h-4" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } >
322223 < path strokeLinecap = "round" strokeLinejoin = "round" d = "M6 18L18 6M6 6l12 12" />
323224 </ svg >
324225) ;
0 commit comments