proto-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/AGENTS.md +350 -0
  2. package/README.md +279 -0
  3. package/components.json +18 -0
  4. package/package.json +73 -0
  5. package/scripts/download-mobbin-reference-images.mjs +112 -0
  6. package/scripts/lib/host-config.example.sh +48 -0
  7. package/scripts/lib/host-config.sh +25 -0
  8. package/scripts/lib/resolve-host-root.mjs +18 -0
  9. package/scripts/lib/source-path.sh +49 -0
  10. package/scripts/link-source-db.sh +128 -0
  11. package/scripts/link-source.sh +40 -0
  12. package/scripts/publish-package.sh +147 -0
  13. package/scripts/share-prototype.sh +279 -0
  14. package/scripts/sync-from-source.sh +74 -0
  15. package/scripts/verify-prototype-component-ids.mjs +151 -0
  16. package/scripts/verify-prototype-preview-states.mjs +135 -0
  17. package/src/app.ts +4 -0
  18. package/src/components/platform-ui/code-block.tsx +21 -0
  19. package/src/components/platform-ui/controls-panel-options.tsx +71 -0
  20. package/src/components/platform-ui/controls-panel-select.tsx +438 -0
  21. package/src/components/platform-ui/empty-state.tsx +15 -0
  22. package/src/components/platform-ui/icon-button.tsx +18 -0
  23. package/src/components/platform-ui/panel-select.tsx +123 -0
  24. package/src/components/platform-ui/panel.tsx +97 -0
  25. package/src/components/platform-ui/review-dropdown-menu.tsx +35 -0
  26. package/src/components/platform-ui/sidebar.tsx +293 -0
  27. package/src/components/platform-ui/toolbar-icon-button.tsx +34 -0
  28. package/src/components/platform-ui/toolbar.tsx +37 -0
  29. package/src/components/prototype-gallery-client.tsx +57 -0
  30. package/src/components/prototype-provider.tsx +20 -0
  31. package/src/components/prototypes/mini-pill-label.tsx +37 -0
  32. package/src/components/prototypes/prototype-change-log-panel.tsx +207 -0
  33. package/src/components/prototypes/prototype-comment-provider.tsx +61 -0
  34. package/src/components/prototypes/prototype-comment-toolbar.tsx +65 -0
  35. package/src/components/prototypes/prototype-control.tsx +31 -0
  36. package/src/components/prototypes/prototype-controls.tsx +553 -0
  37. package/src/components/prototypes/prototype-create-exploration-modal.tsx +117 -0
  38. package/src/components/prototypes/prototype-create-state-modal.tsx +117 -0
  39. package/src/components/prototypes/prototype-default-state-map-page.tsx +66 -0
  40. package/src/components/prototypes/prototype-design-brief-modal.tsx +128 -0
  41. package/src/components/prototypes/prototype-design-brief-panel.tsx +525 -0
  42. package/src/components/prototypes/prototype-design-context-panel.tsx +65 -0
  43. package/src/components/prototypes/prototype-floating-pill.module.scss +1341 -0
  44. package/src/components/prototypes/prototype-mobbin-gallery.tsx +330 -0
  45. package/src/components/prototypes/prototype-reference-docs.tsx +222 -0
  46. package/src/components/prototypes/prototype-review-chrome.module.scss +630 -0
  47. package/src/components/prototypes/prototype-review-chrome.tsx +1241 -0
  48. package/src/components/prototypes/prototype-review-dropdown-menu-item.tsx +1 -0
  49. package/src/components/prototypes/prototype-review-dropdown-menu-separator.tsx +1 -0
  50. package/src/components/prototypes/prototype-review-panel-menu-item.tsx +1 -0
  51. package/src/components/prototypes/prototype-review-panel-menu-separator.tsx +1 -0
  52. package/src/components/prototypes/prototype-review-panel-select.tsx +4 -0
  53. package/src/components/prototypes/prototype-review-sidebar.tsx +167 -0
  54. package/src/components/prototypes/prototype-share-link-restore.tsx +75 -0
  55. package/src/components/prototypes/prototype-share-mode.tsx +206 -0
  56. package/src/components/prototypes/prototype-spec-panel-content.tsx +538 -0
  57. package/src/components/prototypes/prototype-state-canvas-overlay.module.scss +336 -0
  58. package/src/components/prototypes/prototype-state-canvas-overlay.tsx +1023 -0
  59. package/src/components/prototypes/prototype-state-canvas.tsx +38 -0
  60. package/src/components/prototypes/prototype-state-screenshot-preview.tsx +47 -0
  61. package/src/components/prototypes/prototype-target.tsx +120 -0
  62. package/src/components/prototypes/prototype-tweak-highlight.tsx +132 -0
  63. package/src/components/prototypes/prototype-tweaks.tsx +48 -0
  64. package/src/components/prototypes/prototype-variant-explorer.tsx +347 -0
  65. package/src/components/prototypes/prototype-variant-rationale-panel.tsx +49 -0
  66. package/src/components/prototypes/prototype-variant-tabs.tsx +207 -0
  67. package/src/components/prototypes/prototype-vercel-preview-modal.tsx +120 -0
  68. package/src/components/prototypes/state-map-annotation-tooltip.module.scss +89 -0
  69. package/src/components/prototypes/state-map-annotation-tooltip.tsx +137 -0
  70. package/src/components/prototypes/state-map-wireframe-highlight.module.scss +25 -0
  71. package/src/components/prototypes/state-map-wireframe-highlight.tsx +82 -0
  72. package/src/components/shell/prototype-gallery-shell.tsx +79 -0
  73. package/src/components/shell/prototype-screenshot-capture.tsx +45 -0
  74. package/src/components/shell/prototype-shell.module.scss +194 -0
  75. package/src/components/shell/prototype-shell.tsx +160 -0
  76. package/src/components/shell/prototype-state-screenshot-capture.tsx +68 -0
  77. package/src/components/ui/button.tsx +58 -0
  78. package/src/components/ui/dialog.module.scss +81 -0
  79. package/src/components/ui/dialog.tsx +127 -0
  80. package/src/components/ui/dropdown-menu.tsx +200 -0
  81. package/src/components/ui/input.tsx +22 -0
  82. package/src/components/ui/label.tsx +26 -0
  83. package/src/components/ui/option-select.tsx +81 -0
  84. package/src/components/ui/scroll-area.tsx +48 -0
  85. package/src/components/ui/separator.tsx +31 -0
  86. package/src/components/ui/slider.tsx +28 -0
  87. package/src/components/ui/switch.tsx +29 -0
  88. package/src/components/ui/tabs.tsx +55 -0
  89. package/src/components/ui/toggle-group.tsx +80 -0
  90. package/src/components/ui/tooltip.tsx +32 -0
  91. package/src/config/create-prototype-gallery-page.tsx +65 -0
  92. package/src/config/create-prototype-host-app.tsx +121 -0
  93. package/src/config/create-prototype-page.tsx +41 -0
  94. package/src/config/create-prototype-site-layout.tsx +28 -0
  95. package/src/config/define-prototype-config.ts +7 -0
  96. package/src/config/prototype-icon.tsx +25 -0
  97. package/src/config/prototype-site-layout-client.tsx +35 -0
  98. package/src/config/with-prototype.ts +20 -0
  99. package/src/index.ts +303 -0
  100. package/src/lib/pr-split/build-pr-agent-prompt.ts +68 -0
  101. package/src/lib/pr-split/build-pr-split-prototype-url.ts +12 -0
  102. package/src/lib/pr-split/pr-split-highlight.ts +6 -0
  103. package/src/lib/pr-split/pr-split-types.ts +58 -0
  104. package/src/lib/pr-split/resolve-source-preview-url.ts +45 -0
  105. package/src/lib/pr-split/use-pr-vercel-previews.ts +69 -0
  106. package/src/lib/prototype-comments/core/annotation-channel.ts +41 -0
  107. package/src/lib/prototype-comments/core/annotation-status.ts +60 -0
  108. package/src/lib/prototype-comments/core/annotation-target.ts +567 -0
  109. package/src/lib/prototype-comments/core/capture-theme.ts +41 -0
  110. package/src/lib/prototype-comments/core/capture.ts +11 -0
  111. package/src/lib/prototype-comments/core/comment-capture-blocked.ts +47 -0
  112. package/src/lib/prototype-comments/core/comment-threads.ts +52 -0
  113. package/src/lib/prototype-comments/core/element-identification.ts +615 -0
  114. package/src/lib/prototype-comments/core/export.ts +35 -0
  115. package/src/lib/prototype-comments/core/freeze-animations.ts +266 -0
  116. package/src/lib/prototype-comments/core/identify-element.ts +76 -0
  117. package/src/lib/prototype-comments/core/normalize-viewport.ts +35 -0
  118. package/src/lib/prototype-comments/core/react-detection.ts +704 -0
  119. package/src/lib/prototype-comments/core/restore.ts +7 -0
  120. package/src/lib/prototype-comments/core/screenshot.ts +428 -0
  121. package/src/lib/prototype-comments/core/source-location.ts +906 -0
  122. package/src/lib/prototype-comments/core/storage.ts +80 -0
  123. package/src/lib/prototype-comments/core/types.ts +79 -0
  124. package/src/lib/prototype-comments/core/validation.ts +17 -0
  125. package/src/lib/prototype-comments/hooks/useLayoutShift.ts +61 -0
  126. package/src/lib/prototype-comments/index.ts +58 -0
  127. package/src/lib/prototype-comments/react/CommentCaptureToolbar.tsx +1398 -0
  128. package/src/lib/prototype-comments/react/CommentProvider.tsx +298 -0
  129. package/src/lib/prototype-comments/scss.d.ts +10 -0
  130. package/src/lib/prototype-comments/ui/CommentAnnotationOverlay.tsx +253 -0
  131. package/src/lib/prototype-comments/ui/CommentDetailPage.tsx +56 -0
  132. package/src/lib/prototype-comments/ui/CommentHeaderCount.tsx +28 -0
  133. package/src/lib/prototype-comments/ui/CommentThread.tsx +331 -0
  134. package/src/lib/prototype-comments/ui/CommentsGrid.tsx +780 -0
  135. package/src/lib/prototype-comments/ui/CommentsListPage.tsx +25 -0
  136. package/src/lib/prototype-comments/ui/CommentsSidebar.tsx +77 -0
  137. package/src/lib/prototype-comments/ui/annotation-marker/index.tsx +200 -0
  138. package/src/lib/prototype-comments/ui/annotation-marker/styles.module.scss +194 -0
  139. package/src/lib/prototype-comments/ui/annotation-popup/index.tsx +335 -0
  140. package/src/lib/prototype-comments/ui/annotation-popup/styles.module.scss +469 -0
  141. package/src/lib/prototype-comments/ui/capture-styles.module.scss +2225 -0
  142. package/src/lib/prototype-comments/ui/comment-highlight.ts +48 -0
  143. package/src/lib/prototype-comments/ui/comment-thread.module.scss +203 -0
  144. package/src/lib/prototype-comments/ui/comments-grid-card.module.scss +253 -0
  145. package/src/lib/prototype-comments/ui/comments-sidebar.module.scss +123 -0
  146. package/src/lib/prototype-comments/ui/icon-transitions.module.scss +51 -0
  147. package/src/lib/prototype-comments/ui/icons.tsx +1086 -0
  148. package/src/lib/prototype-comments/ui/is-comment-detail-path.ts +7 -0
  149. package/src/lib/prototypes/capture-prototype-viewport.ts +32 -0
  150. package/src/lib/prototypes/changelog-meta-context.tsx +162 -0
  151. package/src/lib/prototypes/changelog-meta-storage.ts +62 -0
  152. package/src/lib/prototypes/changelog-meta.ts +95 -0
  153. package/src/lib/prototypes/create-prototype-registry.ts +74 -0
  154. package/src/lib/prototypes/create-state-screenshot-thumbnail.ts +46 -0
  155. package/src/lib/prototypes/design-exploration-default-storage.ts +68 -0
  156. package/src/lib/prototypes/design-exploration-default.ts +51 -0
  157. package/src/lib/prototypes/design-exploration-types.ts +203 -0
  158. package/src/lib/prototypes/prototype-comment-registry.tsx +145 -0
  159. package/src/lib/prototypes/prototype-comment-review-bridge.tsx +40 -0
  160. package/src/lib/prototypes/prototype-comment-review-state.ts +57 -0
  161. package/src/lib/prototypes/prototype-component-registry.ts +40 -0
  162. package/src/lib/prototypes/prototype-config-types.ts +34 -0
  163. package/src/lib/prototypes/prototype-preview-state-registry.ts +443 -0
  164. package/src/lib/prototypes/prototype-preview-state-types.ts +89 -0
  165. package/src/lib/prototypes/prototype-review-context.tsx +905 -0
  166. package/src/lib/prototypes/prototype-review-url.ts +134 -0
  167. package/src/lib/prototypes/prototype-share-link.ts +175 -0
  168. package/src/lib/prototypes/prototype-state-canvas-constants.ts +118 -0
  169. package/src/lib/prototypes/prototype-state-canvas-types.ts +159 -0
  170. package/src/lib/prototypes/prototype-state-screenshot.ts +39 -0
  171. package/src/lib/prototypes/prototype-tweak-registry.ts +96 -0
  172. package/src/lib/prototypes/prototype-tweak-types.ts +31 -0
  173. package/src/lib/prototypes/reference-docs.ts +137 -0
  174. package/src/lib/prototypes/screenshot-manifest.ts +33 -0
  175. package/src/lib/prototypes/screenshot-src.ts +7 -0
  176. package/src/lib/prototypes/share-command.ts +8 -0
  177. package/src/lib/prototypes/state-map-annotation.ts +70 -0
  178. package/src/lib/prototypes/use-design-exploration-default.ts +114 -0
  179. package/src/lib/prototypes/use-persisted-local-state.ts +140 -0
  180. package/src/lib/prototypes/use-prototype-comments.ts +63 -0
  181. package/src/lib/prototypes/use-prototype-mobile-viewport-frame.ts +46 -0
  182. package/src/lib/prototypes/use-prototype-state-screenshot-src.ts +71 -0
  183. package/src/lib/prototypes/use-prototype-tool-theme.tsx +115 -0
  184. package/src/lib/prototypes/use-prototype-viewport-frame.ts +34 -0
  185. package/src/lib/prototypes/use-register-prototype-reference-docs.ts +16 -0
  186. package/src/lib/prototypes/use-snap-corner-position.ts +417 -0
  187. package/src/lib/prototypes/use-toolbar-hover-panel.ts +98 -0
  188. package/src/lib/prototypes/variant-set-lucide-icon.tsx +55 -0
  189. package/src/lib/tool-portal.ts +33 -0
  190. package/src/lib/use-copy-to-clipboard.ts +81 -0
  191. package/src/lib/utils.ts +6 -0
  192. package/src/lib/vercel-preview/parse-vercel-github-comment.ts +81 -0
  193. package/src/server/create-host-api-route.ts +100 -0
  194. package/src/server/create-prototype-api-routes.ts +428 -0
  195. package/src/server/gallery/create-gallery-api.ts +56 -0
  196. package/src/server/gallery/folders-route.ts +156 -0
  197. package/src/server/gallery/gallery-env.ts +39 -0
  198. package/src/server/gallery/gallery-route.ts +150 -0
  199. package/src/server/gallery/gallery-types.ts +15 -0
  200. package/src/server/gallery/image-route.ts +59 -0
  201. package/src/server/gallery/item-route.ts +92 -0
  202. package/src/server/pr-vercel-preview-route.ts +162 -0
  203. package/src/server/redis/client.ts +55 -0
  204. package/src/server/redis/prototype-changelog-meta.ts +31 -0
  205. package/src/server/redis/prototype-comments.ts +92 -0
  206. package/src/server/redis/prototype-design-exploration-default.ts +32 -0
  207. package/src/server.ts +51 -0
  208. package/src/styles/globals.css +624 -0
  209. package/tsconfig.json +19 -0
