import React, { useState, useEffect, useMemo, useRef, useCallback, useId } from 'react'; import { createPortal } from 'react-dom'; import type { ImageAttachment } from '../types'; import { AttachmentsButton } from './AttachmentsButton'; import { useDraggable } from '../hooks/useDraggable'; import { SparklesIcon } from './SparklesIcon'; import { hasUnsavedCommentContent } from '../utils/commentContent'; import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete'; import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu'; import { ComposerTokenTextarea, type ComposerMentionChips } from './ComposerTokenTextarea'; import { useMentionAutocomplete } from '../hooks/useMentionAutocomplete'; import { MentionAutocompleteMenu, mentionActiveOptionId } from './MentionAutocomplete'; import type { MentionSource } from '../utils/mentions'; import { hasPrimaryCoarsePointer, shouldUseExpandedComposer, useVisibleViewportBounds, type VisibleViewportBounds, } from '../hooks/useViewportEnvironment'; export type { ComposerMentionChips } from './ComposerTokenTextarea'; export interface CommentAskAIContext { kind: 'general' | 'selection'; label?: string; text?: string; sourcePath?: string; /** Agent-facing identity of the selected element(s), for a pinpoint on a * raw-HTML or live-app surface. Rides with the question; not displayed. */ detail?: string; /** 1-based source lines of a markdown text selection (see * `AIQuestion.scope.lineStart`). Rides with the question; not displayed. */ lineStart?: number; lineEnd?: number; } export type CommentAskAIHandler = ( question: string, context: CommentAskAIContext, ) => boolean | void | Promise; /** One selected target of a multi-target draft comment (HTML pinpoint multi-select). */ export interface CommentTargetChip { key: string; /** Semantic label (hover-label cascade), e.g. "Button" / "rowchip". */ label?: string; /** Short text excerpt of the target element. */ excerpt: string; } /** Composer-yield stage while the user is shift-selecting (see composerYield.ts). */ export type CommentPopoverYieldState = 'none' | 'near' | 'over'; interface CommentPopoverProps { /** Element to anchor the popover near (re-reads position on scroll) */ anchorEl?: HTMLElement; /** Static viewport rect to anchor near when no stable DOM element exists */ anchorRect?: DOMRect; /** Truncated selected text shown in header, or empty for global */ contextText: string; /** Whether this is a global comment */ isGlobal: boolean; /** Pre-filled text (for type-to-comment) */ initialText?: string; /** * Called on submit with comment text and optional images. The third * argument carries the mention ids the body still tags, and is passed ONLY * when a `mentionSource` is supplied — without one the call is the two * arguments it has always been. */ onSubmit: (text: string, images?: ImageAttachment[], mentions?: readonly string[]) => void; /** * One-click 👍 "Looks good" (comment-only HTML / live-app surfaces, where a * pinpoint click opens this composer directly and never shows the selection * toolbar's 👍). Renders an emoji-only button beside Save, named "Looks * good" for assistive tech and in its tooltip; disabled once anything is * typed or attached so a click can never discard a draft. It takes no * keyboard shortcut: Enter / Mod+Enter still Save. Like Save, a click * clears the `draftKey` draft and returns focus to the opener. The parent * owns annotation creation and closing. Absent renders nothing. */ onQuickLookGood?: () => void; /** Optional live draft observer for submit paths outside the popover. */ onDraftChange?: (text: string, images?: ImageAttachment[]) => void; /** Called when popover is closed/cancelled */ onClose: () => void; /** Opt-in: persist text + images across close/reopen, keyed by this string. Cleared on submit. */ draftKey?: string; /** Whether image attachments are available in this comment surface. */ allowImages?: boolean; /** Whether submitting empty text is allowed, for editors that support clearing. */ allowEmptySubmit?: boolean; /** Optional Ask AI action. Absent by default so existing comment surfaces are unchanged. */ onAskAI?: CommentAskAIHandler; askAIContext?: CommentAskAIContext; askAIDisabled?: boolean; /** Opt-in: `/` and `$` skill-reference autocomplete (document UI surfaces). Off by default. */ skillReferences?: boolean; /** * Opt-in host capability: an `@` mention source for this composer. Supplies * the people, an optional honest-empty notice, and optional callbacks for * the surviving mention ids and for a picked person the host blocks. * Absent → no listener, no picker, no extra DOM: byte-identical composer. */ mentionSource?: MentionSource; /** Opt-in (HTML multi-select): selected targets rendered as horizontally * scrollable chips above the textarea. Absent → byte-identical composer. */ targetChips?: CommentTargetChip[]; /** Remove a chip's target while composing. */ onRemoveTargetChip?: (key: string) => void; /** Chip hover — host flashes the corresponding element in the page. */ onHoverTargetChip?: (key: string) => void; /** Opt-in: bump to return focus to the textarea (after a shift-click add/remove). */ refocusToken?: number; /** Opt-in: while open, a window-level printable keydown that would otherwise * go nowhere (focus on ) routes into the textarea, so the first * keystroke after a shift-click is never lost. */ captureStrayKeys?: boolean; /** Opt-in composer yield while shift-selecting: 'near' fades the composer, * 'over' makes it near-invisible and click-through. Undefined → no-op. */ yieldState?: CommentPopoverYieldState; } const MAX_POPOVER_WIDTH = 384; const GAP = 8; // Module-level draft store: survives popover unmount so reopening the same key restores in-progress text. const draftStore = new Map(); /** Mirrors the latest text + images into `draftStore[draftKey]` so they outlive popover unmount. No-op without a key. */ function useCommentDraftSync(draftKey: string | undefined, text: string, images: ImageAttachment[]) { useEffect(() => { if (!draftKey) return; if (hasUnsavedCommentContent(text, images)) { draftStore.set(draftKey, { text, images }); } else { draftStore.delete(draftKey); } }, [draftKey, text, images]); } interface CommentPopoverPosition { top: number; left: number; flipAbove: boolean; width: number; maxHeight: number; requiresExpanded: boolean; } export function computeCommentPopoverPosition( anchorRect: Pick, bounds: VisibleViewportBounds, ): CommentPopoverPosition { const spaceBelow = Math.max(0, bounds.bottom - anchorRect.bottom - GAP); const spaceAbove = Math.max(0, anchorRect.top - bounds.top - GAP); const flipAbove = spaceBelow < 280 && spaceAbove > spaceBelow; const width = Math.min(MAX_POPOVER_WIDTH, bounds.width); const top = flipAbove ? anchorRect.top - GAP : anchorRect.bottom + GAP; let left = anchorRect.left + anchorRect.width / 2 - width / 2; left = Math.max(bounds.left, Math.min(left, bounds.right - width)); const maxHeight = flipAbove ? spaceAbove : spaceBelow; return { top, left, flipAbove, width, maxHeight, requiresExpanded: maxHeight < 280, }; } export const CommentPopover: React.FC = ({ anchorEl, anchorRect, contextText, isGlobal, initialText = '', onSubmit, onQuickLookGood, onDraftChange, onClose, draftKey, allowImages = true, allowEmptySubmit = false, onAskAI, askAIContext, askAIDisabled = false, skillReferences = false, mentionSource, targetChips, onRemoveTargetChip, onHoverTargetChip, refocusToken, captureStrayKeys = false, yieldState, }) => { const visibleBounds = useVisibleViewportBounds(16); const coarsePointer = hasPrimaryCoarsePointer(); const prefersExpandedComposer = shouldUseExpandedComposer({ bounds: visibleBounds, coarsePointer, }); const [mode, setMode] = useState<'popover' | 'dialog'>(() => prefersExpandedComposer ? 'dialog' : 'popover' ); // Dialog mode has two very different origins. Either the viewport PREFERS an // expanded composer (phones, small windows), or the anchor simply has no room // for a popover and the geometry FORCED it. Only the forced kind can bounce: // collapsing recomputes the same geometry and immediately re-expands, which // ate Escape and made the Collapse button a no-op. Track which one we are in. const [dialogIsForced, setDialogIsForced] = useState(false); // A preference-driven dialog is never the forced kind. const forcedDialog = dialogIsForced && !prefersExpandedComposer; const initialDraft = draftKey ? draftStore.get(draftKey) : undefined; const [text, setText] = useState(initialDraft?.text ?? initialText); const [images, setImages] = useState(allowImages ? initialDraft?.images ?? [] : []); const [position, setPosition] = useState(null); // Direction of an open popover that has scrolled out of view, or null when on-screen. const [offscreen, setOffscreen] = useState<'above' | 'below' | null>(null); const textareaRef = useRef(null); const popoverRef = useRef(null); const hasUnsavedContent = hasUnsavedCommentContent(text, allowImages ? images : []); const hasUnsavedContentRef = useRef(hasUnsavedContent); hasUnsavedContentRef.current = hasUnsavedContent; const { dragPosition, dragHandleProps, wasDragged, reset: resetDrag } = useDraggable(popoverRef); const openingFocusRef = useRef( typeof document !== 'undefined' && document.activeElement instanceof HTMLElement && document.activeElement !== document.body && document.activeElement !== document.documentElement ? document.activeElement : anchorEl ?? null, ); useEffect(() => { if (prefersExpandedComposer && mode === 'popover') setMode('dialog'); }, [mode, prefersExpandedComposer]); useEffect(() => { const nextDraft = draftKey ? draftStore.get(draftKey) : undefined; setText(nextDraft?.text ?? initialText); setImages(allowImages ? nextDraft?.images ?? [] : []); }, [draftKey, initialText, allowImages]); useCommentDraftSync(draftKey, text, allowImages ? images : []); useEffect(() => { onDraftChange?.(text, allowImages ? images : undefined); }, [allowImages, images, onDraftChange, text]); // Reset drag when anchor changes (new annotation) or mode switches useEffect(() => { resetDrag(); }, [anchorEl, anchorRect, resetDrag]); useEffect(() => { if (mode === 'popover') resetDrag(); }, [mode, resetDrag]); // Track anchor position on scroll and observed viewport changes (popover // mode only, not after user drag). useEffect(() => { if (mode !== 'popover' || wasDragged) return; const update = () => { const rect = anchorEl?.getBoundingClientRect() ?? anchorRect; if (!rect) return; const nextPosition = computeCommentPopoverPosition(rect, visibleBounds); if (nextPosition.requiresExpanded) { setDialogIsForced(true); setMode('dialog'); return; } setPosition(nextPosition); }; update(); window.addEventListener('scroll', update, true); return () => { window.removeEventListener('scroll', update, true); }; }, [anchorEl, anchorRect, mode, visibleBounds, wasDragged]); // Surface a "jump back" arrow when an open popover scrolls out of view. // Re-measures whenever the popover repositions (position updates every scroll // step in tracked mode) so the indicator is accurate at rest, plus on resize. useEffect(() => { if (mode !== 'popover') { setOffscreen(null); return; } const measure = () => { const el = popoverRef.current; if (!el) return; const rect = el.getBoundingClientRect(); if (rect.bottom < visibleBounds.top) setOffscreen('above'); else if (rect.top > visibleBounds.bottom) setOffscreen('below'); else setOffscreen(null); }; measure(); }, [position, dragPosition, mode, visibleBounds]); const scrollToPopover = useCallback(() => { anchorEl?.scrollIntoView({ block: 'center', behavior: 'smooth' }); }, [anchorEl]); // Focus the textarea when it mounts (initial open and popover/dialog switches). // A ref callback rather than a mount effect: in popover mode the textarea only // renders after `position` is measured, and WebKit fires 0ms timers ahead of // that commit, so an effect keyed on mode alone can run before the textarea // exists and never focus it (e.g. in WKWebView hosts like Glimpse). const shouldAutoFocus = !coarsePointer || initialText.length > 0 || isGlobal; const focusOnMountRef = useCallback((el: HTMLTextAreaElement | null) => { textareaRef.current = el; if (!el || !shouldAutoFocus) return; setTimeout(() => { if (!el.isConnected) return; el.focus(); el.selectionStart = el.selectionEnd = el.value.length; }, 0); }, [shouldAutoFocus]); // A touch-opened composer deliberately does not focus the textarea (and // summon the software keyboard), but hardware-keyboard users still need a // coherent focus target and Escape dismissal. useEffect(() => { if (mode !== 'dialog' || shouldAutoFocus) return; const timer = setTimeout(() => { if (!popoverRef.current?.isConnected) return; popoverRef.current.focus({ preventScroll: true }); }, 0); return () => clearTimeout(timer); }, [mode, shouldAutoFocus]); const restoreOpeningFocus = useCallback(() => { const target = openingFocusRef.current; requestAnimationFrame(() => { if (!target?.isConnected) return; target.focus({ preventScroll: true }); }); }, []); const handleClose = useCallback( (focusDisposition: 'restore-opener' | 'preserve-pointer-target' = 'restore-opener') => { if (draftKey) { if (hasUnsavedCommentContent(text, allowImages ? images : [])) { draftStore.set(draftKey, { text, images: allowImages ? images : [] }); } else { draftStore.delete(draftKey); } } onClose(); if (focusDisposition === 'restore-opener') restoreOpeningFocus(); }, [allowImages, draftKey, images, onClose, restoreOpeningFocus, text], ); // Click-outside for popover mode useEffect(() => { if (mode !== 'popover') return; const shiftSelectionActive = Boolean(targetChips?.length); const handlePointerDown = (e: PointerEvent) => { const target = e.target as Node | null; if (!target) return; if (popoverRef.current?.contains(target)) return; // Don't close if clicking inside a child portal (AttachmentsButton, ImageAnnotator, etc.) const el = target as HTMLElement; if (el.closest?.('[data-popover-layer]')) return; if (hasUnsavedContentRef.current) return; // A same-document multi-select target receives pointerdown before click. // Preserve the existing draft so the following Shift-click can extend // it instead of silently replacing it with a new one. if (shiftSelectionActive && e.shiftKey) return; handleClose('preserve-pointer-target'); }; document.addEventListener('pointerdown', handlePointerDown, true); return () => document.removeEventListener('pointerdown', handlePointerDown, true); }, [handleClose, mode, targetChips?.length]); // Focus choreography (multi-select): after a shift-click adds/removes a // target, focus returns to the textarea so typing continues uninterrupted. // Focus only — the caret stays wherever the user left it mid-edit. const refocusSeenRef = useRef(refocusToken); useEffect(() => { if (refocusToken === undefined || refocusToken === refocusSeenRef.current) return; refocusSeenRef.current = refocusToken; textareaRef.current?.focus(); }, [refocusToken]); // First-keystroke guard: while the draft is open, a printable keydown that // lands nowhere (focus fell back to after an iframe interaction) // routes into the textarea instead of vanishing. Runs in capture phase so // it claims the key before the app's shortcut dispatcher; the character // lands at the textarea's remembered caret. Note that preventDefault here // does not stop the dispatcher (it deliberately ignores defaultPrevented, // see shortcuts/runtime.ts) — this guard is only safe because the // plan-review scopes bind no bare printable single key. Any future single- // key binding on this surface must be reconciled with this handler. useEffect(() => { if (!captureStrayKeys) return; const handler = (e: KeyboardEvent) => { if (e.metaKey || e.ctrlKey || e.altKey) return; if (!e.key || e.key.length !== 1) return; const target = e.target; const strayed = target === null || target === document.body || target === document.documentElement; if (!strayed) return; const el = textareaRef.current; if (!el || document.activeElement === el) return; e.preventDefault(); const key = e.key; const start = el.selectionStart ?? el.value.length; const end = el.selectionEnd ?? start; setText((prev) => prev.slice(0, start) + key + prev.slice(end)); el.focus(); requestAnimationFrame(() => { el.selectionStart = el.selectionEnd = start + 1; }); }; window.addEventListener('keydown', handler, true); return () => window.removeEventListener('keydown', handler, true); }, [captureStrayKeys]); // Composer yield (multi-select): fade near the pointer, click-through over // it. Class-driven so prefers-reduced-motion can kill the transition. const yieldClass = yieldState === undefined ? '' : ` pn-composer-yieldable${yieldState === 'over' ? ' pn-composer-yield-over' : yieldState === 'near' ? ' pn-composer-yield-near' : ''}`; const yieldStyleBlock = yieldState === undefined ? null : ( ); // Selected-target chips (multi-select): horizontally scrollable, primary // first; each removable while composing. const chipsRow = targetChips && targetChips.length > 0 ? (
{targetChips.map((chip, i) => ( onHoverTargetChip?.(chip.key)} className={`inline-flex items-center gap-1 shrink-0 max-w-[180px] rounded-full border px-2 py-0.5 text-[10px] ${ i === 0 ? 'border-primary/50 bg-primary/10 text-foreground' : 'border-border bg-muted/50 text-muted-foreground' }`} > {chip.label || 'Element'} {chip.excerpt} {onRemoveTargetChip && ( )} ))}
) : null; const mentionAc = useMentionAutocomplete({ text, setText, textareaRef, source: mentionSource, }); // The mention half of the composer's highlight layer: present for the whole // life of a mention composer (so the first pick never swaps the textarea // element), null without a source (so the overlay does not exist at all). const mentionChipPeople = mentionAc.mentions; const mentionTokenClassName = mentionSource?.tokenClassName; const mentionChips: ComposerMentionChips | null = useMemo( () => mentionSource ? { people: mentionChipPeople, tokenClassName: mentionTokenClassName } : null, [mentionSource, mentionChipPeople, mentionTokenClassName], ); const handleSubmit = useCallback(() => { const canSubmitEmpty = allowEmptySubmit && initialText.trim().length > 0; if (hasUnsavedContent || canSubmitEmpty) { if (draftKey) draftStore.delete(draftKey); const submitImages = allowImages && images.length > 0 ? images : undefined; // Without a mentionSource this is the two-argument call it always was. if (mentionSource) onSubmit(text, submitImages, mentionAc.mentionIds); else onSubmit(text, submitImages); restoreOpeningFocus(); } }, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent, restoreOpeningFocus, mentionSource, mentionAc.mentionIds]); const handleAskAI = useCallback(async () => { const question = text.trim(); if (!question || !onAskAI) { textareaRef.current?.focus(); return; } let accepted: boolean | void; try { accepted = await onAskAI(question, askAIContext ?? { kind: isGlobal ? 'general' : 'selection', text: contextText, }); } catch (error) { console.error('Ask AI action failed:', error); textareaRef.current?.focus(); return; } if (accepted === false) { textareaRef.current?.focus(); return; } if (draftKey) draftStore.delete(draftKey); onDraftChange?.('', allowImages ? [] : undefined); onClose(); }, [allowImages, askAIContext, contextText, draftKey, isGlobal, onAskAI, onClose, onDraftChange, text]); const skillAc = useSkillReferenceAutocomplete({ text, setText, textareaRef, enabled: skillReferences, }); const readComposerCaret = useCallback(() => { skillAc.onSelect(); mentionAc.onSelect(); }, [mentionAc, skillAc]); const skillListboxId = `skill-reference-listbox-${useId().replace(/:/g, '')}`; const mentionListboxId = `${skillListboxId}-mentions`; const activeSkillOptionId = skillAc.menu?.activeIndex === null || skillAc.menu?.activeIndex === undefined ? undefined : `${skillListboxId}-option-${skillAc.menu.activeIndex}`; // At most one of the two menus can be open (their triggers are disjoint), so // the textarea's ARIA relationship points at whichever one is. All three are // `undefined`/false with no menu open, which is every render Plannotator // makes without a `mentionSource`. const activeMentionOptionId = mentionActiveOptionId(mentionListboxId, mentionAc.menu); const composerListboxId = mentionAc.menu !== null ? mentionListboxId : skillListboxId; const composerListboxOpen = skillAc.menu !== null || mentionAc.menu !== null; const activeComposerOptionId = activeSkillOptionId ?? activeMentionOptionId; const handleKeyDown = (e: React.KeyboardEvent) => { if (skillAc.onKeyDown(e)) return; if (mentionAc.onKeyDown(e)) return; if (e.key === 'Escape') { e.stopPropagation(); if (mode === 'dialog') { // Collapsing a forced dialog is geometrically impossible, so Escape // closes it (draft-preserving) instead of being swallowed by the // re-expand. if (prefersExpandedComposer || forcedDialog) handleClose(); else setMode('popover'); } else { handleClose(); } return; } if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) { e.preventDefault(); handleSubmit(); } }; const headerLabel = isGlobal ? 'Global Comment' : contextText ? `"${contextText.length > 50 ? contextText.slice(0, 50) + '...' : contextText}"` : 'Comment'; const canSubmit = hasUnsavedContent || (allowEmptySubmit && initialText.trim().length > 0); const canAskAI = !!onAskAI && !askAIDisabled && text.trim().length > 0; const showsSkillMenu = skillAc.menu !== null; // Shared by both footers, sits beside Save. Emoji-only like the selection // toolbar's 👍 (same hover tint). Disabled once anything is typed or // attached so a click can never discard a draft; with content, Save is the // path. A plain click handler only: Enter / Mod+Enter stay on Save. // The grey-out is an inline filter, not a Tailwind filter utility: this // file is scanned into the pinned guides.show viewer CSS, and every class // below already exists there, so the 👍 adds nothing to that bundle. const handleQuickLookGood = () => { if (!onQuickLookGood || hasUnsavedContent) return; if (draftKey) draftStore.delete(draftKey); onQuickLookGood(); restoreOpeningFocus(); }; const quickLookGoodButton = onQuickLookGood ? ( ) : null; if (mode === 'dialog') { return createPortal(
{/* Backdrop */} )}
{chipsRow} {/* Textarea */}
{skillAc.menu && ( )} { setText(e.target.value); readComposerCaret(); }} onKeyDown={handleKeyDown} onSelectCaret={readComposerCaret} placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'} sizeClassName="min-h-32 max-h-full" skillReferences={skillReferences} tokens={skillAc.referenceTokens} mentionChips={mentionChips} listboxId={composerListboxId} listboxOpen={composerListboxOpen} activeOptionId={activeComposerOptionId} /> {mentionSource?.renderNotice?.(mentionChipPeople)}
{/* Footer — DOM order sets tab order (Save first); row-reverse keeps the visual layout unchanged */}
{quickLookGoodButton} {onAskAI && ( )}
{allowImages && ( setImages((prev) => [...prev, img])} onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))} variant="inline" /> )}
, document.body ); } // Popover mode if (!position) return null; return createPortal( <> {offscreen && ( )}
e.stopPropagation()} > {yieldStyleBlock} {/* Header (draggable) */}
{headerLabel}
{chipsRow} {/* Textarea */}
{skillAc.menu && ( )} { setText(e.target.value); readComposerCaret(); }} onKeyDown={handleKeyDown} onSelectCaret={readComposerCaret} placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'} sizeClassName="max-h-64 min-h-[4.5rem]" skillReferences={skillReferences} tokens={skillAc.referenceTokens} mentionChips={mentionChips} listboxId={composerListboxId} listboxOpen={composerListboxOpen} activeOptionId={activeComposerOptionId} /> {mentionSource?.renderNotice?.(mentionChipPeople)}
{/* Footer — same DOM-order/row-reverse pattern as the dialog footer above */}
{quickLookGoodButton} {onAskAI && ( )}
{allowImages && ( setImages((prev) => [...prev, img])} onRemove={(path) => setImages((prev) => prev.filter((i) => i.path !== path))} variant="inline" /> )}
, document.body ); }; // Icons const ChevronUpIcon = () => ( ); const ChevronDownIcon = () => ( ); const ExpandIcon = () => ( ); const CollapseIcon = () => ( ); const CloseIcon = () => ( );