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
93 changes: 89 additions & 4 deletions packages/ui/components/AnnotationToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { AnnotationType } from "../types";
import { createPortal } from "react-dom";
import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
import { type QuickLabel, getQuickLabels, getLabelColors } from "../utils/quickLabels";

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

Expand All @@ -19,6 +20,8 @@ interface AnnotationToolbarProps {
onClose: () => void;
/** Called when user wants to write a comment (opens CommentPopover in parent) */
onRequestComment?: (initialChar?: string) => void;
/** Called when a quick label chip is selected */
onQuickLabel?: (label: QuickLabel) => void;
/** Text to copy (for text selection, pass source.text) */
copyText?: string;
/** Close toolbar when element scrolls out of viewport */
Expand All @@ -36,6 +39,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
onAnnotate,
onClose,
onRequestComment,
onQuickLabel,
copyText,
closeOnScrollOut = false,
isExiting = false,
Expand All @@ -44,7 +48,9 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
}) => {
const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
const [copied, setCopied] = useState(false);
const [showQuickLabels, setShowQuickLabels] = useState(false);
const toolbarRef = useRef<HTMLDivElement>(null);
const quickLabels = useMemo(() => getQuickLabels(), []);

const handleCopy = async () => {
let textToCopy = copyText;
Expand Down Expand Up @@ -95,12 +101,27 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
};
}, [element, positionMode, closeOnScrollOut, onClose]);

// Type-to-comment: typing opens CommentPopover via parent
// Type-to-comment + Alt+N quick label shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return;
if (isEditableElement(e.target) || isEditableElement(document.activeElement)) return;
if (e.key === "Escape") { onClose(); return; }
if (e.key === "Escape") {
setShowQuickLabels(false);
onClose();
return;
}

// Alt+1..8: apply quick label
if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit8') {
e.preventDefault();
const index = parseInt(e.code.slice(5), 10) - 1;
if (index < quickLabels.length) {
onQuickLabel?.(quickLabels[index]);
}
return;
}

if (e.ctrlKey || e.metaKey || e.altKey) return;
if (e.key === "Tab" || e.key === "Enter") return;
if (e.key.length !== 1) return;
Expand All @@ -110,7 +131,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({

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

useDismissOnOutsideAndEscape({
enabled: true,
Expand Down Expand Up @@ -180,6 +201,25 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
label="Comment"
className="text-accent hover:bg-accent/10"
/>
{onQuickLabel && (
<div className="relative">
<ToolbarButton
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}
onSelect={(label) => {
setShowQuickLabels(false);
onQuickLabel(label);
}}
/>
)}
</div>
)}
<div className="w-px h-5 bg-border mx-0.5" />
<ToolbarButton
onClick={onClose}
Expand All @@ -193,6 +233,45 @@ 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 All @@ -218,6 +297,12 @@ const CommentIcon = () => (
</svg>
);

const ZapIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
);

const CloseIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
Expand Down
1 change: 1 addition & 0 deletions packages/ui/components/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +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: [mod, enter], desc: 'Submit comment' },
{ keys: [mod, 'C'], desc: 'Copy selected text' },
{ keys: ['Esc'], desc: 'Close toolbar / Cancel' },
Expand Down
108 changes: 107 additions & 1 deletion packages/ui/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ import {
} from '../utils/defaultNotesApp';
import { useAgents } from '../hooks/useAgents';
import { KeyboardShortcuts } from './KeyboardShortcuts';
import { type QuickLabel, getQuickLabels, saveQuickLabels, resetQuickLabels, DEFAULT_QUICK_LABELS, getLabelColors, LABEL_COLOR_MAP } from '../utils/quickLabels';

type SettingsTab = 'general' | 'display' | 'saving' | 'shortcuts' | 'obsidian' | 'bear';
type SettingsTab = 'general' | 'display' | 'saving' | 'labels' | 'shortcuts' | 'obsidian' | 'bear';