@@ -0,0 +1,1398 @@
1
+ "use client";
2
+
3
+ import { usePathname, useRouter } from "next/navigation";
4
+ import {
5
+ createContext,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useLayoutEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ type RefObject,
14
+ type ReactNode,
15
+ } from "react";
16
+ import { createPortal } from "react-dom";
17
+
18
+ import {
19
+ getPrototypeTargetElement,
20
+ getPrototypeTargetIdFromElement,
21
+ } from "@prototype/components/prototypes/prototype-target";
22
+ import { usePrototypeCommentRegistry } from "@prototype/lib/prototypes/prototype-comment-registry";
23
+ import { usePrototypeReviewOptional } from "@prototype/lib/prototypes/prototype-review-context";
24
+ import { usePrototypeToolTheme } from "@prototype/lib/prototypes/use-prototype-tool-theme";
25
+ import { getPrototypeSlugFromPathname } from "@prototype/lib/prototypes/share-command";
26
+ import {
27
+ clearPendingCommentRestore,
28
+ resolveShareCommentId,
29
+ stashPendingCommentRestore,
30
+ syncShareCommentParam,
31
+ } from "@prototype/lib/prototypes/prototype-share-link";
32
+
33
+ import {
34
+ computeElementCenterMarkerPosition,
35
+ computeElementRelativeMarkerPosition,
36
+ deepElementFromPoint,
37
+ getAnnotationMarkerPosition,
38
+ getMarkerViewportPosition,
39
+ getPrototypeScrollContainer,
40
+ resolveAnnotationTargetById,
41
+ type AnnotationTargetOptions,
42
+ } from "../core/annotation-target";
43
+ import {
44
+ getAdjacentDisplayCommentId,
45
+ sortAnnotationsForDisplay,
46
+ } from "../core/annotation-status";
47
+ import {
48
+ filterAnnotationsByChannel,
49
+ getCommentRootAnnotations,
50
+ isChangelogAnnotation,
51
+ } from "../core/annotation-channel";
52
+ import { getRootAnnotations, resolveThreadRootId } from "../core/comment-threads";
53
+ import {
54
+ buildCommentCaptureCursorStyles,
55
+ isCommentCaptureBlockedTarget,
56
+ } from "../core/comment-capture-blocked";
57
+ import { closestCrossingShadow } from "../core/element-identification";
58
+ import { injectCaptureColorTokens } from "../core/capture-theme";
59
+ import { originalSetTimeout } from "../core/freeze-animations";
60
+ import {
61
+ detectSourceFile,
62
+ getAccessibilityInfo,
63
+ getDetailedComputedStyles,
64
+ getElementClasses,
65
+ getForensicComputedStyles,
66
+ getFullElementPath,
67
+ getNearbyElements,
68
+ getNearbyText,
69
+ identifyElementWithReact,
70
+ isElementFixed,
71
+ } from "../core/identify-element";
72
+ import { captureViewportScreenshot } from "../core/screenshot";
73
+ import { useCommentStore } from "../react/CommentProvider";
74
+ import {
75
+ AnnotationPopupCSS,
76
+ type AnnotationPopupCSSHandle,
77
+ } from "../ui/annotation-popup";
78
+ import { PendingMarker } from "../ui/annotation-marker";
79
+ import { CommentAnnotationOverlay } from "../ui/CommentAnnotationOverlay";
80
+ import { CommentsSidebar } from "../ui/CommentsSidebar";
81
+ import { IconChatEllipsis, IconPlus, IconXmarkLarge } from "../ui/icons";
82
+ import styles from "../ui/capture-styles.module.scss";
83
+
84
+ type PendingAnnotation = {
85
+ x: number;
86
+ y: number;
87
+ clientY: number;
88
+ element: string;
89
+ elementPath: string;
90
+ selectedText?: string;
91
+ boundingBox?: { x: number; y: number; width: number; height: number };
92
+ nearbyText?: string;
93
+ cssClasses?: string;
94
+ isMultiSelect?: boolean;
95
+ isFixed?: boolean;
96
+ fullPath?: string;
97
+ accessibility?: string;
98
+ computedStyles?: string;
99
+ computedStylesObj?: Record<string, string>;
100
+ nearbyElements?: string;
101
+ reactComponents?: string;
102
+ sourceFile?: string;
103
+ elementBoundingBoxes?: Array<{
104
+ x: number;
105
+ y: number;
106
+ width: number;
107
+ height: number;
108
+ }>;
109
+ multiSelectElements?: HTMLElement[];
110
+ targetElement?: HTMLElement;
111
+ targetId?: string;
112
+ positionAnchor?: "viewport" | "element";
113
+ };
114
+
115
+ function resolveCaptureAnchor(elementUnder: HTMLElement): HTMLElement {
116
+ return getPrototypeTargetElement(elementUnder) ?? elementUnder;
117
+ }
118
+
119
+ type HoverInfo = {
120
+ element: string;
121
+ rect: DOMRect | null;
122
+ };
123
+
124
+ type MultiSelectItem = {
125
+ element: HTMLElement;
126
+ rect: DOMRect;
127
+ name: string;
128
+ path: string;
129
+ reactComponents?: string;
130
+ };
131
+
132
+ export type CommentCaptureToolbarProps<TState = unknown> = {
133
+ sidebarBasePath?: string;
134
+ resolveTargetOptions?:
135
+ | AnnotationTargetOptions
136
+ | (() => AnnotationTargetOptions);
137
+ devOnly?: boolean;
138
+ className?: string;
139
+ children?: ReactNode;
140
+ };
141
+
142
+ export type CommentCaptureBridgeValue = {
143
+ onSelect: (id: string) => void;
144
+ selectedId: string | null;
145
+ isCommentModeActive: boolean;
146
+ isChangelogModeActive: boolean;
147
+ onEnterCommentMode: () => void;
148
+ onEnterChangelogMode: () => void;
149
+ onOpenCommentsPanel: () => void;
150
+ onToggleCommentMode: () => void;
151
+ onClose: () => void;
152
+ };
153
+
154
+ const CommentCaptureBridgeContext =
155
+ createContext<CommentCaptureBridgeValue | null>(null);
156
+
157
+ export function useCommentCaptureBridge(): CommentCaptureBridgeValue {
158
+ const context = useContext(CommentCaptureBridgeContext);
159
+ if (!context) {
160
+ throw new Error(
161
+ "useCommentCaptureBridge must be used within CommentCaptureToolbar"
162
+ );
163
+ }
164
+ return context;
165
+ }
166
+
167
+ export function useCommentCaptureBridgeOptional(): CommentCaptureBridgeValue | null {
168
+ return useContext(CommentCaptureBridgeContext);
169
+ }
170
+
171
+ export function CommentCaptureToolbar<TState = unknown>({
172
+ sidebarBasePath = "/canvas",
173
+ resolveTargetOptions,
174
+ devOnly = false,
175
+ className,
176
+ children,
177
+ }: CommentCaptureToolbarProps<TState>) {
178
+ const store = useCommentStore<TState>();
179
+ const { readHandlers, subscribeLiveStateChange } =
180
+ usePrototypeCommentRegistry();
181
+ const review = usePrototypeReviewOptional();
182
+ const integratedReview = review != null;
183
+ const { useLightTheme, commentTheme } = usePrototypeToolTheme();
184
+ const router = useRouter();
185
+ const currentPathname = usePathname() ?? "/";
186
+ const prototypeSlug = getPrototypeSlugFromPathname(currentPathname);
187
+ const popupRef = useRef<AnnotationPopupCSSHandle>(null);
188
+ const modifiersHeldRef = useRef({ cmd: false, shift: false });
189
+
190
+ const [mounted, setMounted] = useState(false);
191
+ const [isActive, setIsActive] = useState(false);
192
+ const [isGiveAMomentMode, setIsGiveAMomentMode] = useState(false);
193
+ const [showCommentsSidebar, setShowCommentsSidebar] = useState(false);
194
+ const [activeRestoreId, setActiveRestoreId] = useState<string | null>(null);
195
+ const [pendingRestoreId, setPendingRestoreId] = useState<string | null>(null);
196
+ // Mirrors of selection state used by the (persistent) live-state subscriber so
197
+ // it reads fresh values without re-subscribing. `liveStateArmedRef` flips true
198
+ // after the restore-driven live-state change so subsequent (user-driven)
199
+ // changes deselect the active comment.
200
+ const activeRestoreIdRef = useRef<string | null>(null);
201
+ const liveStateArmedRef = useRef(false);
202
+ const [pendingAnnotation, setPendingAnnotation] =
203
+ useState<PendingAnnotation | null>(null);
204
+ const [pendingExiting, setPendingExiting] = useState(false);
205
+ const [pendingMultiSelectElements, setPendingMultiSelectElements] = useState<
206
+ MultiSelectItem[]
207
+ >([]);
208
+ const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
209
+ const [scrollY, setScrollY] = useState(0);
210
+
211
+ const isFeedbackActive = isGiveAMomentMode;
212
+ const annotationTargetOptions = useMemo(() => {
213
+ if (typeof resolveTargetOptions === "function") {
214
+ return resolveTargetOptions();
215
+ }
216
+ return resolveTargetOptions;
217
+ }, [resolveTargetOptions]);
218
+
219
+ useEffect(() => {
220
+ setMounted(true);
221
+ injectCaptureColorTokens();
222
+ }, []);
223
+
224
+ useLayoutEffect(() => {
225
+ if (!prototypeSlug) return;
226
+ resolveShareCommentId(
227
+ new URLSearchParams(window.location.search),
228
+ prototypeSlug,
229
+ );
230
+ }, [prototypeSlug]);
231
+
232
+ useEffect(() => {
233
+ const syncScroll = () => setScrollY(window.scrollY);
234
+ syncScroll();
235
+ window.addEventListener("scroll", syncScroll, { passive: true });
236
+
237
+ const scrollContainer = getPrototypeScrollContainer();
238
+ scrollContainer?.addEventListener("scroll", syncScroll, { passive: true });
239
+
240
+ return () => {
241
+ window.removeEventListener("scroll", syncScroll);
242
+ scrollContainer?.removeEventListener("scroll", syncScroll);
243
+ };
244
+ }, []);
245
+
246
+ const activateToolbar = useCallback(() => {
247
+ setIsActive(true);
248
+ if (integratedReview) {
249
+ review.openSidebar("comments");
250
+ return;
251
+ }
252
+ setShowCommentsSidebar(true);
253
+ }, [integratedReview, review]);
254
+
255
+ const clearActiveComment = useCallback(() => {
256
+ activeRestoreIdRef.current = null;
257
+ liveStateArmedRef.current = false;
258
+ setActiveRestoreId(null);
259
+ syncShareCommentParam(null);
260
+ if (prototypeSlug) clearPendingCommentRestore(prototypeSlug);
261
+ }, [prototypeSlug]);
262
+
263
+ // Restore a comment's frozen state and begin tracking live-state changes. The
264
+ // refs are set synchronously so the live-state change that the restore itself
265
+ // triggers is recognized (and "arms" tracking) rather than treated as a user edit.
266
+ const selectAndRestore = useCallback(
267
+ (id: string) => {
268
+ liveStateArmedRef.current = false;
269
+ activeRestoreIdRef.current = id;
270
+ store.restoreComment(id);
271
+ setActiveRestoreId(id);
272
+ syncShareCommentParam(id);
273
+ if (prototypeSlug) stashPendingCommentRestore(prototypeSlug, id);
274
+ // Fallback: if the restore produced no live-state change (the comment's
275
+ // captured state already matched), arm tracking after the restore settles
276
+ // so the next user edit still deselects.
277
+ window.setTimeout(() => {
278
+ if (activeRestoreIdRef.current === id) {
279
+ liveStateArmedRef.current = true;
280
+ }
281
+ }, 80);
282
+ },
283
+ [store, prototypeSlug],
284
+ );
285
+
286
+ const deactivate = useCallback(() => {
287
+ clearActiveComment();
288
+ setIsGiveAMomentMode(false);
289
+ if (integratedReview) {
290
+ review.closeSidebar();
291
+ } else {
292
+ setShowCommentsSidebar(false);
293
+ }
294
+ setIsActive(false);
295
+ setPendingAnnotation(null);
296
+ setPendingMultiSelectElements([]);
297
+ setHoverInfo(null);
298
+ }, [integratedReview, review, clearActiveComment]);
299
+
300
+ const enterCommentMode = useCallback(() => {
301
+ if (integratedReview) {
302
+ review.setCaptureChannel("comment");
303
+ }
304
+ setActiveRestoreId(null);
305
+ setIsGiveAMomentMode((active) => {
306
+ const next = !active;
307
+ if (next) {
308
+ setIsActive(true);
309
+ if (integratedReview) {
310
+ review.openSidebar("comments");
311
+ }
312
+ } else if (integratedReview ? !review.open : true) {
313
+ setIsActive(false);
314
+ }
315
+ return next;
316
+ });
317
+ }, [integratedReview, review]);
318
+
319
+ const openCommentsPanel = useCallback(() => {
320
+ if (integratedReview) {
321
+ review.setCaptureChannel("comment");
322
+ }
323
+ setActiveRestoreId(null);
324
+ setIsGiveAMomentMode(true);
325
+ setIsActive(true);
326
+ if (integratedReview) {
327
+ review.openSidebar("comments");
328
+ } else {
329
+ setShowCommentsSidebar(true);
330
+ }
331
+ }, [integratedReview, review]);
332
+
333
+ const enterChangelogMode = useCallback(() => {
334
+ if (!integratedReview) return;
335
+ review.setCaptureChannel("changelog");
336
+ setActiveRestoreId(null);
337
+ setIsGiveAMomentMode((active) => {
338
+ const next = !active;
339
+ if (next) {
340
+ setIsActive(true);
341
+ review.openSidebar("change-log");
342
+ } else if (!review.open) {
343
+ setIsActive(false);
344
+ }
345
+ return next;
346
+ });
347
+ }, [integratedReview, review]);
348
+
349
+ const toggleCommentMode = useCallback(() => {
350
+ if (integratedReview) {
351
+ review.setCaptureChannel("comment");
352
+ }
353
+ setActiveRestoreId(null);
354
+ setIsGiveAMomentMode((active) => !active);
355
+ }, [integratedReview, review]);
356
+
357
+ const cancelAnnotation = useCallback(() => {
358
+ setPendingExiting(true);
359
+ originalSetTimeout(() => {
360
+ setPendingAnnotation(null);
361
+ setPendingExiting(false);
362
+ }, 150);
363
+ }, []);
364
+
365
+ const createMultiSelectPendingAnnotation = useCallback(() => {
366
+ if (pendingMultiSelectElements.length === 0) return;
367
+
368
+ const firstItem = pendingMultiSelectElements[0];
369
+ const firstEl = firstItem.element;
370
+ const isMulti = pendingMultiSelectElements.length > 1;
371
+ const freshRects = pendingMultiSelectElements.map((item) =>
372
+ item.element.getBoundingClientRect()
373
+ );
374
+
375
+ if (!isMulti) {
376
+ const anchor = resolveCaptureAnchor(firstEl);
377
+ const rect = anchor.getBoundingClientRect();
378
+ const isFixed = isElementFixed(anchor);
379
+
380
+ setPendingAnnotation({
381
+ ...computeElementCenterMarkerPosition(),
382
+ positionAnchor: "element",
383
+ clientY: rect.top,
384
+ element: firstItem.name,
385
+ elementPath: firstItem.path,
386
+ targetId: getPrototypeTargetIdFromElement(firstEl) ?? undefined,
387
+ boundingBox: {
388
+ x: rect.left,
389
+ y: isFixed ? rect.top : rect.top + window.scrollY,
390
+ width: rect.width,
391
+ height: rect.height,
392
+ },
393
+ isFixed,
394
+ fullPath: getFullElementPath(anchor),
395
+ accessibility: getAccessibilityInfo(anchor),
396
+ computedStyles: getForensicComputedStyles(anchor),
397
+ computedStylesObj: getDetailedComputedStyles(anchor),
398
+ nearbyElements: getNearbyElements(anchor),
399
+ cssClasses: getElementClasses(anchor),
400
+ nearbyText: getNearbyText(anchor),
401
+ reactComponents: firstItem.reactComponents,
402
+ sourceFile: detectSourceFile(anchor),
403
+ targetElement: anchor,
404
+ });
405
+ } else {
406
+ const bounds = {
407
+ left: Math.min(...freshRects.map((r) => r.left)),
408
+ top: Math.min(...freshRects.map((r) => r.top)),
409
+ right: Math.max(...freshRects.map((r) => r.right)),
410
+ bottom: Math.max(...freshRects.map((r) => r.bottom)),
411
+ };
412
+
413
+ const names = pendingMultiSelectElements
414
+ .slice(0, 5)
415
+ .map((item) => item.name)
416
+ .join(", ");
417
+ const suffix =
418
+ pendingMultiSelectElements.length > 5
419
+ ? ` +${pendingMultiSelectElements.length - 5} more`
420
+ : "";
421
+
422
+ const lastItem =
423
+ pendingMultiSelectElements[pendingMultiSelectElements.length - 1];
424
+ const lastEl = lastItem.element;
425
+ const lastRect = freshRects[freshRects.length - 1];
426
+ const lastCenterY = lastRect.top + lastRect.height / 2;
427
+
428
+ setPendingAnnotation({
429
+ ...computeElementCenterMarkerPosition(),
430
+ positionAnchor: "element",
431
+ clientY: lastCenterY,
432
+ element: `${pendingMultiSelectElements.length} elements: ${names}${suffix}`,
433
+ elementPath: "multi-select",
434
+ boundingBox: {
435
+ x: bounds.left,
436
+ y: bounds.top + window.scrollY,
437
+ width: bounds.right - bounds.left,
438
+ height: bounds.bottom - bounds.top,
439
+ },
440
+ isMultiSelect: true,
441
+ isFixed: isElementFixed(lastEl),
442
+ elementBoundingBoxes: freshRects.map((rect) => ({
443
+ x: rect.left,
444
+ y: rect.top + window.scrollY,
445
+ width: rect.width,
446
+ height: rect.height,
447
+ })),
448
+ multiSelectElements: pendingMultiSelectElements.map(
449
+ (item) => item.element
450
+ ),
451
+ targetElement: lastEl,
452
+ fullPath: getFullElementPath(firstEl),
453
+ accessibility: getAccessibilityInfo(firstEl),
454
+ computedStyles: getForensicComputedStyles(firstEl),
455
+ computedStylesObj: getDetailedComputedStyles(firstEl),
456
+ nearbyElements: getNearbyElements(firstEl),
457
+ cssClasses: getElementClasses(firstEl),
458
+ nearbyText: getNearbyText(firstEl),
459
+ sourceFile: detectSourceFile(firstEl),
460
+ });
461
+ }
462
+
463
+ setPendingMultiSelectElements([]);
464
+ setHoverInfo(null);
465
+ }, [pendingMultiSelectElements]);
466
+
467
+ const addAnnotation = useCallback(
468
+ async (comment: string) => {
469
+ if (!pendingAnnotation) return;
470
+ if (!store.storageReady || store.storageError) return;
471
+
472
+ const screenshotResult = await captureViewportScreenshot();
473
+ const id = Date.now().toString();
474
+
475
+ store.addAnnotation({
476
+ id,
477
+ x: pendingAnnotation.x,
478
+ y: pendingAnnotation.y,
479
+ positionAnchor: pendingAnnotation.positionAnchor ?? "element",
480
+ targetId: pendingAnnotation.targetId,
481
+ comment,
482
+ element: pendingAnnotation.element,
483
+ elementPath: pendingAnnotation.elementPath,
484
+ timestamp: Date.now(),
485
+ selectedText: pendingAnnotation.selectedText,
486
+ boundingBox: pendingAnnotation.boundingBox,
487
+ nearbyText: pendingAnnotation.nearbyText,
488
+ cssClasses: pendingAnnotation.cssClasses,
489
+ isMultiSelect: pendingAnnotation.isMultiSelect,
490
+ isFixed: pendingAnnotation.isFixed,
491
+ fullPath: pendingAnnotation.fullPath,
492
+ accessibility: pendingAnnotation.accessibility,
493
+ computedStyles: pendingAnnotation.computedStyles,
494
+ nearbyElements: pendingAnnotation.nearbyElements,
495
+ reactComponents: pendingAnnotation.reactComponents,
496
+ sourceFile: pendingAnnotation.sourceFile,
497
+ screenshot: screenshotResult?.dataUrl ?? undefined,
498
+ captureViewport: screenshotResult?.captureViewport,
499
+ channel: integratedReview ? review.captureChannel : "comment",
500
+ });
501
+
502
+ setPendingExiting(true);
503
+ originalSetTimeout(() => {
504
+ setPendingAnnotation(null);
505
+ setPendingExiting(false);
506
+ }, 150);
507
+
508
+ window.getSelection()?.removeAllRanges();
509
+ },
510
+ [pendingAnnotation, store, integratedReview, review]
511
+ );
512
+
513
+ const openAnnotationPanel = useCallback(
514
+ (channel: "comment" | "changelog" = integratedReview
515
+ ? review.captureChannel
516
+ : "comment") => {
517
+ setIsActive(true);
518
+ if (integratedReview) {
519
+ review.openSidebar(channel === "changelog" ? "change-log" : "comments");
520
+ return;
521
+ }
522
+ setShowCommentsSidebar(true);
523
+ },
524
+ [integratedReview, review],
525
+ );
526
+
527
+ const handleCommentSelect = useCallback(
528
+ (id: string) => {
529
+ const rootId = resolveThreadRootId(store.annotations, id);
530
+ const annotation = store.annotations.find((item) => item.id === rootId);
531
+ if (!annotation) return;
532
+
533
+ setPendingAnnotation(null);
534
+ openAnnotationPanel(
535
+ isChangelogAnnotation(annotation) ? "changelog" : "comment",
536
+ );
537
+
538
+ if (annotation.interactionState.route !== currentPathname) {
539
+ setPendingRestoreId(rootId);
540
+ router.push(annotation.interactionState.route);
541
+ return;
542
+ }
543
+
544
+ selectAndRestore(rootId);
545
+ },
546
+ [store, currentPathname, router, openAnnotationPanel, selectAndRestore]
547
+ );
548
+
549
+ useEffect(() => {
550
+ if (!activeRestoreId || pendingAnnotation) return;
551
+ const currentRestoreId = activeRestoreId;
552
+
553
+ function isTypingTarget(target: EventTarget | null) {
554
+ if (!(target instanceof HTMLElement)) return false;
555
+ const tag = target.tagName;
556
+ return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable;
557
+ }
558
+
559
+ function handleKeyDown(event: KeyboardEvent) {
560
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
561
+ if (isTypingTarget(event.target)) return;
562
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
563
+
564
+ const rootId = resolveThreadRootId(store.annotations, currentRestoreId);
565
+ const currentRoot = store.annotations.find((item) => item.id === rootId);
566
+ const channelFilter = currentRoot
567
+ ? isChangelogAnnotation(currentRoot)
568
+ ? "changelog"
569
+ : "comment"
570
+ : null;
571
+ const sorted = sortAnnotationsForDisplay(
572
+ channelFilter
573
+ ? filterAnnotationsByChannel(store.annotations, channelFilter)
574
+ : store.annotations,
575
+ channelFilter === "changelog" ? "oldest" : "newest",
576
+ );
577
+ if (sorted.length < 2) return;
578
+
579
+ const direction = event.key === "ArrowUp" ? "prev" : "next";
580
+ const nextId = getAdjacentDisplayCommentId(
581
+ sorted,
582
+ currentRestoreId,
583
+ direction,
584
+ );
585
+ if (!nextId || nextId === currentRestoreId) return;
586
+
587
+ event.preventDefault();
588
+ handleCommentSelect(nextId);
589
+ }
590
+
591
+ document.addEventListener("keydown", handleKeyDown, true);
592
+ return () => document.removeEventListener("keydown", handleKeyDown, true);
593
+ }, [
594
+ activeRestoreId,
595
+ pendingAnnotation,
596
+ store.annotations,
597
+ handleCommentSelect,
598
+ ]);
599
+
600
+ useEffect(() => {
601
+ if (!activeRestoreId || pendingAnnotation) return;
602
+ const currentRestoreId = activeRestoreId;
603
+
604
+ function isTypingTarget(target: EventTarget | null) {
605
+ if (!(target instanceof HTMLElement)) return false;
606
+ const tag = target.tagName;
607
+ return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable;
608
+ }
609
+
610
+ function handleKeyDown(event: KeyboardEvent) {
611
+ if (event.key !== "z" && event.key !== "Z") return;
612
+ if (!(event.metaKey || event.ctrlKey) || event.shiftKey || event.altKey)
613
+ return;
614
+ if (isTypingTarget(event.target)) return;
615
+
616
+ const rootId = resolveThreadRootId(store.annotations, currentRestoreId);
617
+ const annotation = store.annotations.find((item) => item.id === rootId);
618
+ if (!annotation || isChangelogAnnotation(annotation)) return;
619
+
620
+ event.preventDefault();
621
+ store.resolveAnnotation(rootId);
622
+ }
623
+
624
+ document.addEventListener("keydown", handleKeyDown, true);
625
+ return () => document.removeEventListener("keydown", handleKeyDown, true);
626
+ }, [activeRestoreId, pendingAnnotation, store]);
627
+
628
+ useEffect(() => {
629
+ if (!pendingRestoreId) return;
630
+
631
+ const annotation = store.annotations.find(
632
+ (item) => item.id === pendingRestoreId
633
+ );
634
+ if (!annotation) {
635
+ setPendingRestoreId(null);
636
+ return;
637
+ }
638
+
639
+ if (annotation.interactionState.route !== currentPathname) return;
640
+
641
+ selectAndRestore(pendingRestoreId);
642
+ setPendingRestoreId(null);
643
+ }, [pendingRestoreId, store, currentPathname, selectAndRestore]);
644
+
645
+ useLayoutEffect(() => {
646
+ if (!mounted || !store.storageReady) return;
647
+
648
+ const commentId = resolveShareCommentId(
649
+ new URLSearchParams(window.location.search),
650
+ prototypeSlug,
651
+ );
652
+ if (!commentId) return;
653
+
654
+ if (activeRestoreId === commentId) {
655
+ if (prototypeSlug) clearPendingCommentRestore(prototypeSlug);
656
+ return;
657
+ }
658
+
659
+ const annotation = store.annotations.find((item) => item.id === commentId);
660
+ if (!annotation) return;
661
+
662
+ handleCommentSelect(commentId);
663
+ }, [
664
+ mounted,
665
+ store.storageReady,
666
+ store.annotations,
667
+ activeRestoreId,
668
+ handleCommentSelect,
669
+ prototypeSlug,
670
+ ]);
671
+
672
+ useEffect(() => {
673
+ if (!mounted) return;
674
+ syncShareCommentParam(activeRestoreId);
675
+ }, [activeRestoreId, mounted]);
676
+
677
+ useEffect(() => {
678
+ if (!mounted || !store.storageReady || !activeRestoreId) return;
679
+
680
+ const commentId = resolveShareCommentId(
681
+ new URLSearchParams(window.location.search),
682
+ prototypeSlug,
683
+ );
684
+ if (commentId !== activeRestoreId) return;
685
+ if (!readHandlers()?.onRestore) return;
686
+
687
+ liveStateArmedRef.current = false;
688
+ store.restoreComment(activeRestoreId);
689
+ if (prototypeSlug) clearPendingCommentRestore(prototypeSlug);
690
+ }, [
691
+ mounted,
692
+ store.storageReady,
693
+ activeRestoreId,
694
+ readHandlers,
695
+ store,
696
+ prototypeSlug,
697
+ ]);
698
+
699
+ // Deselect the active comment when the user navigates the review chrome away
700
+ // from the comment it belongs to: switching sidebar panels (spec/variants),
701
+ // or opening a design exploration. A selected comment is only meaningful while
702
+ // its own comments/change-log panel is showing.
703
+ const reviewOpen = integratedReview ? review.open : false;
704
+ const reviewSidebarPanel = integratedReview ? review.sidebarPanel : null;
705
+ const reviewActiveVariantSetId = integratedReview
706
+ ? review.activeVariantSetId
707
+ : null;
708
+ useEffect(() => {
709
+ if (!integratedReview || !activeRestoreId || !reviewOpen) return;
710
+
711
+ const annotation = store.annotations.find(
712
+ (item) => item.id === activeRestoreId,
713
+ );
714
+ if (!annotation) return;
715
+
716
+ const expectedPanel = isChangelogAnnotation(annotation)
717
+ ? "change-log"
718
+ : "comments";
719
+
720
+ if (reviewSidebarPanel !== expectedPanel || reviewActiveVariantSetId != null) {
721
+ clearActiveComment();
722
+ }
723
+ }, [
724
+ integratedReview,
725
+ activeRestoreId,
726
+ reviewOpen,
727
+ reviewSidebarPanel,
728
+ reviewActiveVariantSetId,
729
+ store.annotations,
730
+ clearActiveComment,
731
+ ]);
732
+
733
+ useEffect(() => {
734
+ activeRestoreIdRef.current = activeRestoreId;
735
+ }, [activeRestoreId]);
736
+
737
+ // Deselect the active comment whenever the prototype's live state changes.
738
+ // The first live-state change after a selection is the restore itself, which
739
+ // arms tracking; every subsequent change is user-driven and clears the comment.
740
+ useEffect(() => {
741
+ return subscribeLiveStateChange(() => {
742
+ if (!activeRestoreIdRef.current) return;
743
+ if (!liveStateArmedRef.current) {
744
+ liveStateArmedRef.current = true;
745
+ return;
746
+ }
747
+ clearActiveComment();
748
+ });
749
+ }, [subscribeLiveStateChange, clearActiveComment]);
750
+
751
+ // Deselect the active comment when the element it is anchored to is no longer
752
+ // present/visible in the DOM (e.g. removed by a state or exploration change
753
+ // that did not register as a tracked live-state edit).
754
+ useEffect(() => {
755
+ if (!activeRestoreId) return;
756
+
757
+ const annotation = store.annotations.find(
758
+ (item) => item.id === activeRestoreId,
759
+ );
760
+ if (!annotation?.targetId) return;
761
+ const { targetId } = annotation;
762
+
763
+ const check = () => {
764
+ if (!resolveAnnotationTargetById(targetId)) {
765
+ clearActiveComment();
766
+ }
767
+ };
768
+
769
+ const interval = window.setInterval(check, 400);
770
+ return () => window.clearInterval(interval);
771
+ }, [activeRestoreId, store.annotations, clearActiveComment]);
772
+
773
+ useEffect(() => {
774
+ if (!isFeedbackActive) {
775
+ setPendingAnnotation(null);
776
+ setPendingMultiSelectElements([]);
777
+ setHoverInfo(null);
778
+ modifiersHeldRef.current = { cmd: false, shift: false };
779
+ }
780
+ }, [isFeedbackActive]);
781
+
782
+ useEffect(() => {
783
+ if (!isFeedbackActive) return;
784
+
785
+ const style = document.createElement("style");
786
+ style.id = "feedback-cursor-styles";
787
+ style.textContent = buildCommentCaptureCursorStyles();
788
+ document.head.appendChild(style);
789
+
790
+ return () => {
791
+ style.remove();
792
+ };
793
+ }, [isFeedbackActive]);
794
+
795
+ useEffect(() => {
796
+ if (!isFeedbackActive || pendingAnnotation) return;
797
+
798
+ const handleMouseMove = (e: MouseEvent) => {
799
+ const target = (e.composedPath()[0] || e.target) as HTMLElement;
800
+ if (isCommentCaptureBlockedTarget(target)) {
801
+ setHoverInfo(null);
802
+ return;
803
+ }
804
+
805
+ const elementUnder = deepElementFromPoint(e.clientX, e.clientY);
806
+ if (!elementUnder || isCommentCaptureBlockedTarget(elementUnder)) {
807
+ setHoverInfo(null);
808
+ return;
809
+ }
810
+
811
+ const { name } = identifyElementWithReact(elementUnder, "filtered");
812
+ setHoverInfo({
813
+ element: name,
814
+ rect: elementUnder.getBoundingClientRect(),
815
+ });
816
+ };
817
+
818
+ document.addEventListener("mousemove", handleMouseMove);
819
+ return () => document.removeEventListener("mousemove", handleMouseMove);
820
+ }, [isFeedbackActive, pendingAnnotation]);
821
+
822
+ useEffect(() => {
823
+ if (!isFeedbackActive) return;
824
+
825
+ const handleClick = (e: MouseEvent) => {
826
+ const target = (e.composedPath()[0] || e.target) as HTMLElement;
827
+
828
+ if (isCommentCaptureBlockedTarget(target)) return;
829
+
830
+ if (e.metaKey && e.shiftKey && !pendingAnnotation) {
831
+ e.preventDefault();
832
+ e.stopPropagation();
833
+
834
+ const elementUnder = deepElementFromPoint(e.clientX, e.clientY);
835
+ if (!elementUnder || isCommentCaptureBlockedTarget(elementUnder)) return;
836
+
837
+ const anchor = resolveCaptureAnchor(elementUnder);
838
+ const rect = anchor.getBoundingClientRect();
839
+ const { name, path, reactComponents } = identifyElementWithReact(
840
+ anchor,
841
+ "filtered"
842
+ );
843
+ const existingIndex = pendingMultiSelectElements.findIndex(
844
+ (item) => item.element === anchor
845
+ );
846
+
847
+ if (existingIndex >= 0) {
848
+ setPendingMultiSelectElements((prev) =>
849
+ prev.filter((_, i) => i !== existingIndex)
850
+ );
851
+ } else {
852
+ setPendingMultiSelectElements((prev) => [
853
+ ...prev,
854
+ {
855
+ element: anchor,
856
+ rect,
857
+ name,
858
+ path,
859
+ reactComponents: reactComponents ?? undefined,
860
+ },
861
+ ]);
862
+ }
863
+ return;
864
+ }
865
+
866
+ const isInteractive = closestCrossingShadow(
867
+ target,
868
+ "button, a, input, select, textarea, [role='button'], [onclick]"
869
+ );
870
+
871
+ if (isInteractive) {
872
+ e.preventDefault();
873
+ e.stopPropagation();
874
+ }
875
+
876
+ if (pendingAnnotation) {
877
+ e.preventDefault();
878
+ popupRef.current?.shake();
879
+ return;
880
+ }
881
+
882
+ e.preventDefault();
883
+
884
+ const elementUnder = deepElementFromPoint(e.clientX, e.clientY);
885
+ if (!elementUnder || isCommentCaptureBlockedTarget(elementUnder)) return;
886
+
887
+ const anchor = resolveCaptureAnchor(elementUnder);
888
+ const { name, path, reactComponents } = identifyElementWithReact(
889
+ anchor,
890
+ "filtered"
891
+ );
892
+ const rect = anchor.getBoundingClientRect();
893
+ const { x, y } = computeElementRelativeMarkerPosition(
894
+ e.clientX,
895
+ e.clientY,
896
+ rect
897
+ );
898
+ const isFixed = isElementFixed(anchor);
899
+
900
+ const selection = window.getSelection();
901
+ let selectedText: string | undefined;
902
+ if (selection && selection.toString().trim().length > 0) {
903
+ selectedText = selection.toString().trim().slice(0, 500);
904
+ }
905
+
906
+ setPendingAnnotation({
907
+ x,
908
+ y,
909
+ positionAnchor: "element",
910
+ clientY: e.clientY,
911
+ element: name,
912
+ elementPath: path,
913
+ targetId: getPrototypeTargetIdFromElement(elementUnder) ?? undefined,
914
+ selectedText,
915
+ boundingBox: {
916
+ x: rect.left,
917
+ y: isFixed ? rect.top : rect.top + window.scrollY,
918
+ width: rect.width,
919
+ height: rect.height,
920
+ },
921
+ nearbyText: getNearbyText(anchor),
922
+ cssClasses: getElementClasses(anchor),
923
+ isFixed,
924
+ fullPath: getFullElementPath(anchor),
925
+ accessibility: getAccessibilityInfo(anchor),
926
+ computedStyles: getForensicComputedStyles(anchor),
927
+ computedStylesObj: getDetailedComputedStyles(anchor),
928
+ nearbyElements: getNearbyElements(anchor),
929
+ reactComponents: reactComponents ?? undefined,
930
+ sourceFile: detectSourceFile(anchor),
931
+ targetElement: anchor,
932
+ });
933
+ setActiveRestoreId(null);
934
+ };
935
+
936
+ document.addEventListener("click", handleClick, true);
937
+ return () => document.removeEventListener("click", handleClick, true);
938
+ }, [isFeedbackActive, pendingAnnotation, pendingMultiSelectElements]);
939
+
940
+ useEffect(() => {
941
+ if (!isFeedbackActive) return;
942
+
943
+ const handleKeyDown = (e: KeyboardEvent) => {
944
+ if (e.key === "Meta") modifiersHeldRef.current.cmd = true;
945
+ if (e.key === "Shift") modifiersHeldRef.current.shift = true;
946
+ };
947
+
948
+ const handleKeyUp = (e: KeyboardEvent) => {
949
+ const wasHoldingBoth =
950
+ modifiersHeldRef.current.cmd && modifiersHeldRef.current.shift;
951
+
952
+ if (e.key === "Meta") modifiersHeldRef.current.cmd = false;
953
+ if (e.key === "Shift") modifiersHeldRef.current.shift = false;
954
+
955
+ const nowHoldingBoth =
956
+ modifiersHeldRef.current.cmd && modifiersHeldRef.current.shift;
957
+
958
+ if (
959
+ wasHoldingBoth &&
960
+ !nowHoldingBoth &&
961
+ pendingMultiSelectElements.length > 0
962
+ ) {
963
+ createMultiSelectPendingAnnotation();
964
+ }
965
+ };
966
+
967
+ const handleBlur = () => {
968
+ modifiersHeldRef.current = { cmd: false, shift: false };
969
+ setPendingMultiSelectElements([]);
970
+ };
971
+
972
+ document.addEventListener("keydown", handleKeyDown);
973
+ document.addEventListener("keyup", handleKeyUp);
974
+ window.addEventListener("blur", handleBlur);
975
+ return () => {
976
+ document.removeEventListener("keydown", handleKeyDown);
977
+ document.removeEventListener("keyup", handleKeyUp);
978
+ window.removeEventListener("blur", handleBlur);
979
+ };
980
+ }, [
981
+ isFeedbackActive,
982
+ pendingMultiSelectElements,
983
+ createMultiSelectPendingAnnotation,
984
+ ]);
985
+
986
+ useEffect(() => {
987
+ const handleKeyDown = (e: KeyboardEvent) => {
988
+ const isTyping =
989
+ e.target instanceof HTMLInputElement ||
990
+ e.target instanceof HTMLTextAreaElement ||
991
+ (e.target instanceof HTMLElement && e.target.isContentEditable);
992
+
993
+ if (e.key === "Escape") {
994
+ if (pendingMultiSelectElements.length > 0) {
995
+ setPendingMultiSelectElements([]);
996
+ e.preventDefault();
997
+ if (document.activeElement instanceof HTMLElement) {
998
+ document.activeElement.blur();
999
+ }
1000
+ return;
1001
+ }
1002
+ if (pendingAnnotation) return;
1003
+ if (isGiveAMomentMode) {
1004
+ setIsGiveAMomentMode(false);
1005
+ e.preventDefault();
1006
+ if (document.activeElement instanceof HTMLElement) {
1007
+ document.activeElement.blur();
1008
+ }
1009
+ }
1010
+ }
1011
+
1012
+ if (
1013
+ (e.metaKey || e.ctrlKey) &&
1014
+ e.shiftKey &&
1015
+ (e.key === "f" || e.key === "F")
1016
+ ) {
1017
+ e.preventDefault();
1018
+ if (!isActive) {
1019
+ activateToolbar();
1020
+ } else {
1021
+ setIsGiveAMomentMode((prev) => !prev);
1022
+ }
1023
+ }
1024
+
1025
+ if (isTyping || e.metaKey || e.ctrlKey) return;
1026
+ };
1027
+
1028
+ document.addEventListener("keydown", handleKeyDown);
1029
+ return () => document.removeEventListener("keydown", handleKeyDown);
1030
+ }, [
1031
+ isGiveAMomentMode,
1032
+ pendingAnnotation,
1033
+ pendingMultiSelectElements.length,
1034
+ activateToolbar,
1035
+ ]);
1036
+
1037
+ useEffect(() => {
1038
+ if (isActive) return;
1039
+
1040
+ setIsGiveAMomentMode(false);
1041
+ if (!integratedReview) {
1042
+ setShowCommentsSidebar(false);
1043
+ }
1044
+
1045
+ const shareCommentId = resolveShareCommentId(
1046
+ new URLSearchParams(window.location.search),
1047
+ prototypeSlug,
1048
+ );
1049
+ if (!shareCommentId) {
1050
+ setActiveRestoreId(null);
1051
+ syncShareCommentParam(null);
1052
+ }
1053
+ }, [isActive, integratedReview, prototypeSlug]);
1054
+
1055
+ const isChangelogModeActive =
1056
+ isGiveAMomentMode &&
1057
+ integratedReview &&
1058
+ review.captureChannel === "changelog";
1059
+ const isCommentModeActive =
1060
+ isGiveAMomentMode &&
1061
+ (!integratedReview || review.captureChannel === "comment");
1062
+
1063
+ const bridgeValue = useMemo<CommentCaptureBridgeValue>(
1064
+ () => ({
1065
+ onSelect: handleCommentSelect,
1066
+ selectedId: activeRestoreId,
1067
+ isCommentModeActive,
1068
+ isChangelogModeActive,
1069
+ onEnterCommentMode: enterCommentMode,
1070
+ onEnterChangelogMode: enterChangelogMode,
1071
+ onOpenCommentsPanel: openCommentsPanel,
1072
+ onToggleCommentMode: toggleCommentMode,
1073
+ onClose: deactivate,
1074
+ }),
1075
+ [
1076
+ handleCommentSelect,
1077
+ activeRestoreId,
1078
+ isCommentModeActive,
1079
+ isChangelogModeActive,
1080
+ enterCommentMode,
1081
+ enterChangelogMode,
1082
+ openCommentsPanel,
1083
+ toggleCommentMode,
1084
+ deactivate,
1085
+ ]
1086
+ );
1087
+
1088
+ if (devOnly && process.env.NODE_ENV !== "development") {
1089
+ return null;
1090
+ }
1091
+
1092
+ if (!mounted) {
1093
+ return (
1094
+ <CommentCaptureBridgeContext.Provider value={bridgeValue}>
1095
+ {children}
1096
+ </CommentCaptureBridgeContext.Provider>
1097
+ );
1098
+ }
1099
+
1100
+ const commentCount = getCommentRootAnnotations(store.annotations).length;
1101
+ const activeAnnotation = activeRestoreId
1102
+ ? store.annotations.find((item) => item.id === activeRestoreId)
1103
+ : null;
1104
+
1105
+ const sidebarPortalTarget =
1106
+ typeof document !== "undefined"
1107
+ ? document.getElementById("prototype-comments-sidebar-root")
1108
+ : null;
1109
+
1110
+ const sidebar = integratedReview ? null : (
1111
+ <CommentsSidebar
1112
+ open={showCommentsSidebar}
1113
+ onClose={deactivate}
1114
+ onSelect={handleCommentSelect}
1115
+ selectedId={activeRestoreId}
1116
+ isCommentModeActive={isGiveAMomentMode}
1117
+ onToggleCommentMode={toggleCommentMode}
1118
+ />
1119
+ );
1120
+
1121
+ const showLegacyToolbar = !integratedReview && !showCommentsSidebar;
1122
+
1123
+ return (
1124
+ <CommentCaptureBridgeContext.Provider value={bridgeValue}>
1125
+ {children}
1126
+ {!integratedReview && sidebarPortalTarget
1127
+ ? createPortal(sidebar, sidebarPortalTarget)
1128
+ : sidebar}
1129
+ {createPortal(
1130
+ (
1131
+ <div
1132
+ className={styles.captureScope}
1133
+ data-prototype-comment-theme={commentTheme}
1134
+ data-prototype-comment-accent="blue"
1135
+ data-prototype-comment-root=""
1136
+ >
1137
+ {showLegacyToolbar && (
1138
+ <div
1139
+ className={`${styles.toolbar}${
1140
+ className ? ` ${className}` : ""
1141
+ }`}
1142
+ data-feedback-toolbar
1143
+ data-comment-capture-toolbar
1144
+ >
1145
+ <div
1146
+ className={`${styles.toolbarContainer} ${
1147
+ isActive ? styles.expanded : styles.collapsed
1148
+ } ${styles.fitContent}`}
1149
+ onClick={!isActive ? activateToolbar : undefined}
1150
+ role={!isActive ? "button" : undefined}
1151
+ tabIndex={!isActive ? 0 : -1}
1152
+ title={!isActive ? "Open comments" : undefined}
1153
+ >
1154
+ <div
1155
+ className={`${styles.toggleContent} ${
1156
+ !isActive ? styles.visible : styles.hidden
1157
+ }`}
1158
+ >
1159
+ <IconChatEllipsis size={24} />
1160
+ {commentCount > 0 && (
1161
+ <span className={styles.badge}>{commentCount}</span>
1162
+ )}
1163
+ </div>
1164
+
1165
+ <div
1166
+ className={`${styles.controlsContent} ${
1167
+ isActive ? styles.visible : styles.hidden
1168
+ }`}
1169
+ >
1170
+ <div className={styles.buttonWrapper}>
1171
+ <button
1172
+ type="button"
1173
+ className={styles.controlButton}
1174
+ onClick={(e) => {
1175
+ e.stopPropagation();
1176
+ setIsGiveAMomentMode((active) => !active);
1177
+ }}
1178
+ data-active={isGiveAMomentMode}
1179
+ aria-label={
1180
+ isGiveAMomentMode
1181
+ ? "Exit comment mode"
1182
+ : "Add comments"
1183
+ }
1184
+ >
1185
+ <IconPlus size={24} />
1186
+ </button>
1187
+ </div>
1188
+
1189
+ <div className={styles.divider} />
1190
+
1191
+ <div className={styles.buttonWrapper}>
1192
+ <button
1193
+ type="button"
1194
+ className={styles.controlButton}
1195
+ onClick={(e) => {
1196
+ e.stopPropagation();
1197
+ deactivate();
1198
+ }}
1199
+ >
1200
+ <IconXmarkLarge size={24} />
1201
+ </button>
1202
+ </div>
1203
+ </div>
1204
+ </div>
1205
+ </div>
1206
+ )}
1207
+
1208
+ {activeAnnotation && (
1209
+ <CommentAnnotationOverlay
1210
+ key={activeAnnotation.id}
1211
+ annotation={activeAnnotation}
1212
+ basePath={sidebarBasePath}
1213
+ readOnly
1214
+ onDismiss={clearActiveComment}
1215
+ resolveTargetOptions={annotationTargetOptions}
1216
+ />
1217
+ )}
1218
+
1219
+ {isFeedbackActive && (
1220
+ <>
1221
+ <div
1222
+ className={styles.targetHighlightLayer}
1223
+ data-comment-capture-highlights
1224
+ >
1225
+ {hoverInfo?.rect && !pendingAnnotation && (
1226
+ <div
1227
+ className={`${styles.singleSelectOutline} ${styles.enter}`}
1228
+ style={{
1229
+ left: hoverInfo.rect.left,
1230
+ top: hoverInfo.rect.top,
1231
+ width: hoverInfo.rect.width,
1232
+ height: hoverInfo.rect.height,
1233
+ borderColor:
1234
+ "color-mix(in srgb, var(--prototype-comment-color-accent) 60%, transparent)",
1235
+ backgroundColor:
1236
+ "color-mix(in srgb, var(--prototype-comment-color-accent) 5%, transparent)",
1237
+ }}
1238
+ />
1239
+ )}
1240
+
1241
+ {pendingMultiSelectElements.map((item, index) => {
1242
+ const rect = item.element.getBoundingClientRect();
1243
+ return (
1244
+ <div
1245
+ key={`multi-hover-${index}`}
1246
+ className={`${styles.multiSelectOutline} ${styles.enter}`}
1247
+ style={{
1248
+ left: rect.left,
1249
+ top: rect.top,
1250
+ width: rect.width,
1251
+ height: rect.height,
1252
+ }}
1253
+ />
1254
+ );
1255
+ })}
1256
+
1257
+ {pendingAnnotation &&
1258
+ (pendingAnnotation.multiSelectElements?.length
1259
+ ? pendingAnnotation.multiSelectElements
1260
+ .filter((el) => document.contains(el))
1261
+ .map((el, index) => {
1262
+ const rect = el.getBoundingClientRect();
1263
+ return (
1264
+ <div
1265
+ key={`pending-multi-${index}`}
1266
+ className={`${styles.multiSelectOutline} ${
1267
+ pendingExiting ? styles.exit : styles.enter
1268
+ }`}
1269
+ style={{
1270
+ left: rect.left,
1271
+ top: rect.top,
1272
+ width: rect.width,
1273
+ height: rect.height,
1274
+ }}
1275
+ />
1276
+ );
1277
+ })
1278
+ : pendingAnnotation.targetElement &&
1279
+ document.contains(pendingAnnotation.targetElement)
1280
+ ? (() => {
1281
+ const rect =
1282
+ pendingAnnotation.targetElement!.getBoundingClientRect();
1283
+ return (
1284
+ <div
1285
+ className={`${styles.singleSelectOutline} ${
1286
+ pendingExiting ? styles.exit : styles.enter
1287
+ }`}
1288
+ style={{
1289
+ left: rect.left,
1290
+ top: rect.top,
1291
+ width: rect.width,
1292
+ height: rect.height,
1293
+ borderColor:
1294
+ "color-mix(in srgb, var(--prototype-comment-color-accent) 60%, transparent)",
1295
+ backgroundColor:
1296
+ "color-mix(in srgb, var(--prototype-comment-color-accent) 5%, transparent)",
1297
+ }}
1298
+ />
1299
+ );
1300
+ })()
1301
+ : pendingAnnotation.boundingBox && (
1302
+ <div
1303
+ className={`${
1304
+ pendingAnnotation.isMultiSelect
1305
+ ? styles.multiSelectOutline
1306
+ : styles.singleSelectOutline
1307
+ } ${pendingExiting ? styles.exit : styles.enter}`}
1308
+ style={{
1309
+ left: pendingAnnotation.boundingBox.x,
1310
+ top: pendingAnnotation.boundingBox.y - scrollY,
1311
+ width: pendingAnnotation.boundingBox.width,
1312
+ height: pendingAnnotation.boundingBox.height,
1313
+ }}
1314
+ />
1315
+ ))}
1316
+ </div>
1317
+
1318
+ {pendingAnnotation && (
1319
+ <div className={styles.overlay} data-comment-capture-overlay>
1320
+ {(() => {
1321
+ const pendingPosition = getAnnotationMarkerPosition(
1322
+ {
1323
+ x: pendingAnnotation.x,
1324
+ y: pendingAnnotation.y,
1325
+ boundingBox: pendingAnnotation.boundingBox,
1326
+ isFixed: pendingAnnotation.isFixed,
1327
+ positionAnchor: pendingAnnotation.positionAnchor,
1328
+ },
1329
+ pendingAnnotation.targetElement ?? null,
1330
+ scrollY,
1331
+ );
1332
+ const markerViewport = getMarkerViewportPosition(
1333
+ {
1334
+ x: pendingAnnotation.x,
1335
+ y: pendingAnnotation.y,
1336
+ boundingBox: pendingAnnotation.boundingBox,
1337
+ isFixed: pendingAnnotation.isFixed,
1338
+ positionAnchor: pendingAnnotation.positionAnchor,
1339
+ },
1340
+ scrollY,
1341
+ pendingAnnotation.targetElement ?? null,
1342
+ );
1343
+
1344
+ return (
1345
+ <>
1346
+ <PendingMarker
1347
+ x={pendingAnnotation.x}
1348
+ y={pendingAnnotation.y}
1349
+ positionStyle={pendingPosition}
1350
+ isMultiSelect={pendingAnnotation.isMultiSelect}
1351
+ isExiting={pendingExiting}
1352
+ />
1353
+
1354
+ <AnnotationPopupCSS
1355
+ ref={
1356
+ popupRef as RefObject<AnnotationPopupCSSHandle>
1357
+ }
1358
+ element={pendingAnnotation.element}
1359
+ selectedText={pendingAnnotation.selectedText}
1360
+ computedStyles={pendingAnnotation.computedStylesObj}
1361
+ onSubmit={addAnnotation}
1362
+ onCancel={cancelAnnotation}
1363
+ isExiting={pendingExiting}
1364
+ lightMode={useLightTheme}
1365
+ showElementPath={false}
1366
+ accentColor="var(--prototype-comment-color-accent)"
1367
+ style={{
1368
+ left: Math.max(
1369
+ 160,
1370
+ Math.min(
1371
+ window.innerWidth - 160,
1372
+ markerViewport.x,
1373
+ ),
1374
+ ),
1375
+ ...(markerViewport.y > window.innerHeight - 290
1376
+ ? {
1377
+ bottom:
1378
+ window.innerHeight -
1379
+ markerViewport.y +
1380
+ 20,
1381
+ }
1382
+ : { top: markerViewport.y + 20 }),
1383
+ }}
1384
+ />
1385
+ </>
1386
+ );
1387
+ })()}
1388
+ </div>
1389
+ )}
1390
+ </>
1391
+ )}
1392
+ </div>
1393
+ ) as ReactNode,
1394
+ document.body
1395
+ )}
1396
+ </CommentCaptureBridgeContext.Provider>
1397
+ );
1398
+ }