"use client"; import { filterAnnotationsByChannel } from "@prototype/lib/prototype-comments/core/annotation-channel"; import { countUnresolvedAnnotations } from "@prototype/lib/prototype-comments/core/annotation-status"; import { injectCaptureColorTokens } from "@prototype/lib/prototype-comments/core/capture-theme"; import { useCommentStore } from "@prototype/lib/prototype-comments/react/CommentProvider"; import toolbarStyles from "@prototype/lib/prototype-comments/ui/capture-styles.module.scss"; import { usePrototypeReview } from "@prototype/lib/prototypes/prototype-review-context"; import type { PrototypeViewportLayout } from "@prototype/lib/prototypes/prototype-review-context"; import { usePrototypeToolTheme } from "@prototype/lib/prototypes/use-prototype-tool-theme"; import { getPrototypeChromeRoot } from "@prototype/lib/tool-portal"; import { getDefaultPrototypeStateMapPath, } from "@prototype/lib/prototypes/prototype-state-canvas-types"; import { getPrototypeShareCommand, getPrototypeSlugFromPathname, } from "@prototype/lib/prototypes/share-command"; import { preventToolbarHoverButtonActivation, supportsToolbarHover, TOOLBAR_HOVER_ONLY_BUTTON_PROPS, useToolbarHoverPanel, } from "@prototype/lib/prototypes/use-toolbar-hover-panel"; import { cn } from "@prototype/lib/utils"; import { ProtoLogoMark } from "@prototype/components/brand/proto-logo-mark"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@prototype/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@prototype/components/ui/tooltip"; import { Monitor, Plus, Rocket, ScrollText, Share2, Smartphone, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { createPortal } from "react-dom"; import { cloneElement, useCallback, useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode, } from "react"; import { VariantSetLucideIcon } from "@prototype/lib/prototypes/variant-set-lucide-icon"; import { useTrackEvent } from "@prototype/lib/prototypes/telemetry/prototype-telemetry-context"; import { miniPillTextFromLabel } from "./mini-pill-label"; import { PrototypeCreateExplorationModal } from "./prototype-create-exploration-modal"; import { PrototypeCreateStateModal } from "./prototype-create-state-modal"; import { PrototypeMiniStateMap, type PrototypeMiniStateMapHandle } from "./prototype-mini-state-map"; import { PrototypeStateMapModal } from "./prototype-state-map-modal"; import { PrototypeShareCommandButton } from "./prototype-controls"; import { PrototypeTweakHighlight } from "./prototype-tweak-highlight"; import { PrototypeReviewSidebar } from "./prototype-review-sidebar"; import pillStyles from "./prototype-floating-pill.module.scss"; import styles from "./prototype-review-chrome.module.scss"; type PrototypeReviewChromeProps = { onSelect: (id: string) => void; selectedId?: string | null; onOpenCommentsPanel?: () => void; isCommentModeActive?: boolean; onToggleCommentMode?: () => void; onClose: () => void; }; function isTypingTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; return ( target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable ); } const TOOLBAR_MENU_CONTENT_CLASS = cn( pillStyles.dropdownContent, "z-[1051] border-0 bg-transparent p-0 text-foreground shadow-none ring-0", "data-[state=open]:animate-none data-[state=closed]:animate-none", ); const FOOTER_MENU_CONTENT_CLASS = cn( TOOLBAR_MENU_CONTENT_CLASS, pillStyles.dropdownContentFooterMenu, ); const FOOTER_MENU_PANEL_CLASS = pillStyles.controlsPanelFooterMenu; const TOOLBAR_MENU_CONTENT_PROPS = { side: "top" as const, align: "center" as const, sideOffset: 8, className: TOOLBAR_MENU_CONTENT_CLASS, onCloseAutoFocus: (event: Event) => event.preventDefault(), }; const TOOLBAR_TOOLTIP_PROPS = { side: "top" as const, sideOffset: 8, className: styles.toolbarTooltipContent, } as const; /** Match TooltipProvider delayDuration in prototype-provider.tsx */ const TOOLBAR_TOOLTIP_HOVER_DELAY_MS = 300; const TOOLBAR_TOOLTIP_DISMISS_EVENT = "prototype-review-toolbar-dismiss-tooltips"; function dismissReviewToolbarTooltips() { document.dispatchEvent(new Event(TOOLBAR_TOOLTIP_DISMISS_EVENT)); } function chainPointerHandlers( existing: ((event: ReactPointerEvent) => void) | undefined, next: (event: ReactPointerEvent) => void, ) { return (event: ReactPointerEvent) => { existing?.(event); if (!event.defaultPrevented) { next(event); } }; } type ToolbarTooltipAnchorProps = { onPointerEnter?: (event: ReactPointerEvent) => void; onPointerLeave?: (event: ReactPointerEvent) => void; }; function ReviewToolbarTooltip({ label, disabled = false, children, }: { label: string; disabled?: boolean; children: ReactElement; }) { const [open, setOpen] = useState(false); const showTimerRef = useRef | null>(null); const isHoveringRef = useRef(false); const clearShowTimer = useCallback(() => { if (showTimerRef.current) { clearTimeout(showTimerRef.current); showTimerRef.current = null; } }, []); const close = useCallback(() => { isHoveringRef.current = false; clearShowTimer(); setOpen(false); }, [clearShowTimer]); const openFromHover = useCallback(() => { if (disabled || !isHoveringRef.current) return; setOpen(true); }, [disabled]); const handlePointerEnter = useCallback( (event: ReactPointerEvent) => { if (disabled || event.pointerType === "touch") return; isHoveringRef.current = true; clearShowTimer(); showTimerRef.current = setTimeout( openFromHover, TOOLBAR_TOOLTIP_HOVER_DELAY_MS, ); }, [clearShowTimer, disabled, openFromHover], ); const handlePointerLeave = useCallback(() => { close(); }, [close]); useEffect(() => { if (disabled) close(); }, [close, disabled]); useEffect(() => { return () => clearShowTimer(); }, [clearShowTimer]); useEffect(() => { document.addEventListener(TOOLBAR_TOOLTIP_DISMISS_EVENT, close); return () => document.removeEventListener(TOOLBAR_TOOLTIP_DISMISS_EVENT, close); }, [close]); const trigger = cloneElement(children, { onPointerEnter: chainPointerHandlers( children.props.onPointerEnter, handlePointerEnter, ), onPointerLeave: chainPointerHandlers( children.props.onPointerLeave, handlePointerLeave, ), }); return ( { if (!next) setOpen(false); }} > {trigger} {label} ); } function ReviewToolbarTextTabTrigger({ label, active, open, hoverTriggerProps, badgeCount, onActivate, }: { label: string; active: boolean; open: boolean; hoverTriggerProps?: ToolbarTooltipAnchorProps; badgeCount?: number; /** Desktop click handler. Hover still opens the menu; touch keeps menu-on-click. */ onActivate?: () => void; }) { const showBadge = badgeCount != null && badgeCount > 0; const badgeLabel = showBadge && badgeCount != null ? badgeCount > 99 ? "99+" : String(badgeCount) : null; return (
{badgeLabel ? ( {badgeLabel} ) : null}
); } function CreateExplorationPanelTrigger({ onOpen, }: { onOpen: () => void; }) { return ( ); } function CreatePanelTrigger({ onOpen, label, }: { onOpen: () => void; label: string; }) { return (
event.stopPropagation()} >
); } type ReviewToolbarIconPickerOption = { value: T; label: string; icon: ReactNode; }; function ReviewToolbarIconPickerMenu({ label, tooltip, value, onValueChange, options, open, onOpenChange, hoverPanel, triggerIcon, panelId, }: { label: string; tooltip?: string; value: T; onValueChange: (value: T) => void; options: ReviewToolbarIconPickerOption[]; open: boolean; onOpenChange: (open: boolean) => void; hoverPanel: ReturnType; triggerIcon: ReactNode; panelId: string; }) { const { commentTheme } = usePrototypeToolTheme(); return (
{options.map((option) => ( ))}
); } const VIEWPORT_LAYOUT_OPTIONS: ReviewToolbarIconPickerOption[] = [ { value: "desktop", label: "Desktop layout", icon: , }, { value: "mobile", label: "Mobile layout", icon: , }, ]; export function PrototypeReviewChrome({ onSelect, selectedId, onOpenCommentsPanel, isCommentModeActive, onToggleCommentMode, onClose, }: PrototypeReviewChromeProps) { const review = usePrototypeReview(); const { commentTheme } = usePrototypeToolTheme(); const track = useTrackEvent(); const chromeRef = useRef(null); const stateMapTabRef = useRef(null); const stateMapHoverSuppressedRef = useRef(false); const miniStateMapRef = useRef(null); const [explorationsMenuOpen, setExplorationsMenuOpen] = useState(false); const [handoffMenuOpen, setHandoffMenuOpen] = useState(false); const [createExplorationModalOpen, setCreateExplorationModalOpen] = useState(false); const [createStateModalOpen, setCreateStateModalOpen] = useState(false); const [shareMenuOpen, setShareMenuOpen] = useState(false); const [viewportMenuOpen, setViewportMenuOpen] = useState(false); const [miniStateMapMinimized, setMiniStateMapMinimized] = useState(true); const [miniStateMapRestoring, setMiniStateMapRestoring] = useState(false); const [stateMapModalOpen, setStateMapModalOpen] = useState(false); const pathname = usePathname(); const { annotations } = useCommentStore(); const [mounted, setMounted] = useState(false); const slug = getPrototypeSlugFromPathname(pathname); const shareCommand = slug ? getPrototypeShareCommand(slug) : null; useEffect(() => { injectCaptureColorTokens(); setMounted(true); }, []); const handleMiniMapMinimize = useCallback(() => { stateMapHoverSuppressedRef.current = true; setMiniStateMapMinimized(true); setMiniStateMapRestoring(false); }, []); const handleMiniMapRestoreComplete = useCallback(() => { setMiniStateMapRestoring(false); setMiniStateMapMinimized(false); }, []); const openStateMapHover = useCallback(() => { if (stateMapHoverSuppressedRef.current || stateMapModalOpen) { return; } setMiniStateMapMinimized(false); setMiniStateMapRestoring(false); }, [stateMapModalOpen]); const closeStateMapHover = useCallback(() => { stateMapHoverSuppressedRef.current = false; setMiniStateMapMinimized(true); setMiniStateMapRestoring(false); }, []); const closeToolbarMenus = useCallback(() => { setExplorationsMenuOpen(false); setHandoffMenuOpen(false); setShareMenuOpen(false); setViewportMenuOpen(false); dismissReviewToolbarTooltips(); }, []); const blurToolbarFocus = useCallback(() => { const active = document.activeElement; if (active instanceof HTMLElement && chromeRef.current?.contains(active)) { active.blur(); } }, []); const openExplorationsMenu = useCallback(() => { dismissReviewToolbarTooltips(); review.closeSettings(); setHandoffMenuOpen(false); setShareMenuOpen(false); setViewportMenuOpen(false); setExplorationsMenuOpen(true); }, [review.closeSettings]); const closeExplorationsMenu = useCallback(() => { setExplorationsMenuOpen(false); blurToolbarFocus(); }, [blurToolbarFocus]); const openHandoffMenu = useCallback(() => { dismissReviewToolbarTooltips(); review.closeSettings(); setExplorationsMenuOpen(false); setShareMenuOpen(false); setViewportMenuOpen(false); setHandoffMenuOpen(true); }, [review.closeSettings]); const closeHandoffMenu = useCallback(() => { setHandoffMenuOpen(false); blurToolbarFocus(); }, [blurToolbarFocus]); const openCreateExplorationModal = useCallback(() => { setExplorationsMenuOpen(false); blurToolbarFocus(); setCreateExplorationModalOpen(true); }, [blurToolbarFocus]); const openCreateStateModal = useCallback(() => { blurToolbarFocus(); setCreateStateModalOpen(true); track("modal.opened", { modal: "create-state" }); }, [blurToolbarFocus, track]); const registerOpenCreateStateModal = review.registerOpenCreateStateModal; useEffect(() => { registerOpenCreateStateModal(openCreateStateModal); return () => registerOpenCreateStateModal(null); }, [openCreateStateModal, registerOpenCreateStateModal]); const openShareMenu = useCallback(() => { dismissReviewToolbarTooltips(); closeToolbarMenus(); review.closeSettings(); setShareMenuOpen(true); }, [closeToolbarMenus, review.closeSettings]); const closeShareMenu = useCallback(() => { setShareMenuOpen(false); blurToolbarFocus(); }, [blurToolbarFocus]); const toggleCommentsSidebar = useCallback(() => { dismissReviewToolbarTooltips(); blurToolbarFocus(); closeToolbarMenus(); if (review.open && review.sidebarPanel === "comments") { onClose(); return; } onOpenCommentsPanel?.(); }, [ blurToolbarFocus, closeToolbarMenus, onClose, onOpenCommentsPanel, review.open, review.sidebarPanel, ]); const openLastVariantSet = review.openLastVariantSet; const closeVariants = review.closeVariants; const openLastHandoffPanel = review.openLastHandoffPanel; const closeSidebar = review.closeSidebar; const toggleLastExploration = useCallback(() => { dismissReviewToolbarTooltips(); blurToolbarFocus(); closeToolbarMenus(); if (review.open && review.sidebarPanel === "variants") { closeVariants(); return; } if (review.variantSets.length === 0) { openExplorationsMenu(); return; } openLastVariantSet(); }, [ blurToolbarFocus, closeToolbarMenus, closeVariants, openExplorationsMenu, openLastVariantSet, review.open, review.sidebarPanel, review.variantSets.length, ]); const toggleLastHandoff = useCallback(() => { dismissReviewToolbarTooltips(); blurToolbarFocus(); closeToolbarMenus(); if ( review.open && (review.sidebarPanel === "change-log" || review.sidebarPanel === "spec") ) { closeSidebar(); return; } openLastHandoffPanel(); }, [ blurToolbarFocus, closeSidebar, closeToolbarMenus, openLastHandoffPanel, review.open, review.sidebarPanel, ]); const openViewportMenu = useCallback(() => { dismissReviewToolbarTooltips(); review.closeSettings(); setExplorationsMenuOpen(false); setHandoffMenuOpen(false); setShareMenuOpen(false); setViewportMenuOpen(true); }, [review.closeSettings]); const closeViewportMenu = useCallback(() => { setViewportMenuOpen(false); blurToolbarFocus(); }, [blurToolbarFocus]); const explorationsHover = useToolbarHoverPanel({ onOpen: openExplorationsMenu, onClose: closeExplorationsMenu, }); const handoffHover = useToolbarHoverPanel({ onOpen: openHandoffMenu, onClose: closeHandoffMenu, }); const shareHover = useToolbarHoverPanel({ onOpen: openShareMenu, onClose: closeShareMenu, }); const viewportHover = useToolbarHoverPanel({ onOpen: openViewportMenu, onClose: closeViewportMenu, }); const stateMapHover = useToolbarHoverPanel({ onOpen: openStateMapHover, onClose: closeStateMapHover, }); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; if (isTypingTarget(event.target)) return; if (document.querySelector("[data-mobbin-lightbox-open]")) return; if (document.querySelector('[role="dialog"][data-state="open"]')) return; dismissReviewToolbarTooltips(); if ( explorationsMenuOpen || handoffMenuOpen || shareMenuOpen || viewportMenuOpen ) { closeToolbarMenus(); event.preventDefault(); event.stopPropagation(); blurToolbarFocus(); } }; document.addEventListener("keydown", handleKeyDown, true); return () => document.removeEventListener("keydown", handleKeyDown, true); }, [ blurToolbarFocus, closeToolbarMenus, explorationsMenuOpen, handoffMenuOpen, shareMenuOpen, viewportMenuOpen, ]); if (!mounted) return null; const chromePortalTarget = getPrototypeChromeRoot() ?? null; const sidebarPortalTarget = document.getElementById( "prototype-comments-sidebar-root", ); const commentAnnotations = filterAnnotationsByChannel(annotations, "comment"); const commentCount = countUnresolvedAnnotations(commentAnnotations); const showShareMenu = Boolean(shareCommand); const stateMapPagePath = review.stateCanvasPagePath ?? getDefaultPrototypeStateMapPath(review.slug); const onStateMapPage = stateMapPagePath != null ? pathname.startsWith(stateMapPagePath) : /\/prototypes\/[^/]+\/states\/?$/.test(pathname); const showStateMapTab = review.hasStateCanvas && !onStateMapPage; const stateMapTabActive = showStateMapTab && (!miniStateMapMinimized || stateMapModalOpen); const commentsTabActive = Boolean(isCommentModeActive) || (review.open && review.sidebarPanel === "comments"); const commentsBadgeLabel = commentCount > 0 ? commentCount > 99 ? "99+" : String(commentCount) : null; const explorationsTabActive = explorationsMenuOpen || (review.open && review.sidebarPanel === "variants"); const handoffTabActive = handoffMenuOpen || (review.open && (review.sidebarPanel === "change-log" || review.sidebarPanel === "spec")); const chrome = (
Proto
{showStateMapTab ? ( ) : null}
{ if (open) { openExplorationsMenu(); return; } closeExplorationsMenu(); }} >
{review.variantSets.length > 0 ? (
) : null} {review.variantSets.map((variantSet) => { const selected = review.variantsOpen && review.activeVariantSetId === variantSet.id; return ( { setExplorationsMenuOpen(false); blurToolbarFocus(); review.openVariantSet(variantSet.id); }} > {miniPillTextFromLabel(variantSet.label)} ); })}
{commentsBadgeLabel ? ( {commentsBadgeLabel} ) : null}
{ if (open) { openHandoffMenu(); return; } closeHandoffMenu(); }} >
{ setHandoffMenuOpen(false); blurToolbarFocus(); if (review.open && review.sidebarPanel === "change-log") { review.closeSidebar(); } else { review.closeSettings(); closeToolbarMenus(); closeShareMenu(); review.openSidebar("change-log"); track("nav.clicked", { target: "handoff-overview-writeup" }); } }} > Overview Writeup { setHandoffMenuOpen(false); blurToolbarFocus(); if (review.open && review.sidebarPanel === "spec") { review.closeSidebar(); } else { review.closeSettings(); closeToolbarMenus(); closeShareMenu(); review.openSidebar("spec"); track("nav.clicked", { target: "handoff-pr-plan" }); } }} > PR Plan
{showShareMenu ? ( { if (open) { openShareMenu(); return; } closeShareMenu(); }} >
) : null} { review.setViewportLayout(layout); track("layout.toggled", { layout }); }} options={VIEWPORT_LAYOUT_OPTIONS} open={viewportMenuOpen} onOpenChange={(open) => { if (open) { openViewportMenu(); return; } closeViewportMenu(); }} hoverPanel={viewportHover} panelId="viewport" triggerIcon={ review.viewportLayout === "mobile" ? ( ) : ( ) } />
); return ( <> {sidebarPortalTarget ? createPortal( , sidebarPortalTarget, ) : null} {chromePortalTarget ? createPortal(chrome, chromePortalTarget) : null} {review.hasStateCanvas && !onStateMapPage && !stateMapModalOpen ? ( setStateMapModalOpen(true)} onMinimize={handleMiniMapMinimize} onRestoreComplete={handleMiniMapRestoreComplete} minimizeTargetRef={stateMapTabRef} hoverPanelProps={stateMapHover.panelProps} /> ) : null} {review.highlightTargetId ? ( ) : null} ({ id: variantSet.id, label: variantSet.label, }))} /> ); }