interface SettingsProps {
taterMode: boolean;
Expand Down Expand Up @@ -81,6 +82,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
const [agentWarning, setAgentWarning] = useState<string | null>(null);
const [autoCloseDelay, setAutoCloseDelayState] = useState<AutoCloseDelay>('off');
const [defaultNotesApp, setDefaultNotesApp] = useState<DefaultNotesApp>('ask');
const [quickLabelsState, setQuickLabelsState] = useState<QuickLabel[]>([]);

// Fetch available agents for OpenCode
const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin ?? null);
Expand All @@ -90,6 +92,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
if (mode === 'plan') {
t.push({ id: 'display', label: 'Display' });
t.push({ id: 'saving', label: 'Saving' });
t.push({ id: 'labels', label: 'Labels' });
}
t.push({ id: 'shortcuts', label: 'Shortcuts' });
return t;
Expand Down Expand Up @@ -118,6 +121,7 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
setPermissionMode(getPermissionModeSettings().mode);
setAutoCloseDelayState(getAutoCloseDelay());
setDefaultNotesApp(getDefaultNotesApp());
setQuickLabelsState(getQuickLabels());

// Validate agent setting when dialog opens
if (origin === 'opencode') {
Expand Down Expand Up @@ -751,6 +755,108 @@ export const Settings: React.FC<SettingsProps> = ({ taterMode, onTaterModeChange
</>
)}

{/* === LABELS TAB === */}
{activeTab === 'labels' && (
<>
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Quick Labels</div>
<div className="text-xs text-muted-foreground">
Preset annotations for one-click feedback
</div>
</div>
<button
onClick={() => {
resetQuickLabels();
setQuickLabelsState(DEFAULT_QUICK_LABELS);
}}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
Reset to defaults
</button>
</div>

<div className="space-y-1.5">
{quickLabelsState.map((label, index) => {
const colors = getLabelColors(label.color);
return (
<div key={index} className="flex items-center gap-2 p-2 rounded-lg" style={{ backgroundColor: colors.bg }}>
<span className="text-sm flex-shrink-0">{label.emoji}</span>
<input
type="text"
value={label.text}
onChange={(e) => {
const updated = [...quickLabelsState];
updated[index] = {
...label,
text: e.target.value,
id: e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
};
setQuickLabelsState(updated);
saveQuickLabels(updated);
}}
className="flex-1 px-2 py-1 bg-background/80 rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<select
value={label.color}
onChange={(e) => {
const updated = [...quickLabelsState];
updated[index] = { ...label, color: e.target.value };
setQuickLabelsState(updated);
saveQuickLabels(updated);
}}
className="px-1.5 py-1 bg-background/80 rounded text-[10px] focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{Object.keys(LABEL_COLOR_MAP).map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
<span className="text-[10px] text-muted-foreground/50 font-mono w-8 text-center flex-shrink-0">
{index < 8 ? `${navigator.platform?.includes('Mac') ? '⌥' : 'Alt+'}${index + 1}` : ''}
</span>
<button
onClick={() => {
const updated = quickLabelsState.filter((_, i) => i !== index);
setQuickLabelsState(updated);
saveQuickLabels(updated);
}}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors flex-shrink-0"
title="Remove label"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
);
})}
</div>

{quickLabelsState.length < 12 && (
<button
onClick={() => {
const newLabel: QuickLabel = {
id: `custom-${Date.now()}`,
emoji: '📌',
text: 'New label',
color: 'blue',
};
const updated = [...quickLabelsState, newLabel];
setQuickLabelsState(updated);
saveQuickLabels(updated);
}}
className="w-full py-1.5 text-xs text-muted-foreground hover:text-foreground border border-dashed border-border rounded-lg hover:border-foreground/30 transition-colors"
>
+ Add label
</button>
)}

<div className="text-[10px] text-muted-foreground/70">
Use {navigator.platform?.includes('Mac') ? '⌥' : 'Alt+'}1 through {navigator.platform?.includes('Mac') ? '⌥' : 'Alt+'}8 when the annotation toolbar is visible to apply a label instantly.
</div>
</>
)}

{/* === SHORTCUTS TAB === */}
{activeTab === 'shortcuts' && (
<KeyboardShortcuts mode={mode} />
Expand Down
Loading
Loading