"use client"; import { usePathname, useRouter } from "next/navigation"; import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { CommentCaptureTargetHighlights } from "./CommentCaptureTargetHighlights"; import { PrototypeComponentAnchorLayer } from "@prototype/components/prototypes/prototype-component-anchor-layer"; import { getPrototypeTargetElement, getPrototypeTargetIdFromElement, } from "@prototype/components/prototypes/prototype-target"; import { usePrototypeCommentRegistry } from "@prototype/lib/prototypes/prototype-comment-registry"; import { usePrototypeReviewOptional } from "@prototype/lib/prototypes/prototype-review-context"; import { usePrototypeToolTheme } from "@prototype/lib/prototypes/use-prototype-tool-theme"; import { resolveCommentAuthorName } from "@prototype/lib/prototypes/workspace-profile"; import { useWorkspaceProfile } from "@prototype/lib/prototypes/use-workspace-profile"; import { useTrackEvent } from "@prototype/lib/prototypes/telemetry/prototype-telemetry-context"; import { getPrototypeSlugFromPathname } from "@prototype/lib/prototypes/share-command"; import { clearPendingCommentRestore, resolveShareCommentId, stashPendingCommentRestore, syncShareCommentParam, } from "@prototype/lib/prototypes/prototype-share-link"; import { computeElementCenterMarkerPosition, computeElementRelativeMarkerPosition, deepElementFromPoint, getElementRelativeMarkerStyle, getMarkerViewportPosition, getPrototypeCaptureViewport, getPrototypeScrollContainer, resolveAnnotationTargetById, type AnnotationTargetOptions, } from "../core/annotation-target"; import type { AnnotationBoundingBox, CaptureViewport } from "../core/types"; import { getAdjacentDisplayCommentId, sortAnnotationsForDisplay, } from "../core/annotation-status"; import { filterAnnotationsByChannel, getCommentRootAnnotations, isChangelogAnnotation, } from "../core/annotation-channel"; import { getRootAnnotations, resolveThreadRootId } from "../core/comment-threads"; import { buildCommentCaptureCursorStyles, isCommentCaptureBlockedTarget, isCommentModeChromeInteraction, } from "../core/comment-capture-blocked"; import { closestCrossingShadow } from "../core/element-identification"; import { injectCaptureColorTokens } from "../core/capture-theme"; import { originalSetTimeout } from "../core/freeze-animations"; import { detectSourceFile, getAccessibilityInfo, getDetailedComputedStyles, getElementClasses, getForensicComputedStyles, getFullElementPath, getNearbyElements, getNearbyText, identifyElementWithReact, isElementFixed, } from "../core/identify-element"; import { captureViewportScreenshot } from "../core/screenshot"; import { useCommentStore } from "../react/CommentProvider"; import { AnnotationPopupCSS, type AnnotationPopupCSSHandle, } from "../ui/annotation-popup"; import { PendingMarker } from "../ui/annotation-marker"; import { CommentAnnotationOverlay } from "../ui/CommentAnnotationOverlay"; import { CommentsSidebar } from "../ui/CommentsSidebar"; import { IconChatEllipsis, IconPlus, IconXmarkLarge } from "../ui/icons"; import styles from "../ui/capture-styles.module.scss"; type PendingAnnotation = { x: number; y: number; clientY: number; element: string; elementPath: string; selectedText?: string; boundingBox?: { x: number; y: number; width: number; height: number }; nearbyText?: string; cssClasses?: string; isMultiSelect?: boolean; isFixed?: boolean; fullPath?: string; accessibility?: string; computedStyles?: string; computedStylesObj?: Record; nearbyElements?: string; reactComponents?: string; sourceFile?: string; elementBoundingBoxes?: Array<{ x: number; y: number; width: number; height: number; }>; multiSelectElements?: HTMLElement[]; targetElement?: HTMLElement; targetId?: string; positionAnchor?: "viewport" | "element"; captureViewport?: CaptureViewport; viewportBoundingBox?: AnnotationBoundingBox; }; function resolveCaptureAnchor(elementUnder: HTMLElement): HTMLElement { return getPrototypeTargetElement(elementUnder) ?? elementUnder; } function toViewportBoundingBox( rect: Pick, ): AnnotationBoundingBox { return { x: rect.left, y: rect.top, width: rect.width, height: rect.height, }; } type HoverInfo = { element: string; rect: DOMRect | null; }; type MultiSelectItem = { element: HTMLElement; rect: DOMRect; name: string; path: string; reactComponents?: string; }; export type CommentCaptureToolbarProps = { sidebarBasePath?: string; resolveTargetOptions?: | AnnotationTargetOptions | (() => AnnotationTargetOptions); devOnly?: boolean; className?: string; children?: ReactNode; }; export type CommentCaptureBridgeValue = { onSelect: (id: string) => void; selectedId: string | null; isCommentModeActive: boolean; isChangelogModeActive: boolean; onEnterCommentMode: () => void; onEnterChangelogMode: () => void; onOpenCommentsPanel: () => void; onToggleCommentMode: () => void; onClose: () => void; }; const CommentCaptureBridgeContext = createContext(null); export function useCommentCaptureBridge(): CommentCaptureBridgeValue { const context = useContext(CommentCaptureBridgeContext); if (!context) { throw new Error( "useCommentCaptureBridge must be used within CommentCaptureToolbar" ); } return context; } export function useCommentCaptureBridgeOptional(): CommentCaptureBridgeValue | null { return useContext(CommentCaptureBridgeContext); } export function CommentCaptureToolbar({ sidebarBasePath = "/canvas", resolveTargetOptions, devOnly = false, className, children, }: CommentCaptureToolbarProps) { const store = useCommentStore(); const { readHandlers, subscribeLiveStateChange } = usePrototypeCommentRegistry(); const review = usePrototypeReviewOptional(); const integratedReview = review != null; const { useLightTheme, commentTheme } = usePrototypeToolTheme(); const workspaceProfile = useWorkspaceProfile(); const router = useRouter(); const currentPathname = usePathname() ?? "/"; const prototypeSlug = getPrototypeSlugFromPathname(currentPathname); const popupRef = useRef(null); const modifiersHeldRef = useRef({ cmd: false, shift: false }); const [mounted, setMounted] = useState(false); const [isActive, setIsActive] = useState(false); const [isGiveAMomentMode, setIsGiveAMomentMode] = useState(false); const [showCommentsSidebar, setShowCommentsSidebar] = useState(false); const [activeRestoreId, setActiveRestoreId] = useState(null); const [pendingRestoreId, setPendingRestoreId] = useState(null); // Mirrors of selection state used by the (persistent) live-state subscriber so // it reads fresh values without re-subscribing. `liveStateArmedRef` flips true // after the restore-driven live-state change so subsequent (user-driven) // changes deselect the active comment. const activeRestoreIdRef = useRef(null); const liveStateArmedRef = useRef(false); const [pendingAnnotation, setPendingAnnotation] = useState(null); const [pendingExiting, setPendingExiting] = useState(false); const [pendingMultiSelectElements, setPendingMultiSelectElements] = useState< MultiSelectItem[] >([]); const [hoverInfo, setHoverInfo] = useState(null); const [scrollY, setScrollY] = useState(0); const track = useTrackEvent(); const prevCommentModeRef = useRef(false); useEffect(() => { if (isGiveAMomentMode === prevCommentModeRef.current) return; prevCommentModeRef.current = isGiveAMomentMode; const channel = integratedReview && review?.captureChannel === "changelog" ? "changelog" : "comment"; track( isGiveAMomentMode ? "comment.mode_entered" : "comment.mode_exited", { channel }, ); }, [isGiveAMomentMode, integratedReview, review, track]); const isFeedbackActive = isGiveAMomentMode; const isChangelogModeActive = isGiveAMomentMode && integratedReview && review?.captureChannel === "changelog"; const isCommentModeActive = isGiveAMomentMode && (!integratedReview || review?.captureChannel === "comment"); const annotationTargetOptions = useMemo(() => { if (typeof resolveTargetOptions === "function") { return resolveTargetOptions(); } return resolveTargetOptions; }, [resolveTargetOptions]); useEffect(() => { setMounted(true); injectCaptureColorTokens(); }, []); useLayoutEffect(() => { if (!prototypeSlug) return; resolveShareCommentId( new URLSearchParams(window.location.search), prototypeSlug, ); }, [prototypeSlug]); useEffect(() => { const syncScroll = () => setScrollY(window.scrollY); syncScroll(); window.addEventListener("scroll", syncScroll, { passive: true }); const scrollContainer = getPrototypeScrollContainer(); scrollContainer?.addEventListener("scroll", syncScroll, { passive: true }); return () => { window.removeEventListener("scroll", syncScroll); scrollContainer?.removeEventListener("scroll", syncScroll); }; }, []); const activateToolbar = useCallback(() => { setIsActive(true); if (integratedReview) { review.openSidebar("comments"); return; } setShowCommentsSidebar(true); }, [integratedReview, review]); const clearActiveComment = useCallback(() => { activeRestoreIdRef.current = null; liveStateArmedRef.current = false; setActiveRestoreId(null); syncShareCommentParam(null); if (prototypeSlug) clearPendingCommentRestore(prototypeSlug); }, [prototypeSlug]); // Restore a comment's frozen state and begin tracking live-state changes. The // refs are set synchronously so the live-state change that the restore itself // triggers is recognized (and "arms" tracking) rather than treated as a user edit. const selectAndRestore = useCallback( (id: string) => { liveStateArmedRef.current = false; activeRestoreIdRef.current = id; store.restoreComment(id); setActiveRestoreId(id); syncShareCommentParam(id); if (prototypeSlug) stashPendingCommentRestore(prototypeSlug, id); // Fallback: if the restore produced no live-state change (the comment's // captured state already matched), arm tracking after the restore settles // so the next user edit still deselects. window.setTimeout(() => { if (activeRestoreIdRef.current === id) { liveStateArmedRef.current = true; } }, 80); }, [store, prototypeSlug], ); const deactivate = useCallback(() => { clearActiveComment(); setIsGiveAMomentMode(false); if (integratedReview) { review.closeSidebar(); } else { setShowCommentsSidebar(false); } setIsActive(false); setPendingAnnotation(null); setPendingMultiSelectElements([]); setHoverInfo(null); }, [integratedReview, review, clearActiveComment]); const enterCommentMode = useCallback(() => { if (integratedReview) { review.setCaptureChannel("comment"); } setActiveRestoreId(null); setIsGiveAMomentMode((active) => { const next = !active; if (next) { setIsActive(true); if (integratedReview) { review.openSidebar("comments"); } } else if (integratedReview ? !review.open : true) { setIsActive(false); } return next; }); }, [integratedReview, review]); const openCommentsPanel = useCallback(() => { if (integratedReview) { review.setCaptureChannel("comment"); review.openSidebar("comments"); return; } setIsActive(true); setShowCommentsSidebar(true); }, [integratedReview, review]); const enterChangelogMode = useCallback(() => { if (!integratedReview) return; review.setCaptureChannel("changelog"); setActiveRestoreId(null); setIsGiveAMomentMode((active) => { const next = !active; if (next) { setIsActive(true); review.openSidebar("change-log"); } else if (!review.open) { setIsActive(false); } return next; }); }, [integratedReview, review]); const toggleCommentMode = useCallback(() => { if (integratedReview) { review.setCaptureChannel("comment"); } setActiveRestoreId(null); setIsGiveAMomentMode((active) => !active); }, [integratedReview, review]); const exitCommentMode = useCallback(() => { setPendingMultiSelectElements([]); setPendingAnnotation(null); setPendingExiting(false); setHoverInfo(null); setIsGiveAMomentMode(false); modifiersHeldRef.current = { cmd: false, shift: false }; if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } }, []); const cancelAnnotation = useCallback(() => { setPendingExiting(true); originalSetTimeout(() => { setPendingAnnotation(null); setPendingExiting(false); }, 150); }, []); const createMultiSelectPendingAnnotation = useCallback(() => { if (pendingMultiSelectElements.length === 0) return; const firstItem = pendingMultiSelectElements[0]; const firstEl = firstItem.element; const isMulti = pendingMultiSelectElements.length > 1; const freshRects = pendingMultiSelectElements.map((item) => item.element.getBoundingClientRect() ); if (!isMulti) { const anchor = resolveCaptureAnchor(firstEl); const rect = anchor.getBoundingClientRect(); const isFixed = isElementFixed(anchor); setPendingAnnotation({ ...computeElementCenterMarkerPosition(), positionAnchor: "element", clientY: rect.top, element: firstItem.name, elementPath: firstItem.path, targetId: getPrototypeTargetIdFromElement(firstEl) ?? undefined, boundingBox: { x: rect.left, y: isFixed ? rect.top : rect.top + window.scrollY, width: rect.width, height: rect.height, }, captureViewport: getPrototypeCaptureViewport(anchor), viewportBoundingBox: toViewportBoundingBox(rect), isFixed, fullPath: getFullElementPath(anchor), accessibility: getAccessibilityInfo(anchor), computedStyles: getForensicComputedStyles(anchor), computedStylesObj: getDetailedComputedStyles(anchor), nearbyElements: getNearbyElements(anchor), cssClasses: getElementClasses(anchor), nearbyText: getNearbyText(anchor), reactComponents: firstItem.reactComponents, sourceFile: detectSourceFile(anchor), targetElement: anchor, }); } else { const bounds = { left: Math.min(...freshRects.map((r) => r.left)), top: Math.min(...freshRects.map((r) => r.top)), right: Math.max(...freshRects.map((r) => r.right)), bottom: Math.max(...freshRects.map((r) => r.bottom)), }; const names = pendingMultiSelectElements .slice(0, 5) .map((item) => item.name) .join(", "); const suffix = pendingMultiSelectElements.length > 5 ? ` +${pendingMultiSelectElements.length - 5} more` : ""; const lastItem = pendingMultiSelectElements[pendingMultiSelectElements.length - 1]; const lastEl = lastItem.element; const lastRect = freshRects[freshRects.length - 1]; const lastCenterY = lastRect.top + lastRect.height / 2; setPendingAnnotation({ ...computeElementCenterMarkerPosition(), positionAnchor: "element", clientY: lastCenterY, element: `${pendingMultiSelectElements.length} elements: ${names}${suffix}`, elementPath: "multi-select", boundingBox: { x: bounds.left, y: bounds.top + window.scrollY, width: bounds.right - bounds.left, height: bounds.bottom - bounds.top, }, captureViewport: getPrototypeCaptureViewport(lastEl), viewportBoundingBox: toViewportBoundingBox({ left: bounds.left, top: bounds.top, width: bounds.right - bounds.left, height: bounds.bottom - bounds.top, }), isMultiSelect: true, isFixed: isElementFixed(lastEl), elementBoundingBoxes: freshRects.map((rect) => ({ x: rect.left, y: rect.top + window.scrollY, width: rect.width, height: rect.height, })), multiSelectElements: pendingMultiSelectElements.map( (item) => item.element ), targetElement: lastEl, fullPath: getFullElementPath(firstEl), accessibility: getAccessibilityInfo(firstEl), computedStyles: getForensicComputedStyles(firstEl), computedStylesObj: getDetailedComputedStyles(firstEl), nearbyElements: getNearbyElements(firstEl), cssClasses: getElementClasses(firstEl), nearbyText: getNearbyText(firstEl), sourceFile: detectSourceFile(firstEl), }); } setPendingMultiSelectElements([]); setHoverInfo(null); }, [pendingMultiSelectElements]); const addAnnotation = useCallback( async (comment: string) => { if (!pendingAnnotation) return; if (!store.storageReady || store.storageError) return; const screenshotResult = await captureViewportScreenshot(); const id = Date.now().toString(); const clickViewport = pendingAnnotation.captureViewport ?? getPrototypeCaptureViewport(pendingAnnotation.targetElement ?? null); const screenshotViewport = screenshotResult?.captureViewport; const captureViewport: CaptureViewport = { width: screenshotViewport?.width ?? clickViewport.width, height: screenshotViewport?.height ?? clickViewport.height, scrollY: clickViewport.scrollY, scrollTop: clickViewport.scrollTop, scrollContainerTargetId: clickViewport.scrollContainerTargetId, }; store.addAnnotation({ id, x: pendingAnnotation.x, y: pendingAnnotation.y, positionAnchor: pendingAnnotation.positionAnchor ?? "element", targetId: pendingAnnotation.targetId, comment, authorName: resolveCommentAuthorName(workspaceProfile), element: pendingAnnotation.element, elementPath: pendingAnnotation.elementPath, timestamp: Date.now(), selectedText: pendingAnnotation.selectedText, boundingBox: pendingAnnotation.boundingBox, viewportBoundingBox: pendingAnnotation.viewportBoundingBox, nearbyText: pendingAnnotation.nearbyText, cssClasses: pendingAnnotation.cssClasses, isMultiSelect: pendingAnnotation.isMultiSelect, isFixed: pendingAnnotation.isFixed, fullPath: pendingAnnotation.fullPath, accessibility: pendingAnnotation.accessibility, computedStyles: pendingAnnotation.computedStyles, nearbyElements: pendingAnnotation.nearbyElements, reactComponents: pendingAnnotation.reactComponents, sourceFile: pendingAnnotation.sourceFile, screenshot: screenshotResult?.dataUrl ?? undefined, captureViewport, channel: integratedReview ? review.captureChannel : "comment", }); setPendingExiting(true); originalSetTimeout(() => { setPendingAnnotation(null); setPendingExiting(false); }, 150); window.getSelection()?.removeAllRanges(); }, [pendingAnnotation, store, integratedReview, review, workspaceProfile] ); const openAnnotationPanel = useCallback( (channel: "comment" | "changelog" = integratedReview ? review.captureChannel : "comment") => { setIsActive(true); if (integratedReview) { review.openSidebar(channel === "changelog" ? "change-log" : "comments"); return; } setShowCommentsSidebar(true); }, [integratedReview, review], ); const handleCommentSelect = useCallback( (id: string) => { const rootId = resolveThreadRootId(store.annotations, id); const annotation = store.annotations.find((item) => item.id === rootId); if (!annotation) return; setPendingAnnotation(null); openAnnotationPanel( isChangelogAnnotation(annotation) ? "changelog" : "comment", ); if (annotation.interactionState.route !== currentPathname) { setPendingRestoreId(rootId); router.push(annotation.interactionState.route); return; } selectAndRestore(rootId); }, [store, currentPathname, router, openAnnotationPanel, selectAndRestore] ); useEffect(() => { if (!activeRestoreId || pendingAnnotation) return; const currentRestoreId = activeRestoreId; function isTypingTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) return false; const tag = target.tagName; return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable; } function handleKeyDown(event: KeyboardEvent) { if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; if (isTypingTarget(event.target)) return; if (event.metaKey || event.ctrlKey || event.altKey) return; const rootId = resolveThreadRootId(store.annotations, currentRestoreId); const currentRoot = store.annotations.find((item) => item.id === rootId); const channelFilter = currentRoot ? isChangelogAnnotation(currentRoot) ? "changelog" : "comment" : null; const sorted = sortAnnotationsForDisplay( channelFilter ? filterAnnotationsByChannel(store.annotations, channelFilter) : store.annotations, channelFilter === "changelog" ? "oldest" : "newest", ); if (sorted.length < 2) return; const direction = event.key === "ArrowUp" ? "prev" : "next"; const nextId = getAdjacentDisplayCommentId( sorted, currentRestoreId, direction, ); if (!nextId || nextId === currentRestoreId) return; event.preventDefault(); handleCommentSelect(nextId); } document.addEventListener("keydown", handleKeyDown, true); return () => document.removeEventListener("keydown", handleKeyDown, true); }, [ activeRestoreId, pendingAnnotation, store.annotations, handleCommentSelect, ]); useEffect(() => { if (!activeRestoreId || pendingAnnotation) return; const currentRestoreId = activeRestoreId; function isTypingTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) return false; const tag = target.tagName; return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable; } function handleKeyDown(event: KeyboardEvent) { if (event.key !== "z" && event.key !== "Z") return; if (!(event.metaKey || event.ctrlKey) || event.shiftKey || event.altKey) return; if (isTypingTarget(event.target)) return; const rootId = resolveThreadRootId(store.annotations, currentRestoreId); const annotation = store.annotations.find((item) => item.id === rootId); if (!annotation || isChangelogAnnotation(annotation)) return; event.preventDefault(); store.resolveAnnotation(rootId); } document.addEventListener("keydown", handleKeyDown, true); return () => document.removeEventListener("keydown", handleKeyDown, true); }, [activeRestoreId, pendingAnnotation, store]); useEffect(() => { if (!pendingRestoreId) return; const annotation = store.annotations.find( (item) => item.id === pendingRestoreId ); if (!annotation) { setPendingRestoreId(null); return; } if (annotation.interactionState.route !== currentPathname) return; selectAndRestore(pendingRestoreId); setPendingRestoreId(null); }, [pendingRestoreId, store, currentPathname, selectAndRestore]); useLayoutEffect(() => { if (!mounted || !store.storageReady) return; const commentId = resolveShareCommentId( new URLSearchParams(window.location.search), prototypeSlug, ); if (!commentId) return; if (activeRestoreId === commentId) { if (prototypeSlug) clearPendingCommentRestore(prototypeSlug); return; } const annotation = store.annotations.find((item) => item.id === commentId); if (!annotation) return; handleCommentSelect(commentId); }, [ mounted, store.storageReady, store.annotations, activeRestoreId, handleCommentSelect, prototypeSlug, ]); useEffect(() => { if (!mounted) return; syncShareCommentParam(activeRestoreId); }, [activeRestoreId, mounted]); useEffect(() => { if (!mounted || !store.storageReady || !activeRestoreId) return; const commentId = resolveShareCommentId( new URLSearchParams(window.location.search), prototypeSlug, ); if (commentId !== activeRestoreId) return; if (!readHandlers()?.onRestore) return; liveStateArmedRef.current = false; store.restoreComment(activeRestoreId); if (prototypeSlug) clearPendingCommentRestore(prototypeSlug); }, [ mounted, store.storageReady, activeRestoreId, readHandlers, store, prototypeSlug, ]); // Deselect the active comment when the user navigates the review chrome away // from the comment it belongs to: switching sidebar panels (spec/variants), // or opening a design exploration. A selected comment is only meaningful while // its own comments/change-log panel is showing. const reviewOpen = integratedReview ? review.open : false; const reviewSidebarPanel = integratedReview ? review.sidebarPanel : null; const reviewActiveVariantSetId = integratedReview ? review.activeVariantSetId : null; useEffect(() => { if (!integratedReview || !activeRestoreId || !reviewOpen) return; const annotation = store.annotations.find( (item) => item.id === activeRestoreId, ); if (!annotation) return; const expectedPanel = isChangelogAnnotation(annotation) ? "change-log" : "comments"; if (reviewSidebarPanel !== expectedPanel || reviewActiveVariantSetId != null) { clearActiveComment(); } }, [ integratedReview, activeRestoreId, reviewOpen, reviewSidebarPanel, reviewActiveVariantSetId, store.annotations, clearActiveComment, ]); useEffect(() => { activeRestoreIdRef.current = activeRestoreId; }, [activeRestoreId]); // Deselect the active comment whenever the prototype's live state changes. // The first live-state change after a selection is the restore itself, which // arms tracking; every subsequent change is user-driven and clears the comment. useEffect(() => { return subscribeLiveStateChange(() => { if (!activeRestoreIdRef.current) return; if (!liveStateArmedRef.current) { liveStateArmedRef.current = true; return; } clearActiveComment(); }); }, [subscribeLiveStateChange, clearActiveComment]); // Deselect the active comment when the element it is anchored to is no longer // present/visible in the DOM (e.g. removed by a state or exploration change // that did not register as a tracked live-state edit). useEffect(() => { if (!activeRestoreId) return; const annotation = store.annotations.find( (item) => item.id === activeRestoreId, ); if (!annotation?.targetId) return; const { targetId } = annotation; const check = () => { if (!resolveAnnotationTargetById(targetId)) { clearActiveComment(); } }; const interval = window.setInterval(check, 400); return () => window.clearInterval(interval); }, [activeRestoreId, store.annotations, clearActiveComment]); useEffect(() => { if (!isFeedbackActive) { setPendingAnnotation(null); setPendingMultiSelectElements([]); setHoverInfo(null); modifiersHeldRef.current = { cmd: false, shift: false }; } }, [isFeedbackActive]); useEffect(() => { if (!isFeedbackActive) return; const style = document.createElement("style"); style.id = "feedback-cursor-styles"; style.textContent = buildCommentCaptureCursorStyles(); document.head.appendChild(style); return () => { style.remove(); }; }, [isFeedbackActive]); useEffect(() => { if (!isFeedbackActive || pendingAnnotation) return; const handleMouseMove = (e: MouseEvent) => { const target = (e.composedPath()[0] || e.target) as HTMLElement; if (isCommentCaptureBlockedTarget(target)) { setHoverInfo(null); return; } const elementUnder = deepElementFromPoint(e.clientX, e.clientY); if (!elementUnder || isCommentCaptureBlockedTarget(elementUnder)) { setHoverInfo(null); return; } const { name } = identifyElementWithReact(elementUnder, "filtered"); setHoverInfo({ element: name, rect: elementUnder.getBoundingClientRect(), }); }; document.addEventListener("mousemove", handleMouseMove); return () => document.removeEventListener("mousemove", handleMouseMove); }, [isFeedbackActive, pendingAnnotation]); useEffect(() => { if (!isFeedbackActive) return; const handleClick = (e: MouseEvent) => { const target = (e.composedPath()[0] || e.target) as HTMLElement; if (isCommentCaptureBlockedTarget(target)) return; if (e.metaKey && e.shiftKey && !pendingAnnotation) { e.preventDefault(); e.stopPropagation(); const elementUnder = deepElementFromPoint(e.clientX, e.clientY); if (!elementUnder || isCommentCaptureBlockedTarget(elementUnder)) return; const anchor = resolveCaptureAnchor(elementUnder); const rect = anchor.getBoundingClientRect(); const { name, path, reactComponents } = identifyElementWithReact( anchor, "filtered" ); const existingIndex = pendingMultiSelectElements.findIndex( (item) => item.element === anchor ); if (existingIndex >= 0) { setPendingMultiSelectElements((prev) => prev.filter((_, i) => i !== existingIndex) ); } else { setPendingMultiSelectElements((prev) => [ ...prev, { element: anchor, rect, name, path, reactComponents: reactComponents ?? undefined, }, ]); } return; } const isInteractive = closestCrossingShadow( target, "button, a, input, select, textarea, [role='button'], [onclick]" ); if (isInteractive) { e.preventDefault(); e.stopPropagation(); } if (pendingAnnotation) { e.preventDefault(); popupRef.current?.shake(); return; } e.preventDefault(); const elementUnder = deepElementFromPoint(e.clientX, e.clientY); if (!elementUnder || isCommentCaptureBlockedTarget(elementUnder)) return; const anchor = resolveCaptureAnchor(elementUnder); const { name, path, reactComponents } = identifyElementWithReact( anchor, "filtered" ); const rect = anchor.getBoundingClientRect(); const { x, y } = computeElementRelativeMarkerPosition( e.clientX, e.clientY, rect ); const isFixed = isElementFixed(anchor); const selection = window.getSelection(); let selectedText: string | undefined; if (selection && selection.toString().trim().length > 0) { selectedText = selection.toString().trim().slice(0, 500); } setPendingAnnotation({ x, y, positionAnchor: "element", clientY: e.clientY, element: name, elementPath: path, targetId: getPrototypeTargetIdFromElement(elementUnder) ?? undefined, selectedText, boundingBox: { x: rect.left, y: isFixed ? rect.top : rect.top + window.scrollY, width: rect.width, height: rect.height, }, captureViewport: getPrototypeCaptureViewport(anchor), viewportBoundingBox: toViewportBoundingBox(rect), nearbyText: getNearbyText(anchor), cssClasses: getElementClasses(anchor), isFixed, fullPath: getFullElementPath(anchor), accessibility: getAccessibilityInfo(anchor), computedStyles: getForensicComputedStyles(anchor), computedStylesObj: getDetailedComputedStyles(anchor), nearbyElements: getNearbyElements(anchor), reactComponents: reactComponents ?? undefined, sourceFile: detectSourceFile(anchor), targetElement: anchor, }); setActiveRestoreId(null); }; document.addEventListener("click", handleClick, true); return () => document.removeEventListener("click", handleClick, true); }, [isFeedbackActive, pendingAnnotation, pendingMultiSelectElements]); useEffect(() => { if (!isFeedbackActive) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Meta") modifiersHeldRef.current.cmd = true; if (e.key === "Shift") modifiersHeldRef.current.shift = true; }; const handleKeyUp = (e: KeyboardEvent) => { const wasHoldingBoth = modifiersHeldRef.current.cmd && modifiersHeldRef.current.shift; if (e.key === "Meta") modifiersHeldRef.current.cmd = false; if (e.key === "Shift") modifiersHeldRef.current.shift = false; const nowHoldingBoth = modifiersHeldRef.current.cmd && modifiersHeldRef.current.shift; if ( wasHoldingBoth && !nowHoldingBoth && pendingMultiSelectElements.length > 0 ) { createMultiSelectPendingAnnotation(); } }; const handleBlur = () => { modifiersHeldRef.current = { cmd: false, shift: false }; setPendingMultiSelectElements([]); }; document.addEventListener("keydown", handleKeyDown); document.addEventListener("keyup", handleKeyUp); window.addEventListener("blur", handleBlur); return () => { document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keyup", handleKeyUp); window.removeEventListener("blur", handleBlur); }; }, [ isFeedbackActive, pendingMultiSelectElements, createMultiSelectPendingAnnotation, ]); useEffect(() => { if (!isCommentModeActive) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; exitCommentMode(); }; const handlePointerDown = (event: PointerEvent) => { const target = (event.composedPath()[0] || event.target) as HTMLElement; if (!isCommentModeChromeInteraction(target)) return; exitCommentMode(); }; document.addEventListener("keydown", handleKeyDown, true); document.addEventListener("pointerdown", handlePointerDown, true); return () => { document.removeEventListener("keydown", handleKeyDown, true); document.removeEventListener("pointerdown", handlePointerDown, true); }; }, [exitCommentMode, isCommentModeActive]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const isTyping = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || (e.target instanceof HTMLElement && e.target.isContentEditable); if (e.key === "Escape") { if (pendingMultiSelectElements.length > 0) { setPendingMultiSelectElements([]); e.preventDefault(); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } return; } if (pendingAnnotation) return; if (isChangelogModeActive) { setIsGiveAMomentMode(false); e.preventDefault(); if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } } } if ( (e.metaKey || e.ctrlKey) && e.shiftKey && (e.key === "f" || e.key === "F") ) { e.preventDefault(); if (!isActive) { activateToolbar(); } else { setIsGiveAMomentMode((prev) => !prev); } } if ( (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && (e.key === "c" || e.key === "C") ) { if (isTyping) return; const hasTextSelection = Boolean( window.getSelection()?.toString().trim(), ); if (hasTextSelection) return; e.preventDefault(); if (integratedReview) { review.setCaptureChannel("comment"); review.openSidebar("comments"); } setActiveRestoreId(null); setIsActive(true); setIsGiveAMomentMode(true); return; } if (isTyping || e.metaKey || e.ctrlKey) return; }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [ activateToolbar, integratedReview, isActive, isChangelogModeActive, pendingAnnotation, pendingMultiSelectElements.length, review, ]); useEffect(() => { if (isActive) return; setIsGiveAMomentMode(false); if (!integratedReview) { setShowCommentsSidebar(false); } const shareCommentId = resolveShareCommentId( new URLSearchParams(window.location.search), prototypeSlug, ); if (!shareCommentId) { setActiveRestoreId(null); syncShareCommentParam(null); } }, [isActive, integratedReview, prototypeSlug]); const bridgeValue = useMemo( () => ({ onSelect: handleCommentSelect, selectedId: activeRestoreId, isCommentModeActive, isChangelogModeActive, onEnterCommentMode: enterCommentMode, onEnterChangelogMode: enterChangelogMode, onOpenCommentsPanel: openCommentsPanel, onToggleCommentMode: toggleCommentMode, onClose: deactivate, }), [ handleCommentSelect, activeRestoreId, isCommentModeActive, isChangelogModeActive, enterCommentMode, enterChangelogMode, openCommentsPanel, toggleCommentMode, deactivate, ] ); if (devOnly && process.env.NODE_ENV !== "development") { return null; } if (!mounted) { return ( {children} ); } const commentCount = getCommentRootAnnotations(store.annotations).length; const activeAnnotation = activeRestoreId ? store.annotations.find((item) => item.id === activeRestoreId) : null; const sidebarPortalTarget = typeof document !== "undefined" ? document.getElementById("prototype-comments-sidebar-root") : null; const sidebar = integratedReview ? null : ( ); const showLegacyToolbar = !integratedReview && !showCommentsSidebar; return ( {children} {!integratedReview && sidebarPortalTarget ? createPortal(sidebar, sidebarPortalTarget) : sidebar} {createPortal( (
{showLegacyToolbar && (
{commentCount > 0 && ( {commentCount} )}
)} {activeAnnotation && ( )} {isFeedbackActive ? ( ) : null} {isFeedbackActive && pendingAnnotation ? ( <>
{(() => { const markerViewport = getMarkerViewportPosition( { x: pendingAnnotation.x, y: pendingAnnotation.y, boundingBox: pendingAnnotation.boundingBox, isFixed: pendingAnnotation.isFixed, positionAnchor: pendingAnnotation.positionAnchor, }, pendingAnnotation.targetElement ?? null, ); return ( } element={pendingAnnotation.element} selectedText={pendingAnnotation.selectedText} computedStyles={pendingAnnotation.computedStylesObj} onSubmit={addAnnotation} onCancel={cancelAnnotation} isExiting={pendingExiting} lightMode={useLightTheme} showElementPath={false} accentColor="var(--prototype-comment-color-accent)" style={{ left: Math.max( 160, Math.min( window.innerWidth - 160, markerViewport.x, ), ), ...(markerViewport.y > window.innerHeight - 290 ? { bottom: window.innerHeight - markerViewport.y + 20, } : { top: markerViewport.y + 20 }), }} /> ); })()}
) : null}
) as ReactNode, document.body )} ); }