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
1 change: 0 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 27 additions & 56 deletions packages/ui/components/AnnotationToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import React, { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { AnnotationType } from "../types";
import { createPortal } from "react-dom";
import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
import { type QuickLabel, getQuickLabels, getLabelColors } from "../utils/quickLabels";
import { type QuickLabel, getQuickLabels } from "../utils/quickLabels";
import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";

type PositionMode = 'center-above' | 'top-right';

Expand Down Expand Up @@ -50,6 +51,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
const [copied, setCopied] = useState(false);
const [showQuickLabels, setShowQuickLabels] = useState(false);
const toolbarRef = useRef<HTMLDivElement>(null);
const zapButtonRef = useRef<HTMLButtonElement>(null);
const quickLabels = useMemo(() => getQuickLabels(), []);

const handleCopy = async () => {
Expand Down Expand Up @@ -101,21 +103,26 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
};
}, [element, positionMode, closeOnScrollOut, onClose]);

// Type-to-comment + Alt+N quick label shortcuts
// Type-to-comment + Alt+N / bare digit quick label shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return;
if (isEditableElement(e.target) || isEditableElement(document.activeElement)) return;

// When picker is open, let FloatingQuickLabelPicker own all keyboard input
if (showQuickLabels) return;

if (e.key === "Escape") {
setShowQuickLabels(false);
onClose();
return;
}

// Alt+1..8: apply quick label
if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit8') {
// Alt+N applies quick label (picker closed)
const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0';
if (isDigit && !e.ctrlKey && !e.metaKey && e.altKey) {
e.preventDefault();
const index = parseInt(e.code.slice(5), 10) - 1;
const digit = parseInt(e.code.slice(5), 10);
const index = digit === 0 ? 9 : digit - 1;
if (index < quickLabels.length) {
onQuickLabel?.(quickLabels[index]);
}
Expand All @@ -131,10 +138,10 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose, onRequestComment, onQuickLabel, quickLabels]);
}, [onClose, onRequestComment, onQuickLabel, quickLabels, showQuickLabels]);

useDismissOnOutsideAndEscape({
enabled: true,
enabled: !showQuickLabels,
ref: toolbarRef,
onDismiss: onClose,
});
Expand Down Expand Up @@ -202,23 +209,25 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
className="text-accent hover:bg-accent/10"
/>
{onQuickLabel && (
<div className="relative">
<>
<ToolbarButton
ref={zapButtonRef}
onClick={() => setShowQuickLabels(prev => !prev)}
icon={<ZapIcon />}
label="Quick label"
className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"}
/>
{showQuickLabels && (
<QuickLabelDropdown
labels={quickLabels}
{showQuickLabels && zapButtonRef.current && (
<FloatingQuickLabelPicker
anchorEl={zapButtonRef.current}
onSelect={(label) => {
setShowQuickLabels(false);
onQuickLabel(label);
}}
onDismiss={() => setShowQuickLabels(false)}
/>
)}
</div>
</>
)}
<div className="w-px h-5 bg-border mx-0.5" />
<ToolbarButton
Expand All @@ -233,45 +242,6 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
);
};

// Quick Label Dropdown
const QuickLabelDropdown: React.FC<{
labels: QuickLabel[];
onSelect: (label: QuickLabel) => void;
}> = ({ labels, onSelect }) => {
const isMac = navigator.platform?.includes('Mac');
const altKey = isMac ? '⌥' : 'Alt+';

return (
<div
className="absolute top-full left-1/2 -translate-x-1/2 mt-1.5 bg-popover border border-border rounded-lg shadow-2xl p-2 min-w-[220px] z-[101]"
style={{ animation: 'annotation-toolbar-in 0.1s ease-out' }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="text-[10px] text-muted-foreground/60 px-1 mb-1.5 font-medium uppercase tracking-wide">Quick Labels</div>
<div className="flex flex-wrap gap-1">
{labels.map((label, index) => {
const colors = getLabelColors(label.color);
return (
<button
key={label.id}
onClick={() => onSelect(label)}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition-opacity hover:opacity-75 active:opacity-60"
style={{ backgroundColor: colors.bg, color: colors.text }}
title={index < 8 ? `${altKey}${index + 1}` : undefined}
>
<span>{label.emoji}</span>
<span>{label.text}</span>
{index < 8 && (
<span className="text-[9px] opacity-40 ml-0.5">{index + 1}</span>
)}
</button>
);
})}
</div>
</div>
);
};

// Icons
const CopyIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
Expand Down Expand Up @@ -309,17 +279,18 @@ const CloseIcon = () => (
</svg>
);

const ToolbarButton: React.FC<{
const ToolbarButton = React.forwardRef<HTMLButtonElement, {
onClick: () => void;
icon: React.ReactNode;
label: string;
className: string;
}> = ({ onClick, icon, label, className }) => (
}>(({ onClick, icon, label, className }, ref) => (
<button
ref={ref}
onClick={onClick}
title={label}
className={`p-1.5 rounded-md transition-colors ${className}`}
>
{icon}
</button>
);
));
17 changes: 17 additions & 0 deletions packages/ui/components/AnnotationToolstrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,18 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
</svg>
}
/>
<ToolstripButton
active={mode === 'quickLabel'}
onClick={() => onModeChange('quickLabel')}
label="Label"
color="warning"
mounted={mounted}
icon={
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
}
/>
</div>

