diff --git a/packages/ui/components/AnnotationToolbar.tsx b/packages/ui/components/AnnotationToolbar.tsx index 7fbbb6759..9fb2f1dcf 100644 --- a/packages/ui/components/AnnotationToolbar.tsx +++ b/packages/ui/components/AnnotationToolbar.tsx @@ -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'; @@ -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 */ @@ -36,6 +39,7 @@ export const AnnotationToolbar: React.FC = ({ onAnnotate, onClose, onRequestComment, + onQuickLabel, copyText, closeOnScrollOut = false, isExiting = false, @@ -44,7 +48,9 @@ export const AnnotationToolbar: React.FC = ({ }) => { 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(null); + const quickLabels = useMemo(() => getQuickLabels(), []); const handleCopy = async () => { let textToCopy = copyText; @@ -95,12 +101,27 @@ export const AnnotationToolbar: React.FC = ({ }; }, [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; @@ -110,7 +131,7 @@ export const AnnotationToolbar: React.FC = ({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [onClose, onRequestComment]); + }, [onClose, onRequestComment, onQuickLabel, quickLabels]); useDismissOnOutsideAndEscape({ enabled: true, @@ -180,6 +201,25 @@ export const AnnotationToolbar: React.FC = ({ label="Comment" className="text-accent hover:bg-accent/10" /> + {onQuickLabel && ( +
+ setShowQuickLabels(prev => !prev)} + icon={} + label="Quick label" + className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"} + /> + {showQuickLabels && ( + { + setShowQuickLabels(false); + onQuickLabel(label); + }} + /> + )} +
+ )}
= ({ ); }; +// 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 ( +
e.stopPropagation()} + > +
Quick Labels
+
+ {labels.map((label, index) => { + const colors = getLabelColors(label.color); + return ( + + ); + })} +
+
+ ); +}; + // Icons const CopyIcon = () => ( @@ -218,6 +297,12 @@ const CommentIcon = () => ( ); +const ZapIcon = () => ( + + + +); + const CloseIcon = () => ( diff --git a/packages/ui/components/KeyboardShortcuts.tsx b/packages/ui/components/KeyboardShortcuts.tsx index c5c8e5d8c..713ed2780 100644 --- a/packages/ui/components/KeyboardShortcuts.tsx +++ b/packages/ui/components/KeyboardShortcuts.tsx @@ -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' }, diff --git a/packages/ui/components/Settings.tsx b/packages/ui/components/Settings.tsx index 23091b041..0d713b07f 100644 --- a/packages/ui/components/Settings.tsx +++ b/packages/ui/components/Settings.tsx @@ -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; @@ -81,6 +82,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange const [agentWarning, setAgentWarning] = useState(null); const [autoCloseDelay, setAutoCloseDelayState] = useState('off'); const [defaultNotesApp, setDefaultNotesApp] = useState('ask'); + const [quickLabelsState, setQuickLabelsState] = useState([]); // Fetch available agents for OpenCode const { agents: availableAgents, validateAgent, getAgentWarning } = useAgents(origin ?? null); @@ -90,6 +92,7 @@ export const Settings: React.FC = ({ 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; @@ -118,6 +121,7 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange setPermissionMode(getPermissionModeSettings().mode); setAutoCloseDelayState(getAutoCloseDelay()); setDefaultNotesApp(getDefaultNotesApp()); + setQuickLabelsState(getQuickLabels()); // Validate agent setting when dialog opens if (origin === 'opencode') { @@ -751,6 +755,108 @@ export const Settings: React.FC = ({ taterMode, onTaterModeChange )} + {/* === LABELS TAB === */} + {activeTab === 'labels' && ( + <> +
+
+
Quick Labels
+
+ Preset annotations for one-click feedback +
+
+ +
+ +
+ {quickLabelsState.map((label, index) => { + const colors = getLabelColors(label.color); + return ( +
+ {label.emoji} + { + 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" + /> + + + {index < 8 ? `${navigator.platform?.includes('Mac') ? 'โŒฅ' : 'Alt+'}${index + 1}` : ''} + + +
+ ); + })} +
+ + {quickLabelsState.length < 12 && ( + + )} + +
+ 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. +
+ + )} + {/* === SHORTCUTS TAB === */} {activeTab === 'shortcuts' && ( diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index f5cb162b4..a61401924 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -5,6 +5,24 @@ import 'highlight.js/styles/github-dark.css'; import { Block, Annotation, AnnotationType, EditorMode, type InputMethod, type ImageAttachment } from '../types'; import { Frontmatter } from '../utils/parser'; import { AnnotationToolbar } from './AnnotationToolbar'; + +// Debug error boundary to catch silent toolbar crashes +class ToolbarErrorBoundary extends React.Component< + { children: React.ReactNode }, + { error: Error | null } +> { + state = { error: null as Error | null }; + static getDerivedStateFromError(error: Error) { return { error }; } + componentDidCatch(error: Error) { console.error('AnnotationToolbar crashed:', error); } + render() { + if (this.state.error) { + return
+ Toolbar error: {this.state.error.message} +
; + } + return this.props.children; + } +} import { CommentPopover } from './CommentPopover'; import { TaterSpriteSitting } from './TaterSpriteSitting'; import { AttachmentsButton } from './AttachmentsButton'; @@ -12,6 +30,7 @@ import { GraphvizBlock } from './GraphvizBlock'; import { MermaidBlock } from './MermaidBlock'; import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages'; import { getIdentity } from '../utils/identity'; +import { type QuickLabel } from '../utils/quickLabels'; import { PlanDiffBadge } from './plan-diff/PlanDiffBadge'; import { PinpointOverlay } from './PinpointOverlay'; import { usePinpoint } from '../hooks/usePinpoint'; @@ -225,7 +244,8 @@ export const Viewer = forwardRef(({ source: any, type: AnnotationType, text?: string, - images?: ImageAttachment[] + images?: ImageAttachment[], + isQuickLabel?: boolean, ) => { const doms = highlighter.getDoms(source.id); let blockId = ''; @@ -258,6 +278,7 @@ export const Viewer = forwardRef(({ startMeta: source.startMeta, endMeta: source.endMeta, images, + ...(isQuickLabel ? { isQuickLabel: true } : {}), }; if (type === AnnotationType.DELETION) { @@ -562,9 +583,11 @@ export const Viewer = forwardRef(({ // root, so the web-highlighter's built-in PointerEnd listener never triggers. // This selectionchange listener detects valid selections and uses the highlighter's // public fromRange() API to programmatically create the highlight and emit CREATE. - const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + // Use (pointer: coarse) instead of 'ontouchstart' in window โ€” the latter is true on + // desktop Chrome when the machine has a touchscreen or DevTools touch was toggled. + const isTouchPrimary = window.matchMedia('(pointer: coarse)').matches; let selectionTimer: ReturnType; - const handleSelectionChange = isTouchDevice ? () => { + const handleSelectionChange = isTouchPrimary ? () => { clearTimeout(selectionTimer); selectionTimer = setTimeout(() => { const sel = window.getSelection(); @@ -663,6 +686,19 @@ export const Viewer = forwardRef(({ window.getSelection()?.removeAllRanges(); }; + const handleQuickLabel = (label: QuickLabel) => { + const highlighter = highlighterRef.current; + if (!toolbarState || !highlighter) return; + + createAnnotationFromSource( + highlighter, toolbarState.source, AnnotationType.COMMENT, + `${label.emoji} ${label.text}`, undefined, true + ); + pendingSourceRef.current = null; + setToolbarState(null); + window.getSelection()?.removeAllRanges(); + }; + const handleToolbarClose = () => { if (toolbarState && highlighterRef.current) { highlighterRef.current.remove(toolbarState.source.id); @@ -678,6 +714,7 @@ export const Viewer = forwardRef(({ type: AnnotationType, text?: string, images?: ImageAttachment[], + isQuickLabel?: boolean, ) => { const id = `codeblock-${Date.now()}`; const codeText = codeEl.textContent || ''; @@ -701,6 +738,7 @@ export const Viewer = forwardRef(({ createdA: Date.now(), author: getIdentity(), images, + ...(isQuickLabel ? { isQuickLabel: true } : {}), }; justCreatedIdRef.current = newAnnotation.id; @@ -716,6 +754,17 @@ export const Viewer = forwardRef(({ setHoveredCodeBlock(null); }; + const handleCodeBlockQuickLabel = (label: QuickLabel) => { + if (!hoveredCodeBlock) return; + const codeEl = hoveredCodeBlock.element.querySelector('code'); + if (!codeEl) return; + applyCodeBlockAnnotation( + hoveredCodeBlock.block.id, codeEl, AnnotationType.COMMENT, + `${label.emoji} ${label.text}`, undefined, true + ); + setHoveredCodeBlock(null); + }; + const handleCodeBlockToolbarClose = () => { setHoveredCodeBlock(null); }; @@ -967,25 +1016,30 @@ export const Viewer = forwardRef(({ {/* Text selection toolbar */} {toolbarState && ( - + + + )} {/* Code block hover toolbar */} {hoveredCodeBlock && !toolbarState && ( + { if (hoverTimeoutRef.current) { @@ -1004,6 +1058,7 @@ export const Viewer = forwardRef(({ }, 100); }} /> + )} {/* Pinpoint hover overlay */} diff --git a/packages/ui/types.ts b/packages/ui/types.ts index 1329efecc..4b71fde4e 100644 --- a/packages/ui/types.ts +++ b/packages/ui/types.ts @@ -26,6 +26,7 @@ export interface Annotation { createdA: number; author?: string; // Tater identity for collaborative sharing images?: ImageAttachment[]; // Attached images with human-readable names + isQuickLabel?: boolean; // true if created via quick label chip // web-highlighter metadata for cross-element selections startMeta?: { parentTagName: string; diff --git a/packages/ui/utils/parser.ts b/packages/ui/utils/parser.ts index cc77b50ac..14fe91ff4 100644 --- a/packages/ui/utils/parser.ts +++ b/packages/ui/utils/parser.ts @@ -296,8 +296,12 @@ export const exportAnnotations = (blocks: Block[], annotations: any[], globalAtt break; case 'COMMENT': - output += `Feedback on: "${ann.originalText}"\n`; - output += `> ${ann.text}\n`; + if (ann.isQuickLabel) { + output += `[${ann.text}] Feedback on: "${ann.originalText}"\n`; + } else { + output += `Feedback on: "${ann.originalText}"\n`; + output += `> ${ann.text}\n`; + } break; case 'GLOBAL_COMMENT': @@ -319,6 +323,21 @@ export const exportAnnotations = (blocks: Block[], annotations: any[], globalAtt output += `---\n`; + // Quick Label Summary + const labeledAnns = sortedAnns.filter((a: any) => a.isQuickLabel && a.text); + if (labeledAnns.length > 0) { + const grouped = new Map(); + labeledAnns.forEach((a: any) => { + grouped.set(a.text, (grouped.get(a.text) || 0) + 1); + }); + + output += `\n## Label Summary\n\n`; + for (const [text, count] of grouped) { + output += `- **${text}**: ${count}\n`; + } + output += '\n'; + } + return output; }; diff --git a/packages/ui/utils/quickLabels.ts b/packages/ui/utils/quickLabels.ts new file mode 100644 index 000000000..b231d6ee3 --- /dev/null +++ b/packages/ui/utils/quickLabels.ts @@ -0,0 +1,72 @@ +/** + * Quick Labels โ€” preset annotation labels for one-click feedback + * + * Labels are stored in cookies (same pattern as other settings) + * so they persist across different port-based sessions. + */ + +import { storage } from './storage'; + +const STORAGE_KEY = 'plannotator-quick-labels'; + +export interface QuickLabel { + id: string; // kebab-case identifier e.g. "needs-tests" + emoji: string; // single emoji e.g. "๐Ÿงช" + text: string; // display text e.g. "Needs tests" + color: string; // key into LABEL_COLOR_MAP +} + +/** Inline styles for label colors (avoids Tailwind dynamic class purging) */ +export const LABEL_COLOR_MAP: Record = { + blue: { bg: 'rgba(59,130,246,0.15)', text: '#2563eb', darkText: '#60a5fa' }, + red: { bg: 'rgba(239,68,68,0.15)', text: '#dc2626', darkText: '#f87171' }, + orange: { bg: 'rgba(249,115,22,0.15)', text: '#ea580c', darkText: '#fb923c' }, + yellow: { bg: 'rgba(234,179,8,0.15)', text: '#ca8a04', darkText: '#facc15' }, + purple: { bg: 'rgba(147,51,234,0.15)', text: '#9333ea', darkText: '#a78bfa' }, + teal: { bg: 'rgba(20,184,166,0.15)', text: '#0d9488', darkText: '#2dd4bf' }, + pink: { bg: 'rgba(236,72,153,0.15)', text: '#db2777', darkText: '#f472b6' }, + green: { bg: 'rgba(34,197,94,0.15)', text: '#16a34a', darkText: '#4ade80' }, +}; + +export const DEFAULT_QUICK_LABELS: QuickLabel[] = [ + { id: 'needs-tests', emoji: '๐Ÿงช', text: 'Needs tests', color: 'blue' }, + { id: 'security-concern', emoji: '๐Ÿ”’', text: 'Security concern', color: 'red' }, + { id: 'break-this-up', emoji: 'โœ‚๏ธ', text: 'Break this up', color: 'orange' }, + { id: 'clarify-this-step', emoji: 'โ“', text: 'Clarify this step', color: 'yellow' }, + { id: 'wrong-order', emoji: '๐Ÿ”€', text: 'Wrong order', color: 'purple' }, + { id: 'consider-edge-cases', emoji: '๐Ÿงฉ', text: 'Consider edge cases', color: 'teal' }, + { id: 'discuss-first', emoji: '๐Ÿ’ฌ', text: 'Discuss first', color: 'pink' }, + { id: 'nice-approach', emoji: '๐Ÿ‘', text: 'Nice approach', color: 'green' }, +]; + +export function getQuickLabels(): QuickLabel[] { + const raw = storage.getItem(STORAGE_KEY); + if (!raw) return DEFAULT_QUICK_LABELS; + try { + const parsed = JSON.parse(raw) as QuickLabel[]; + return parsed.length > 0 ? parsed : DEFAULT_QUICK_LABELS; + } catch { + return DEFAULT_QUICK_LABELS; + } +} + +export function saveQuickLabels(labels: QuickLabel[]): void { + storage.setItem(STORAGE_KEY, JSON.stringify(labels)); +} + +export function resetQuickLabels(): void { + storage.removeItem(STORAGE_KEY); +} + +/** Find a configured label whose "emoji text" matches an annotation's text field */ +export function findLabelByText(annotationText: string): QuickLabel | undefined { + return getQuickLabels().find(l => `${l.emoji} ${l.text}` === annotationText); +} + +/** Get color styles for a label, respecting dark mode */ +export function getLabelColors(color: string): { bg: string; text: string } { + const colors = LABEL_COLOR_MAP[color]; + if (!colors) return { bg: 'rgba(128,128,128,0.15)', text: '#666' }; + const isDark = document.documentElement.classList.contains('dark'); + return { bg: colors.bg, text: isDark ? colors.darkText : colors.text }; +} diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts index 9799ffd12..bd7715f15 100644 --- a/packages/ui/utils/sharing.ts +++ b/packages/ui/utils/sharing.ts @@ -15,13 +15,13 @@ import { encrypt, decrypt } from '@plannotator/shared/crypto'; // Image in shareable format: plain string (old) or [path, name] tuple (new) type ShareableImage = string | [string, string]; -// Minimal shareable annotation format: [type, originalText, text?, author?, images?] +// Minimal shareable annotation format: [type, originalText, text?, author?, images?, quickLabel?] export type ShareableAnnotation = - | ['D', string, string | null, ShareableImage[]?] // Deletion: type, original, author, images - | ['R', string, string, string | null, ShareableImage[]?] // Replacement: type, original, replacement, author, images - | ['C', string, string, string | null, ShareableImage[]?] // Comment: type, original, comment, author, images - | ['I', string, string, string | null, ShareableImage[]?] // Insertion: type, context, new text, author, images - | ['G', string, string | null, ShareableImage[]?]; // Global Comment: type, comment, author, images + | ['D', string, string | null, ShareableImage[]?] // Deletion: type, original, author, images + | ['R', string, string, string | null, ShareableImage[]?] // Replacement: type, original, replacement, author, images + | ['C', string, string, string | null, ShareableImage[]?, (1)?] // Comment: type, original, comment, author, images, isQuickLabel + | ['I', string, string, string | null, ShareableImage[]?] // Insertion: type, context, new text, author, images + | ['G', string, string | null, ShareableImage[]?]; // Global Comment: type, comment, author, images export interface SharePayload { p: string; // plan markdown @@ -75,6 +75,9 @@ export function toShareable(annotations: Annotation[]): ShareableAnnotation[] { } // R, C, I all have text + if (type === 'C' && ann.isQuickLabel) { + return ['C', ann.originalText, ann.text || '', author, images ?? undefined, 1] as ShareableAnnotation; + } return [type, ann.originalText, ann.text || '', author, images] as ShareableAnnotation; }); } @@ -122,6 +125,8 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { const text = type === 'D' ? undefined : item[2] as string; const author = type === 'D' ? item[2] as string | null : item[3] as string | null; const rawImages = type === 'D' ? item[3] as ShareableImage[] | undefined : item[4] as ShareableImage[] | undefined; + // Comment annotations may have isQuickLabel flag at index 5 + const isQuickLabel = type === 'C' && item.length > 5 && item[5] === 1 ? true : undefined; return { id: `shared-${index}-${Date.now()}`, @@ -134,6 +139,7 @@ export function fromShareable(data: ShareableAnnotation[]): Annotation[] { createdA: Date.now() + index, // Preserve order author: author || undefined, images: parseShareableImages(rawImages), + ...(isQuickLabel ? { isQuickLabel } : {}), // startMeta/endMeta will be set by web-highlighter }; });