Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions packages/review-editor/components/AnnotationToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { ToolbarState } from '../hooks/useAnnotationToolbar';
import { useTabIndent } from '../hooks/useTabIndent';
import { formatLineRange } from '../utils/formatLineRange';
import { AskAIInput } from './AskAIInput';
import { SparklesIcon } from './SparklesIcon';
import type { AIChatEntry } from '../hooks/useAIChat';
import { useDraggable } from '@plannotator/ui/hooks/useDraggable';

interface AnnotationToolbarProps {
toolbarState: ToolbarState;
Expand Down Expand Up @@ -52,6 +53,12 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
}) => {
const handleTabIndent = useTabIndent(setSuggestedCode);
const [askAIMode, setAskAIMode] = useState(false);
const { dragPosition, dragHandleProps, wasDragged, reset: resetDrag } = useDraggable(toolbarRef);

// Reset drag when toolbar reopens for a new selection
useEffect(() => {
resetDrag();
}, [toolbarState.range.start, toolbarState.range.end, toolbarState.range.side, resetDrag]);

const handleAskAIClick = () => {
// If user already typed text in the comment box, send it directly as an AI question
Expand All @@ -78,13 +85,16 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
<div
ref={toolbarRef}
className="review-toolbar"
style={{
position: 'fixed',
top: Math.min(toolbarState.position.top, window.innerHeight - 200),
left: Math.max(150, Math.min(toolbarState.position.left, window.innerWidth - 150)),
transform: 'translateX(-50%)',
zIndex: 1000,
}}
style={dragPosition
? { position: 'fixed', top: dragPosition.top, left: dragPosition.left, zIndex: 1000 }
: {
position: 'fixed',
top: Math.min(toolbarState.position.top, window.innerHeight - 200),
left: Math.max(150, Math.min(toolbarState.position.left, window.innerWidth - 150)),
transform: 'translateX(-50%)',
zIndex: 1000,
}
}
>
{askAIMode ? (
<AskAIInput
Expand All @@ -96,10 +106,11 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
aiHistory={aiHistoryMessages}
onViewResponse={onViewAIResponse}
onSwitchToComment={() => setAskAIMode(false)}
dragHandleProps={dragHandleProps}
/>
) : (
<div className="w-80">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center justify-between mb-2" {...dragHandleProps}>
<span className="text-xs text-muted-foreground">
{isEditing ? 'Edit annotation' : formatLineRange(toolbarState.range.start, toolbarState.range.end)}
</span>
Expand Down
5 changes: 4 additions & 1 deletion packages/review-editor/components/AskAIInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ interface AskAIInputProps {
onViewResponse?: (questionId: string) => void;
/** Toggle back to comment mode */
onSwitchToComment?: () => void;
/** Props to make the header a drag handle */
dragHandleProps?: React.HTMLAttributes<HTMLDivElement> & { style?: React.CSSProperties };
}

export const AskAIInput: React.FC<AskAIInputProps> = ({
Expand All @@ -29,6 +31,7 @@ export const AskAIInput: React.FC<AskAIInputProps> = ({
aiHistory,
onViewResponse,
onSwitchToComment,
dragHandleProps,
}) => {
const [question, setQuestion] = useState(initialText);

Expand All @@ -40,7 +43,7 @@ export const AskAIInput: React.FC<AskAIInputProps> = ({

return (
<div className="w-80">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center justify-between mb-2" {...dragHandleProps}>
<span className="text-xs text-muted-foreground flex items-center gap-1.5">
<SparklesIcon className="w-3 h-3 text-primary" />
Ask AI · {formatLineRange(lineStart, lineEnd)}
Expand Down
37 changes: 23 additions & 14 deletions packages/ui/components/CommentPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import type { ImageAttachment } from '../types';
import { AttachmentsButton } from './AttachmentsButton';
import { submitHint } from '../utils/platform';
import { useDraggable } from '../hooks/useDraggable';

interface CommentPopoverProps {
/** Element to anchor the popover near (re-reads position on scroll) */
Expand Down Expand Up @@ -51,10 +52,15 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
const [position, setPosition] = useState<{ top: number; left: number; flipAbove: boolean; width: number } | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const { dragPosition, dragHandleProps, wasDragged, reset: resetDrag } = useDraggable(popoverRef);

// Track anchor position on scroll/resize (popover mode only)
// Reset drag when anchor changes (new annotation) or mode switches
useEffect(() => { resetDrag(); }, [anchorEl, resetDrag]);
useEffect(() => { if (mode === 'popover') resetDrag(); }, [mode, resetDrag]);

// Track anchor position on scroll/resize (popover mode only, not after user drag)
useEffect(() => {
if (mode !== 'popover') return;
if (mode !== 'popover' || wasDragged) return;

const update = () => {
setPosition(computePosition(anchorEl.getBoundingClientRect()));
Expand All @@ -67,7 +73,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, [anchorEl, mode]);
}, [anchorEl, mode, wasDragged]);

// Focus textarea on mount and mode changes
useEffect(() => {
Expand Down Expand Up @@ -221,15 +227,18 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
<div
ref={popoverRef}
className="fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col"
style={{
top: position.top,
left: position.left,
width: position.width,
...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
animation: position.flipAbove
? 'comment-popover-in-above 0.15s ease-out'
: 'comment-popover-in 0.15s ease-out',
}}
style={dragPosition
? { top: dragPosition.top, left: dragPosition.left, width: position.width }
: {
top: position.top,
left: position.left,
width: position.width,
...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
animation: position.flipAbove
? 'comment-popover-in-above 0.15s ease-out'
: 'comment-popover-in 0.15s ease-out',
}
}
onPointerDown={(e) => e.stopPropagation()}
>
<style>{`
Expand All @@ -243,8 +252,8 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
}
`}</style>

{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
{/* Header (draggable) */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50" {...dragHandleProps}>
<span className="text-xs text-muted-foreground truncate max-w-[260px]">
{headerLabel}
</span>
Expand Down
108 changes: 108 additions & 0 deletions packages/ui/hooks/useDraggable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { useState, useRef, useCallback, useEffect } from 'react';

const DRAG_THRESHOLD = 3;
const VISIBLE_MIN = 50;

interface DragPosition {
top: number;
left: number;
}

/**
* Makes a fixed-position element draggable by its header/handle.
* Reads the element's actual rendered position on drag start via getBoundingClientRect,
* so it works regardless of CSS transforms (flipAbove, translateX, etc.).
*
* @param elementRef - Ref to the positioned element (the popover/toolbar container)
*/
export function useDraggable(elementRef: React.RefObject<HTMLElement | null>) {
const [dragPosition, setDragPosition] = useState<DragPosition | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [wasDragged, setWasDragged] = useState(false);

const startPointerRef = useRef({ x: 0, y: 0 });
const startElRef = useRef({ top: 0, left: 0 });
const thresholdMetRef = useRef(false);

const reset = useCallback(() => {
setDragPosition(null);
setIsDragging(false);
setWasDragged(false);
}, []);

// Document-level pointermove/pointerup while dragging
useEffect(() => {
if (!isDragging) return;

const onMove = (e: PointerEvent) => {
const dx = e.clientX - startPointerRef.current.x;
const dy = e.clientY - startPointerRef.current.y;

if (!thresholdMetRef.current) {
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
thresholdMetRef.current = true;
}

// Clamp to keep at least VISIBLE_MIN px visible on screen
const top = Math.max(
-((elementRef.current?.offsetHeight ?? 0) - VISIBLE_MIN),
Math.min(startElRef.current.top + dy, window.innerHeight - VISIBLE_MIN),
);
const left = Math.max(
-((elementRef.current?.offsetWidth ?? 0) - VISIBLE_MIN),
Math.min(startElRef.current.left + dx, window.innerWidth - VISIBLE_MIN),
);

setDragPosition({ top, left });
};

const onUp = () => {
setIsDragging(false);
if (thresholdMetRef.current) {
setWasDragged(true);
}
};

document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
return () => {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
};
}, [isDragging, elementRef]);

const onPointerDown = useCallback(
(e: React.PointerEvent) => {
// Only primary button (left click / single touch)
if (e.button !== 0) return;
// Don't drag if clicking on an interactive element inside the handle
const target = e.target as HTMLElement;
if (target.closest('button, a, input, textarea, select')) return;

const el = elementRef.current;
if (!el) return;

const rect = el.getBoundingClientRect();
startPointerRef.current = { x: e.clientX, y: e.clientY };
startElRef.current = { top: rect.top, left: rect.left };
thresholdMetRef.current = false;
setIsDragging(true);
},
[elementRef],
);

return {
dragPosition,
isDragging,
wasDragged,
reset,
dragHandleProps: {
onPointerDown,
style: {
cursor: isDragging ? 'grabbing' : 'grab',
userSelect: 'none' as const,
touchAction: 'none' as const,
},
},
};
}