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, quickLabelIndexForDigit, THUMBS_UP_LABEL } from "../utils/quickLabels"; import { copyTextToClipboard } from "../utils/clipboard"; import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotationMode.shortcuts"; import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker"; import { FloatingSelectionActionsPicker } from "./SelectionActionsDropdown"; import { buildSelectionActionContext, type SelectionAction } from "../utils/selectionActions"; import { HostTitle } from './HostTitle'; type PositionMode = 'center-above' | 'top-right'; const isEditableElement = (node: EventTarget | Element | null): boolean => { if (!(node instanceof Element)) return false; if (node.matches('input, textarea, select, [role="textbox"]')) return true; if (node.closest('[contenteditable]:not([contenteditable="false"])')) return true; return (node as HTMLElement).isContentEditable; }; interface AnnotationToolbarProps { element: HTMLElement; positionMode: PositionMode; onAnnotate: (type: AnnotationType) => void; 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 when the button is clicked */ copyText?: string; /** Comment-only surfaces (HTML / live-app viewer): hide the Delete action, * the quick-label picker, and the Alt+digit label shortcuts. A provided * onQuickLabel then renders ONLY the hardcoded 👍 "Looks good" button — * the one label affordance restored to these surfaces. Markdown surfaces * keep the full toolbar. */ commentOnly?: boolean; /** * Opt-in host capability: the host's own commands for this selection, * rendered as ONE wand button (`data-selection-actions`) that opens the * package's dropdown below it. Selecting an item calls `onSelect` with the * selection context and closes the toolbar, exactly as a quick label does; * the package creates no annotation — the host decides what an action means. * Absent or empty → no button, no dropdown: today's toolbar. */ selectionActions?: SelectionAction[]; /** * Opt-in host capability: the glyph on the `selectionActions` button, so a * host can match the wand it draws elsewhere. Absent → the package's own * wand. Nothing else about the button changes (name, data attribute, size). */ selectionActionsIcon?: React.ReactNode; /** * Whether the package's own quick labels are offered (default true). * `false` hides the Zap picker button and the Alt+digit label shortcuts on * this toolbar. The one-click 👍 is a separate affordance and is unaffected. */ quickLabels?: boolean; /** Hide the copy button (set when a keyboard copy handler exists) */ hideCopyButton?: boolean; /** Close toolbar when element scrolls out of viewport */ closeOnScrollOut?: boolean; /** Exit animation state */ isExiting?: boolean; /** Hover callbacks for code block behavior */ onMouseEnter?: () => void; onMouseLeave?: () => void; } export const AnnotationToolbar: React.FC = ({ element, positionMode, onAnnotate, onClose, onRequestComment, onQuickLabel, selectionActions, selectionActionsIcon, quickLabels: quickLabelsEnabled = true, copyText, commentOnly = false, hideCopyButton = false, closeOnScrollOut = false, isExiting = false, onMouseEnter, onMouseLeave, }) => { const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null); const [copied, setCopied] = useState(false); const [showQuickLabels, setShowQuickLabels] = useState(false); const [showSelectionActions, setShowSelectionActions] = useState(false); const toolbarRef = useRef(null); const zapButtonRef = useRef(null); const actionsButtonRef = useRef(null); const quickLabels = useMemo(() => getQuickLabels(), []); // A picker counts as open only while the button that opens it is actually // rendered. Without this, a host whose `selectionActions` go empty (or who // flips `quickLabels` off) while its dropdown is open unmounts the dropdown // and its Escape handler, but leaves the flag set — and the flag is what // stands the toolbar's own Escape, type-to-comment and outside-dismiss // listeners down, wedging the toolbar until the ✕ is clicked. Both flags are // false whenever the props are absent, so Plannotator's toolbar is // unchanged. const hasSelectionActions = !!selectionActions && selectionActions.length > 0; const selectionActionsOpen = showSelectionActions && hasSelectionActions; const quickLabelsOpen = showQuickLabels && !commentOnly && quickLabelsEnabled; useEffect(() => { setCopied(false); }, [element]); const handleCopy = async () => { let textToCopy = copyText; if (!textToCopy) { const codeEl = element.querySelector('code'); textToCopy = codeEl?.textContent || element.textContent || ''; } if (await copyTextToClipboard(textToCopy)) { setCopied(true); setTimeout(() => setCopied(false), 1500); } }; // Update position on scroll/resize useEffect(() => { const updatePosition = () => { const rect = element.getBoundingClientRect(); if (closeOnScrollOut && (rect.bottom < 0 || rect.top > window.innerHeight)) { onClose(); return; } if (positionMode === 'center-above') { setPosition({ top: rect.top - 48, left: rect.left + rect.width / 2, }); } else { setPosition({ top: rect.top - 40, right: window.innerWidth - rect.right, }); } }; updatePosition(); window.addEventListener("scroll", updatePosition, true); window.addEventListener("resize", updatePosition); return () => { window.removeEventListener("scroll", updatePosition, true); window.removeEventListener("resize", updatePosition); }; }, [element, positionMode, closeOnScrollOut, onClose]); // Type-to-comment + Alt+N / bare digit quick label shortcuts useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented || e.isComposing) return; if (isEditableElement(e.target) || isEditableElement(document.activeElement)) return; // When a picker is open, let it own all keyboard input if (quickLabelsOpen || selectionActionsOpen) return; if (e.key === "Escape") { onClose(); return; } // Alt+N applies quick label (picker closed). Comment-only surfaces // suppress this path: their only label affordance is the 👍 button. const isDigit = (e.code >= 'Digit1' && e.code <= 'Digit9') || e.code === 'Digit0'; if (isDigit && !e.ctrlKey && !e.metaKey && e.altKey) { e.preventDefault(); if (!commentOnly && quickLabelsEnabled) { const digit = parseInt(e.code.slice(5), 10); const index = quickLabelIndexForDigit(digit); 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; onRequestComment?.(e.key); }; window.addEventListener("keydown", handleKeyDown); // While this listener owns printable keys, the Shift+1..4 annotation-mode // shortcuts must not fire: Shift+3 is "#", and a user typing "#" into a // starting comment must not silently arm Redline (#1244 follow-up). const releaseCapture = acquireTypeToCommentCapture(); return () => { window.removeEventListener("keydown", handleKeyDown); releaseCapture(); }; }, [onClose, onRequestComment, onQuickLabel, quickLabels, quickLabelsOpen, selectionActionsOpen, commentOnly, quickLabelsEnabled]); useDismissOnOutsideAndEscape({ enabled: !quickLabelsOpen && !selectionActionsOpen, ref: toolbarRef, onDismiss: onClose, }); if (!position) return null; const handleTypeSelect = (type: AnnotationType) => { if (type === AnnotationType.DELETION) { onAnnotate(type); } else { onRequestComment?.(); } }; const isCentered = position.left !== undefined; const translateX = isCentered ? ' translateX(-50%)' : ''; const style: React.CSSProperties & { '--pn-annotation-toolbar-anchor-x'?: string } = { top: position.top, '--pn-annotation-toolbar-anchor-x': isCentered ? `${position.left}px` : undefined, ...(isCentered ? { left: position.left, transform: 'translateX(-50%)' } : { right: position.right }), animation: isExiting ? 'annotation-toolbar-out 0.15s ease-in forwards' : 'annotation-toolbar-in 0.15s ease-out', }; return createPortal(
e.stopPropagation()} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} >
{!hideCopyButton && ( <> : } label={copied ? "Copied!" : "Copy"} className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"} />
)} {!commentOnly && ( handleTypeSelect(AnnotationType.DELETION)} icon={} label="Delete" className="text-destructive hover:bg-destructive/10" /> )} handleTypeSelect(AnnotationType.COMMENT)} icon={} label="Comment" className="text-annotation-comment hover:bg-annotation-comment/10" /> {hasSelectionActions && ( setShowSelectionActions(prev => !prev)} icon={selectionActionsIcon ?? } label="Actions" className={selectionActionsOpen ? "text-primary bg-primary/10" : "text-primary hover:bg-primary/10"} dataAttributes={{ 'data-selection-actions': 'true' }} /> )} {selectionActionsOpen && actionsButtonRef.current && ( { setShowSelectionActions(false); // Read at invoke time, not on every render: a toolbar with no // host actions must not walk the element's text at all. const actionText = copyText ?? element.querySelector('code')?.textContent ?? element.textContent ?? ''; action.onSelect(buildSelectionActionContext(element, actionText)); onClose(); }} onDismiss={() => setShowSelectionActions(false)} /> )} {onQuickLabel && ( <> {!commentOnly && quickLabelsEnabled && ( setShowQuickLabels(prev => !prev)} icon={} label="Quick label" className={showQuickLabels ? "text-amber-500 bg-amber-500/10" : "text-amber-500 hover:bg-amber-500/10"} /> )} onQuickLabel(THUMBS_UP_LABEL)} icon={👍} label="Looks good" className="hover:bg-green-500/10" /> {quickLabelsOpen && zapButtonRef.current && ( { setShowQuickLabels(false); onQuickLabel(label); }} onDismiss={() => setShowQuickLabels(false)} /> )} )}
} label="Cancel" className="text-muted-foreground hover:bg-muted" />
, document.body ); }; // Icons const CopyIcon = () => ( ); const CheckIcon = () => ( ); const TrashIcon = () => ( ); const CommentIcon = () => ( ); const ZapIcon = () => ( ); // One thick diagonal wand with a single four-point star at its tip: the // earlier glyph carried six sparks and read as noise at 16px. const WandIcon = () => ( ); const CloseIcon = () => ( ); const ToolbarButton = React.forwardRef void; icon: React.ReactNode; label: string; className: string; /** Extra DOM attributes (e.g. data-selection-actions). Absent adds nothing. */ dataAttributes?: Record; }>(({ onClick, icon, label, className, dataAttributes }, ref) => ( // The label is the button's only name: as a component tooltip (HostTitle) it also becomes its aria-label. ));