{/* Help */}
Expand Down Expand Up @@ -203,6 +215,11 @@ const colorStyles = {
hover: 'text-destructive/80 bg-destructive/8',
inactive: 'text-muted-foreground hover:text-foreground',
},
warning: {
active: 'bg-background text-foreground shadow-sm',
hover: 'text-amber-500/80 bg-amber-500/8',
inactive: 'text-muted-foreground hover:text-foreground',
},
} as const;

type ButtonColor = keyof typeof colorStyles;
Expand Down
143 changes: 143 additions & 0 deletions packages/ui/components/FloatingQuickLabelPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { type QuickLabel, getQuickLabels } from '../utils/quickLabels';
import { QuickLabelDropdown } from './QuickLabelDropdown';

interface FloatingQuickLabelPickerProps {
anchorEl: HTMLElement;
/** Mouse coordinates at the moment of selection — picker appears here */
cursorHint?: { x: number; y: number };
onSelect: (label: QuickLabel) => void;
onDismiss: () => void;
}

const PICKER_WIDTH = 192;
const GAP = 6;
const VIEWPORT_PADDING = 12;

function computePosition(
anchorEl: HTMLElement,
cursorHint?: { x: number; y: number },
): { top: number; left: number; flipAbove: boolean } {
const rect = anchorEl.getBoundingClientRect();

// Vertical: use anchor rect for above/below decision + placement
const spaceBelow = window.innerHeight - rect.bottom;
const flipAbove = spaceBelow < 220;
const top = flipAbove ? rect.top - GAP : rect.bottom + GAP;

// Horizontal: prefer cursor x, fallback to anchor right edge
let left: number;
if (cursorHint) {
// Anchor left edge of picker at cursor x, nudge left slightly so
// the first row's text is directly under the pointer
left = cursorHint.x - 28;
} else {
// Fallback: right edge of anchor (where selection likely ended)
left = rect.right - PICKER_WIDTH / 2;
}

// Clamp to viewport
left = Math.max(VIEWPORT_PADDING, Math.min(left, window.innerWidth - PICKER_WIDTH - VIEWPORT_PADDING));

return { top, left, flipAbove };
}

export const FloatingQuickLabelPicker: React.FC<FloatingQuickLabelPickerProps> = ({
anchorEl,
cursorHint,
onSelect,
onDismiss,
}) => {
const [position, setPosition] = useState<{ top: number; left: number; flipAbove: boolean } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const quickLabels = useMemo(() => getQuickLabels(), []);

// Position tracking
useEffect(() => {
const update = () => setPosition(computePosition(anchorEl, cursorHint));
update();
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, [anchorEl, cursorHint]);

// Keyboard: 1-9/0 or Alt+1-9/0 to apply label, Escape to dismiss
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onDismiss();
return;
}
// Accept bare digit or Alt+digit — picker is open so digits mean labels
const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0';
if (isDigit && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
const digit = parseInt(e.code.slice(5), 10);
const index = digit === 0 ? 9 : digit - 1;
if (index < quickLabels.length) {
onSelect(quickLabels[index]);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onDismiss, onSelect, quickLabels]);

// Click outside to dismiss
useEffect(() => {
const handlePointerDown = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onDismiss();
}
};
// Defer to avoid catching the triggering click
const timer = setTimeout(() => {
document.addEventListener('pointerdown', handlePointerDown, true);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('pointerdown', handlePointerDown, true);
};
}, [onDismiss]);

if (!position) return null;

const animName = position.flipAbove ? 'qlp-in-above' : 'qlp-in-below';

return createPortal(
<div
ref={ref}
data-quick-label-picker
className="fixed z-[100]"
style={{
top: position.top,
left: position.left,
width: PICKER_WIDTH,
...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
animation: `${animName} 0.12s ease-out`,
}}
onMouseDown={(e) => e.stopPropagation()}
>
<style>{`
@keyframes qlp-in-below {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes qlp-in-above {
from { opacity: 0; transform: translateY(-100%) translateY(4px); }
to { opacity: 1; transform: translateY(-100%); }
}
`}</style>

<div className="bg-popover border border-border/60 rounded-lg shadow-xl overflow-hidden">
<QuickLabelDropdown labels={quickLabels} onSelect={onSelect} animate />
</div>
</div>,
document.body
);
};
2 changes: 1 addition & 1 deletion packages/ui/components/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const planShortcuts: ShortcutSection[] = [
title: 'Annotations',
shortcuts: [
{ keys: ['a-z'], desc: 'Start typing comment', hint: 'When the annotation toolbar is open, any letter key opens the comment editor with that character' },
{ keys: [alt, '1-8'], desc: 'Apply quick label', hint: 'When the toolbar is open, instantly applies the Nth preset label as an annotation' },
{ keys: [alt, '1-0'], desc: 'Apply quick label', hint: 'Instantly applies the Nth preset label (0 = 10th). When the label picker is open, bare digits also work.' },
{ keys: [mod, enter], desc: 'Submit comment' },
{ keys: [mod, 'C'], desc: 'Copy selected text' },
{ keys: ['Esc'], desc: 'Close toolbar / Cancel' },
Expand Down
Loading
Loading