@plannotator/ui 0.22.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 (305) hide show
  1. package/README.md +60 -0
  2. package/assets/diff-options.png +0 -0
  3. package/assets/icon-codex.png +0 -0
  4. package/assets/look-flat.png +0 -0
  5. package/assets/look-grid.png +0 -0
  6. package/assets/review-sections.png +0 -0
  7. package/assets/review-tree.png +0 -0
  8. package/assets/workspaces.webp +0 -0
  9. package/components/AISettingsTab.tsx +145 -0
  10. package/components/ActionMenu.tsx +101 -0
  11. package/components/AgentControls.tsx +216 -0
  12. package/components/AgentsTab.tsx +1294 -0
  13. package/components/AnnotationPanel.tsx +731 -0
  14. package/components/AnnotationSidebar.tsx +86 -0
  15. package/components/AnnotationToolbar.tsx +323 -0
  16. package/components/AnnotationToolstrip.tsx +355 -0
  17. package/components/ApproveDropdown.tsx +170 -0
  18. package/components/AttachmentsButton.tsx +410 -0
  19. package/components/BlockRenderer.tsx +163 -0
  20. package/components/BorderTrail.tsx +38 -0
  21. package/components/CodeFilePicker.tsx +64 -0
  22. package/components/CodeFilePopout.tsx +602 -0
  23. package/components/CodePathValidationContext.tsx +14 -0
  24. package/components/CommentPopover.tsx +512 -0
  25. package/components/CompletionOverlay.tsx +99 -0
  26. package/components/ConfirmDialog.tsx +128 -0
  27. package/components/DocBadges.tsx +231 -0
  28. package/components/EditorAnnotationCard.tsx +65 -0
  29. package/components/ExportModal.tsx +565 -0
  30. package/components/FloatingQuickLabelPicker.tsx +144 -0
  31. package/components/GitHubIcon.tsx +16 -0
  32. package/components/GitLabIcon.tsx +30 -0
  33. package/components/GraphvizBlock.tsx +511 -0
  34. package/components/ImageAnnotator/Canvas.tsx +118 -0
  35. package/components/ImageAnnotator/Toolbar.tsx +218 -0
  36. package/components/ImageAnnotator/index.tsx +272 -0
  37. package/components/ImageAnnotator/types.ts +39 -0
  38. package/components/ImageAnnotator/utils.ts +147 -0
  39. package/components/ImageThumbnail.tsx +126 -0
  40. package/components/ImportModal.tsx +146 -0
  41. package/components/InlineMarkdown.tsx +1094 -0
  42. package/components/KeyboardShortcuts.tsx +208 -0
  43. package/components/Landing.tsx +496 -0
  44. package/components/ListItemBody.tsx +62 -0
  45. package/components/ListMarker.tsx +73 -0
  46. package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
  47. package/components/MarkdownEditor.tsx +44 -0
  48. package/components/MenuVersionSection.tsx +91 -0
  49. package/components/MermaidBlock.tsx +579 -0
  50. package/components/ModeToggle.tsx +68 -0
  51. package/components/OpenInAppButton.tsx +299 -0
  52. package/components/OverlayScrollArea.tsx +82 -0
  53. package/components/PermissionModeSetup.tsx +92 -0
  54. package/components/PinpointOverlay.tsx +99 -0
  55. package/components/PlanAIAnnouncementDialog.tsx +187 -0
  56. package/components/PlanHeaderMenu.tsx +299 -0
  57. package/components/PopoutDialog.tsx +91 -0
  58. package/components/Popover.tsx +27 -0
  59. package/components/ProviderIcons.tsx +51 -0
  60. package/components/PullRequestIcon.tsx +12 -0
  61. package/components/QuickLabelDropdown.tsx +63 -0
  62. package/components/RenderedMarkdown.tsx +54 -0
  63. package/components/RepoIcon.tsx +12 -0
  64. package/components/ResizeHandle.tsx +86 -0
  65. package/components/ReviewAgentsIcon.tsx +18 -0
  66. package/components/SearchableSelect.tsx +157 -0
  67. package/components/Settings.tsx +2220 -0
  68. package/components/SparklesIcon.tsx +45 -0
  69. package/components/StickyHeaderLane.tsx +260 -0
  70. package/components/TableOfContents.tsx +157 -0
  71. package/components/TaterSpritePullup.tsx +34 -0
  72. package/components/TaterSpriteRunning.tsx +54 -0
  73. package/components/TaterSpriteSitting.tsx +37 -0
  74. package/components/TextShimmer.tsx +57 -0
  75. package/components/ThemeProvider.tsx +162 -0
  76. package/components/ThemeTab.tsx +139 -0
  77. package/components/ToolbarButtons.tsx +116 -0
  78. package/components/Tooltip.tsx +45 -0
  79. package/components/Viewer.tsx +977 -0
  80. package/components/ai/AIProviderBar.tsx +95 -0
  81. package/components/ai/DocumentAIChatPanel.tsx +313 -0
  82. package/components/blocks/AlertBlock.tsx +58 -0
  83. package/components/blocks/Callout.tsx +60 -0
  84. package/components/blocks/CodeBlock.tsx +75 -0
  85. package/components/blocks/HtmlBlock.tsx +122 -0
  86. package/components/blocks/MathBlock.tsx +36 -0
  87. package/components/blocks/TableBlock.tsx +146 -0
  88. package/components/blocks/TablePopout.tsx +279 -0
  89. package/components/blocks/TableToolbar.tsx +153 -0
  90. package/components/blocks/proseBody.tsx +102 -0
  91. package/components/core/button.tsx +44 -0
  92. package/components/core/textarea.tsx +26 -0
  93. package/components/diagramLanguages.ts +14 -0
  94. package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
  95. package/components/html-viewer/HtmlViewer.tsx +386 -0
  96. package/components/html-viewer/bridge-script.ts +505 -0
  97. package/components/html-viewer/index.ts +1 -0
  98. package/components/html-viewer/useHtmlAnnotation.ts +400 -0
  99. package/components/icons/AgentIcons.tsx +66 -0
  100. package/components/icons/AppIcon.tsx +56 -0
  101. package/components/icons/MessagesIcon.tsx +11 -0
  102. package/components/icons/ObsidianIcons.tsx +208 -0
  103. package/components/icons/app/android-studio.svg +369 -0
  104. package/components/icons/app/antigravity.svg +97 -0
  105. package/components/icons/app/cursor.svg +16 -0
  106. package/components/icons/app/file-explorer.svg +20 -0
  107. package/components/icons/app/finder.png +0 -0
  108. package/components/icons/app/ghostty.svg +13 -0
  109. package/components/icons/app/iterm2.svg +13 -0
  110. package/components/icons/app/powershell.svg +14 -0
  111. package/components/icons/app/sublime-text.svg +17 -0
  112. package/components/icons/app/terminal.png +0 -0
  113. package/components/icons/app/textmate.png +0 -0
  114. package/components/icons/app/vscode.svg +39 -0
  115. package/components/icons/app/warp.png +0 -0
  116. package/components/icons/app/xcode.png +0 -0
  117. package/components/icons/app/zed-dark.svg +15 -0
  118. package/components/icons/app/zed.svg +15 -0
  119. package/components/icons/themeIcons.tsx +47 -0
  120. package/components/mermaidSvg.ts +33 -0
  121. package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
  122. package/components/plan-diff/PlanDiffBadge.tsx +48 -0
  123. package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
  124. package/components/plan-diff/PlanDiffViewer.tsx +214 -0
  125. package/components/plan-diff/PlanRawDiffView.tsx +102 -0
  126. package/components/plan-diff/VSCodeIcon.tsx +132 -0
  127. package/components/settings/HooksTab.tsx +208 -0
  128. package/components/sidebar/ArchiveBrowser.tsx +98 -0
  129. package/components/sidebar/CountBadge.tsx +12 -0
  130. package/components/sidebar/FileBrowser.tsx +498 -0
  131. package/components/sidebar/MessagesBrowser.tsx +109 -0
  132. package/components/sidebar/SidebarContainer.tsx +353 -0
  133. package/components/sidebar/SidebarTabs.tsx +155 -0
  134. package/components/sidebar/VersionBrowser.tsx +141 -0
  135. package/components/types.d.ts +7 -0
  136. package/components/ui/badge.tsx +44 -0
  137. package/components/ui/button.tsx +83 -0
  138. package/components/ui/card.tsx +58 -0
  139. package/components/ui/dialog.tsx +106 -0
  140. package/components/ui/dropdown-menu.tsx +240 -0
  141. package/components/ui/state-pill.tsx +45 -0
  142. package/components/ui/tabs.tsx +46 -0
  143. package/components/ui/textarea.tsx +25 -0
  144. package/config/configStore.ts +214 -0
  145. package/config/index.ts +4 -0
  146. package/config/reviewView.ts +42 -0
  147. package/config/settings.ts +310 -0
  148. package/config/useConfig.ts +20 -0
  149. package/configure.ts +69 -0
  150. package/globals.d.ts +14 -0
  151. package/hooks/pfm/useCodeFilePopout.ts +111 -0
  152. package/hooks/useAIChat.ts +532 -0
  153. package/hooks/useAIProviderConfig.ts +115 -0
  154. package/hooks/useActiveSection.ts +78 -0
  155. package/hooks/useAgentJobs.ts +306 -0
  156. package/hooks/useAgentSettings.ts +579 -0
  157. package/hooks/useAgents.ts +90 -0
  158. package/hooks/useAnnotationDraft.ts +523 -0
  159. package/hooks/useAnnotationHighlighter.ts +1170 -0
  160. package/hooks/useArchive.ts +171 -0
  161. package/hooks/useAutoClose.ts +104 -0
  162. package/hooks/useCodeAnnotationDraft.ts +194 -0
  163. package/hooks/useCodeFilePopout.ts +1 -0
  164. package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
  165. package/hooks/useDraggable.ts +108 -0
  166. package/hooks/useEditorAnnotations.ts +64 -0
  167. package/hooks/useExternalAnnotationHighlights.ts +105 -0
  168. package/hooks/useExternalAnnotations.ts +268 -0
  169. package/hooks/useFileBrowser.ts +402 -0
  170. package/hooks/useInputMethodSwitch.ts +89 -0
  171. package/hooks/useIsMobile.ts +17 -0
  172. package/hooks/useLinkedDoc.ts +494 -0
  173. package/hooks/useOverlayViewport.ts +36 -0
  174. package/hooks/usePinpoint.ts +182 -0
  175. package/hooks/usePlanDiff.ts +176 -0
  176. package/hooks/usePrintMode.ts +27 -0
  177. package/hooks/useResizablePanel.ts +173 -0
  178. package/hooks/useScrollViewport.ts +38 -0
  179. package/hooks/useSharing.ts +441 -0
  180. package/hooks/useSidebar.ts +55 -0
  181. package/hooks/useUpdateCheck.ts +132 -0
  182. package/hooks/useValidatedCodePaths.ts +94 -0
  183. package/icons/GitUser.tsx +17 -0
  184. package/lib/utils.ts +11 -0
  185. package/package.json +117 -0
  186. package/plannotator.webp +0 -0
  187. package/print.css +427 -0
  188. package/shortcuts/code-review/ai.shortcuts.ts +25 -0
  189. package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
  190. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
  191. package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
  192. package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
  193. package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
  194. package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
  195. package/shortcuts/core.ts +399 -0
  196. package/shortcuts/index.ts +21 -0
  197. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
  198. package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
  199. package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
  200. package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
  201. package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
  202. package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
  203. package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
  204. package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
  205. package/shortcuts/runtime.ts +259 -0
  206. package/sprite_package_additional/index.html +34 -0
  207. package/sprite_package_additional/sprite.png +0 -0
  208. package/sprite_package_new/index.html +34 -0
  209. package/sprite_package_new/sprite.png +0 -0
  210. package/sprite_package_pulluphang/index.html +34 -0
  211. package/sprite_package_pulluphang/sprite.png +0 -0
  212. package/styles.css +1 -0
  213. package/theme.css +893 -0
  214. package/themes/adwaita.css +62 -0
  215. package/themes/andromeeda.css +66 -0
  216. package/themes/aurora-x.css +66 -0
  217. package/themes/ayu-dark.css +66 -0
  218. package/themes/caffeine.css +113 -0
  219. package/themes/catppuccin.css +62 -0
  220. package/themes/claude-plus.css +60 -0
  221. package/themes/cursor-hc.css +34 -0
  222. package/themes/cursor-midnight.css +34 -0
  223. package/themes/cursor.css +62 -0
  224. package/themes/dark-plus.css +66 -0
  225. package/themes/doom-64.css +109 -0
  226. package/themes/dracula.css +33 -0
  227. package/themes/everforest-hard.css +62 -0
  228. package/themes/everforest-soft.css +62 -0
  229. package/themes/everforest.css +62 -0
  230. package/themes/github.css +66 -0
  231. package/themes/gruvbox.css +62 -0
  232. package/themes/houston.css +66 -0
  233. package/themes/kanagawa-dragon.css +34 -0
  234. package/themes/kanagawa-lotus.css +34 -0
  235. package/themes/kanagawa-wave.css +34 -0
  236. package/themes/laserwave.css +66 -0
  237. package/themes/material.css +62 -0
  238. package/themes/min.css +66 -0
  239. package/themes/monokai-pro.css +34 -0
  240. package/themes/neutral.css +59 -0
  241. package/themes/night-owl.css +66 -0
  242. package/themes/nord.css +66 -0
  243. package/themes/one-dark-pro.css +66 -0
  244. package/themes/one-light.css +66 -0
  245. package/themes/paulmillr.css +34 -0
  246. package/themes/plannotator.css +60 -0
  247. package/themes/plastic.css +66 -0
  248. package/themes/poimandres.css +66 -0
  249. package/themes/quantum-rose.css +109 -0
  250. package/themes/red.css +66 -0
  251. package/themes/rose-pine.css +62 -0
  252. package/themes/simple.css +124 -0
  253. package/themes/slack.css +66 -0
  254. package/themes/snazzy-light.css +66 -0
  255. package/themes/soft-pop.css +60 -0
  256. package/themes/solar-dusk.css +109 -0
  257. package/themes/solarized.css +66 -0
  258. package/themes/synthwave-84.css +34 -0
  259. package/themes/terminal.css +62 -0
  260. package/themes/tinacious.css +57 -0
  261. package/themes/tokyo-night.css +62 -0
  262. package/themes/vesper.css +62 -0
  263. package/themes/vitesse-black.css +66 -0
  264. package/themes/vitesse.css +62 -0
  265. package/types.ts +264 -0
  266. package/utils/agentSwitch.ts +71 -0
  267. package/utils/aiChatFormat.ts +33 -0
  268. package/utils/aiPrompt.ts +29 -0
  269. package/utils/aiProvider.ts +217 -0
  270. package/utils/anchors.ts +11 -0
  271. package/utils/annotateAgentTerminal.ts +29 -0
  272. package/utils/annotationHelpers.ts +101 -0
  273. package/utils/bear.ts +59 -0
  274. package/utils/blockTargeting.ts +240 -0
  275. package/utils/callback.ts +99 -0
  276. package/utils/commentContent.ts +8 -0
  277. package/utils/defaultNotesApp.ts +20 -0
  278. package/utils/diffFonts.ts +33 -0
  279. package/utils/editorMode.ts +31 -0
  280. package/utils/fileBrowser.ts +40 -0
  281. package/utils/generateId.ts +7 -0
  282. package/utils/generateIdentity.ts +29 -0
  283. package/utils/identity.ts +112 -0
  284. package/utils/inlineTransforms.ts +41 -0
  285. package/utils/inputMethod.ts +17 -0
  286. package/utils/lookAndFeelAnnouncement.ts +18 -0
  287. package/utils/obsidian.ts +201 -0
  288. package/utils/octarine.ts +53 -0
  289. package/utils/parser.ts +991 -0
  290. package/utils/permissionMode.ts +77 -0
  291. package/utils/planAIAnnouncement.ts +17 -0
  292. package/utils/planAgentInstructions.ts +137 -0
  293. package/utils/planDiffEngine.ts +590 -0
  294. package/utils/planSave.ts +49 -0
  295. package/utils/platform.ts +11 -0
  296. package/utils/quickLabels.ts +77 -0
  297. package/utils/reviewAgentInstructions.ts +182 -0
  298. package/utils/sanitizeHtml.ts +45 -0
  299. package/utils/sharing.ts +350 -0
  300. package/utils/slugify.ts +37 -0
  301. package/utils/storage.ts +141 -0
  302. package/utils/themeRegistry.ts +567 -0
  303. package/utils/uiPreferences.ts +34 -0
  304. package/utils/upload.ts +56 -0
  305. package/utils/wideMode.ts +48 -0
@@ -0,0 +1,977 @@
1
+ import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import hljs from 'highlight.js';
4
+ import { Block, Annotation, AnnotationType, EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
5
+ import { Frontmatter, computeListIndices, groupBlocks } from '../utils/parser';
6
+ import { buildHeadingSlugMap } from '../utils/slugify';
7
+ import { BlockRenderer } from './BlockRenderer';
8
+ import { CodeBlock } from './blocks/CodeBlock';
9
+ import { TableBlock } from './blocks/TableBlock';
10
+ import { TableToolbar } from './blocks/TableToolbar';
11
+ import { TablePopout } from './blocks/TablePopout';
12
+ import { CodePathValidationContext } from './CodePathValidationContext';
13
+ import { useValidatedCodePaths } from '../hooks/useValidatedCodePaths';
14
+ import { ListMarker } from './ListMarker';
15
+ import { AnnotationToolbar } from './AnnotationToolbar';
16
+ import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker';
17
+
18
+ // Debug error boundary to catch silent toolbar crashes
19
+ class ToolbarErrorBoundary extends React.Component<
20
+ { children: React.ReactNode },
21
+ { error: Error | null }
22
+ > {
23
+ state = { error: null as Error | null };
24
+ static getDerivedStateFromError(error: Error) { return { error }; }
25
+ componentDidCatch(error: Error) { console.error('AnnotationToolbar crashed:', error); }
26
+ render() {
27
+ if (this.state.error) {
28
+ return <div style={{ position: 'fixed', top: 10, left: 10, zIndex: 9999, background: 'red', color: 'white', padding: '8px 12px', borderRadius: 6, fontSize: 12 }}>
29
+ Toolbar error: {this.state.error.message}
30
+ </div>;
31
+ }
32
+ return this.props.children;
33
+ }
34
+ }
35
+
36
+ import { CommentPopover, type CommentAskAIContext, type CommentAskAIHandler } from './CommentPopover';
37
+ import { TaterSpriteSitting } from './TaterSpriteSitting';
38
+ import { AttachmentsButton } from './AttachmentsButton';
39
+ import { MessagesIcon } from './icons/MessagesIcon';
40
+ import { GraphvizBlock } from './GraphvizBlock';
41
+ import { MermaidBlock } from './MermaidBlock';
42
+ import { getImageSrc } from './ImageThumbnail';
43
+ import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
44
+ import { getIdentity } from '../utils/identity';
45
+ import { type QuickLabel } from '../utils/quickLabels';
46
+ import { DocBadges, type LinkedDocBadgeInfo } from './DocBadges';
47
+ import { PinpointOverlay } from './PinpointOverlay';
48
+ import { usePinpoint } from '../hooks/usePinpoint';
49
+ import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
50
+ import { useScrollViewport } from '../hooks/useScrollViewport';
51
+ import { decodeAnchorHash } from '../utils/anchors';
52
+
53
+ interface ViewerProps {
54
+ blocks: Block[];
55
+ markdown: string;
56
+ frontmatter?: Frontmatter | null;
57
+ annotations: Annotation[];
58
+ onAddAnnotation: (ann: Annotation) => void;
59
+ onSelectAnnotation: (id: string | null) => void;
60
+ selectedAnnotationId: string | null;
61
+ mode: EditorMode;
62
+ inputMethod?: InputMethod;
63
+ taterMode: boolean;
64
+ globalAttachments?: ImageAttachment[];
65
+ onAddGlobalAttachment?: (image: ImageAttachment) => void;
66
+ onRemoveGlobalAttachment?: (path: string) => void;
67
+ repoInfo?: { display: string; branch?: string; host?: string } | null;
68
+ stickyActions?: boolean;
69
+ /** Render the plan as a floating card on a grid background (shadow/border/padding). Default false. */
70
+ gridEnabled?: boolean;
71
+ onOpenLinkedDoc?: (path: string) => void;
72
+ onOpenCodeFile?: (path: string) => void;
73
+ imageBaseDir?: string;
74
+ /** Directory the active document lives in — used by the code-path validator
75
+ * so out-of-tree relative references (e.g. `../foo.ts` in a linked doc)
76
+ * resolve against the doc's own directory rather than only cwd. */
77
+ codePathBaseDir?: string;
78
+ /** Opt out of `/api/doc/exists` code-path validation (host without that
79
+ * endpoint). Default undefined for Plannotator => validation stays on. */
80
+ disableCodePathValidation?: boolean;
81
+ linkedDocInfo?: LinkedDocBadgeInfo | null;
82
+ // Plan diff props
83
+ planDiffStats?: { additions: number; deletions: number; modifications: number } | null;
84
+ isPlanDiffActive?: boolean;
85
+ onPlanDiffToggle?: () => void;
86
+ hasPreviousVersion?: boolean;
87
+ /** Show amber "Demo" badge (portal mode, no shared content loaded) */
88
+ showDemoBadge?: boolean;
89
+ /** Max width in px for the plan card; null removes the cap entirely. */
90
+ maxWidth?: number | null;
91
+ /** Label for the copy button (default: "Copy plan") */
92
+ copyLabel?: string;
93
+ /**
94
+ * Compactness of the action button labels. See ActionsLabelMode in
95
+ * types.ts. Defaults to 'full' to preserve the original look for
96
+ * callers that don't measure plan-area width.
97
+ */
98
+ actionsLabelMode?: ActionsLabelMode;
99
+ archiveInfo?: { status: 'approved' | 'denied' | 'unknown'; timestamp: string; title: string } | null;
100
+ /** Source attribution for HTML/URL annotations (e.g. URL or filename) */
101
+ sourceInfo?: string;
102
+ /** Absolute path of the annotated source file for the Open-in-app control. */
103
+ openInAppPath?: string | null;
104
+ /**
105
+ * Message picker affordance — annotate-last mode only. Shown as a button in
106
+ * the sticky-top action bar so the user can switch to a different recent
107
+ * assistant message. Clicking opens the full picker in the left sidebar's
108
+ * Messages tab.
109
+ */
110
+ messagePickerInfo?: { current: number; total: number; onOpen: () => void };
111
+ // Checkbox toggle props
112
+ onToggleCheckbox?: (blockId: string, checked: boolean) => void;
113
+ checkboxOverrides?: Map<string, boolean>;
114
+ onAskAI?: CommentAskAIHandler;
115
+ }
116
+
117
+ export interface ViewerHandle {
118
+ removeHighlight: (id: string) => void;
119
+ clearAllHighlights: () => void;
120
+ applySharedAnnotations: (annotations: Annotation[]) => void;
121
+ }
122
+
123
+ /**
124
+ * Renders YAML frontmatter as a styled metadata card.
125
+ */
126
+ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }) => {
127
+ const entries = Object.entries(frontmatter);
128
+ if (entries.length === 0) return null;
129
+
130
+ return (
131
+ <div className="mt-4 mb-6 p-4 bg-muted/30 border border-border/50 rounded-lg">
132
+ <div className="grid gap-2 text-sm">
133
+ {entries.map(([key, value]) => (
134
+ <div key={key} className="flex gap-2">
135
+ <span className="font-medium text-muted-foreground min-w-[80px]">{key}:</span>
136
+ <span className="text-foreground">
137
+ {Array.isArray(value) ? (
138
+ <span className="flex flex-wrap gap-1">
139
+ {value.map((v, i) => (
140
+ <span key={i} className="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-xs">
141
+ {v}
142
+ </span>
143
+ ))}
144
+ </span>
145
+ ) : (
146
+ value
147
+ )}
148
+ </span>
149
+ </div>
150
+ ))}
151
+ </div>
152
+ </div>
153
+ );
154
+ };
155
+
156
+ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
157
+ blocks,
158
+ markdown,
159
+ frontmatter,
160
+ annotations,
161
+ onAddAnnotation,
162
+ onSelectAnnotation,
163
+ selectedAnnotationId,
164
+ mode,
165
+ inputMethod = 'drag',
166
+ taterMode,
167
+ globalAttachments = [],
168
+ onAddGlobalAttachment,
169
+ onRemoveGlobalAttachment,
170
+ repoInfo,
171
+ stickyActions = true,
172
+ gridEnabled = false,
173
+ planDiffStats,
174
+ isPlanDiffActive,
175
+ onPlanDiffToggle,
176
+ hasPreviousVersion,
177
+ showDemoBadge,
178
+ maxWidth,
179
+ onOpenLinkedDoc,
180
+ onOpenCodeFile,
181
+ linkedDocInfo,
182
+ imageBaseDir,
183
+ codePathBaseDir,
184
+ disableCodePathValidation,
185
+ copyLabel,
186
+ actionsLabelMode = 'full',
187
+ archiveInfo,
188
+ sourceInfo,
189
+ openInAppPath,
190
+ messagePickerInfo,
191
+ onToggleCheckbox,
192
+ checkboxOverrides,
193
+ onAskAI,
194
+ }, ref) => {
195
+ const [copied, setCopied] = useState(false);
196
+ const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
197
+ const [locationHash, setLocationHash] = useState(() => window.location.hash);
198
+ const globalCommentButtonRef = useRef<HTMLButtonElement>(null);
199
+
200
+ const handleCopyPlan = async () => {
201
+ try {
202
+ await navigator.clipboard.writeText(markdown);
203
+ setCopied(true);
204
+ setTimeout(() => setCopied(false), 2000);
205
+ } catch (e) {
206
+ console.error('Failed to copy:', e);
207
+ }
208
+ };
209
+ const containerRef = useRef<HTMLDivElement>(null);
210
+ // The badge cluster (repo chips / diff badge) is absolutely positioned in the
211
+ // card's top padding. One row fits; a second row (diff badge) or mobile
212
+ // wrapping outgrows the padding and lands on the document's first heading.
213
+ // Measure the cluster and insert exactly the clearance it needs (0 when it fits).
214
+ const docBadgesRef = useRef<HTMLDivElement | null>(null);
215
+ const [badgeClearance, setBadgeClearance] = useState(0);
216
+ useEffect(() => {
217
+ const el = docBadgesRef.current;
218
+ const article = containerRef.current;
219
+ if (!el || !article) { setBadgeClearance(0); return; }
220
+ const measure = () => {
221
+ const pad = parseFloat(getComputedStyle(article).paddingTop) || 0;
222
+ const overflow = el.offsetTop + el.offsetHeight - pad;
223
+ setBadgeClearance(overflow > 1 ? overflow + 4 : 0);
224
+ };
225
+ measure();
226
+ const ro = new ResizeObserver(measure);
227
+ ro.observe(el);
228
+ window.addEventListener('resize', measure);
229
+ return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
230
+ }, [repoInfo, hasPreviousVersion, showDemoBadge, linkedDocInfo, archiveInfo, sourceInfo, planDiffStats, openInAppPath]);
231
+
232
+ // Per-doc heading slug map with dedup — computed once per blocks array so
233
+ // anchor ids stay stable across re-renders and duplicate heading texts get
234
+ // `-1`/`-2`/... suffixes rather than colliding on the same id.
235
+ const headingSlugMap = useMemo(() => buildHeadingSlugMap(blocks), [blocks]);
236
+ const isTouchDevice = useMemo(() => window.matchMedia('(pointer: coarse)').matches, []);
237
+ const [hoveredCodeBlock, setHoveredCodeBlock] = useState<{ block: Block; element: HTMLElement } | null>(null);
238
+ const [isCodeBlockToolbarExiting, setIsCodeBlockToolbarExiting] = useState(false);
239
+ const [hoveredTable, setHoveredTable] = useState<{ block: Block; element: HTMLElement } | null>(null);
240
+ const [isTableToolbarExiting, setIsTableToolbarExiting] = useState(false);
241
+ const tableHoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
242
+ const [popoutTable, setPopoutTable] = useState<Block | null>(null);
243
+ // Viewer-specific comment popover state (global comments + code blocks)
244
+ const [viewerCommentPopover, setViewerCommentPopover] = useState<{
245
+ anchorEl: HTMLElement;
246
+ contextText: string;
247
+ selectedText?: string;
248
+ initialText?: string;
249
+ isGlobal: boolean;
250
+ codeBlock?: { block: Block; element: HTMLElement };
251
+ } | null>(null);
252
+ // Viewer-specific quick label state (code blocks)
253
+ const [codeBlockQuickLabelPicker, setCodeBlockQuickLabelPicker] = useState<{
254
+ anchorEl: HTMLElement;
255
+ codeBlock: { block: Block; element: HTMLElement };
256
+ } | null>(null);
257
+ const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
258
+ const stickySentinelRef = useRef<HTMLDivElement>(null);
259
+ const lastAutoScrolledHashRef = useRef<string | null>(null);
260
+ const [isStuck, setIsStuck] = useState(false);
261
+
262
+ // Shared annotation infrastructure via hook
263
+ const {
264
+ highlighterRef,
265
+ toolbarState,
266
+ commentPopover: hookCommentPopover,
267
+ quickLabelPicker: hookQuickLabelPicker,
268
+ handleAnnotate,
269
+ handleQuickLabel,
270
+ handleToolbarClose,
271
+ handleRequestComment,
272
+ handleCommentSubmit: hookCommentSubmit,
273
+ handleCommentClose: hookCommentClose,
274
+ handleFloatingQuickLabel: hookFloatingQuickLabel,
275
+ handleQuickLabelPickerDismiss: hookQuickLabelPickerDismiss,
276
+ removeHighlight: hookRemoveHighlight,
277
+ clearAllHighlights,
278
+ applyAnnotations,
279
+ } = useAnnotationHighlighter({
280
+ containerRef,
281
+ annotations,
282
+ onAddAnnotation,
283
+ onSelectAnnotation,
284
+ selectedAnnotationId,
285
+ mode,
286
+ });
287
+
288
+ // Refs for code block annotation path
289
+ const onAddAnnotationRef = useRef(onAddAnnotation);
290
+ useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]);
291
+ const modeRef = useRef<EditorMode>(mode);
292
+ useEffect(() => { modeRef.current = mode; }, [mode]);
293
+
294
+ // Pinpoint mode: hover + click to select elements
295
+ const handlePinpointCodeBlockClick = useCallback((blockId: string, element: HTMLElement) => {
296
+ const codeEl = element.querySelector('code');
297
+ if (!codeEl) return;
298
+ // In pinpoint mode, apply code block annotation based on current editor mode
299
+ if (modeRef.current === 'redline') {
300
+ applyCodeBlockAnnotation(blockId, codeEl, AnnotationType.DELETION);
301
+ } else if (modeRef.current === 'quickLabel') {
302
+ setCodeBlockQuickLabelPicker({
303
+ anchorEl: element,
304
+ codeBlock: { block: blocks.find(b => b.id === blockId)!, element },
305
+ });
306
+ } else {
307
+ // Show comment popover anchored to the code block
308
+ setViewerCommentPopover({
309
+ anchorEl: element,
310
+ contextText: (codeEl.textContent || '').slice(0, 80),
311
+ selectedText: codeEl.textContent || '',
312
+ isGlobal: false,
313
+ codeBlock: { block: blocks.find(b => b.id === blockId)!, element },
314
+ });
315
+ }
316
+ }, [blocks]);
317
+
318
+ const { hoverTarget } = usePinpoint({
319
+ containerRef,
320
+ highlighterRef,
321
+ inputMethod,
322
+ enabled: !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false),
323
+ onCodeBlockClick: handlePinpointCodeBlockClick,
324
+ });
325
+
326
+ // Suppress native context menu on touch devices (prevents cut/copy/paste overlay on mobile)
327
+ useEffect(() => {
328
+ const container = containerRef.current;
329
+ if (!container || !isTouchDevice) return;
330
+
331
+ const handleContextMenu = (e: Event) => {
332
+ e.preventDefault();
333
+ };
334
+
335
+ container.addEventListener('contextmenu', handleContextMenu);
336
+ return () => container.removeEventListener('contextmenu', handleContextMenu);
337
+ }, []);
338
+
339
+ // Detect when sticky action bar is "stuck" to show card background.
340
+ // The IntersectionObserver root must be the actual scroll element — the
341
+ // OverlayScrollArea viewport — not the <main> host, which doesn't scroll.
342
+ const stickyScrollViewport = useScrollViewport();
343
+ useEffect(() => {
344
+ if (!stickyActions || !stickySentinelRef.current || !stickyScrollViewport) return;
345
+ const observer = new IntersectionObserver(
346
+ ([entry]) => setIsStuck(!entry.isIntersecting),
347
+ { root: stickyScrollViewport, threshold: 0 }
348
+ );
349
+ observer.observe(stickySentinelRef.current);
350
+ return () => observer.disconnect();
351
+ }, [stickyActions, stickyScrollViewport]);
352
+
353
+ useEffect(() => {
354
+ const handleHashChange = () => {
355
+ lastAutoScrolledHashRef.current = null;
356
+ setLocationHash(window.location.hash);
357
+ };
358
+
359
+ window.addEventListener('hashchange', handleHashChange);
360
+ return () => window.removeEventListener('hashchange', handleHashChange);
361
+ }, []);
362
+
363
+ const scrollToAnchor = useCallback((hash: string) => {
364
+ const anchor = decodeAnchorHash(hash);
365
+ if (!anchor) return false;
366
+
367
+ const container = containerRef.current;
368
+ if (!container || !stickyScrollViewport) return false;
369
+
370
+ const target = document.getElementById(anchor);
371
+ if (!target || !container.contains(target)) return false;
372
+
373
+ const stickyActionsEl = container.querySelector<HTMLElement>('[data-sticky-actions]');
374
+ const stickyTop = stickyActionsEl
375
+ ? Number.parseFloat(window.getComputedStyle(stickyActionsEl).top || '0') || 0
376
+ : 0;
377
+ const headerOffset = stickyActionsEl
378
+ ? stickyActionsEl.getBoundingClientRect().height + stickyTop
379
+ : 0;
380
+ const containerRect = stickyScrollViewport.getBoundingClientRect();
381
+ const targetRect = target.getBoundingClientRect();
382
+ const relativeTop = targetRect.top - containerRect.top;
383
+ const offsetPosition = stickyScrollViewport.scrollTop + relativeTop - headerOffset;
384
+
385
+ stickyScrollViewport.scrollTo({
386
+ top: Math.max(0, offsetPosition),
387
+ behavior: 'smooth',
388
+ });
389
+ return true;
390
+ }, [stickyScrollViewport]);
391
+
392
+ useEffect(() => {
393
+ if (!stickyScrollViewport || !locationHash || lastAutoScrolledHashRef.current === locationHash) return;
394
+ const timer = window.setTimeout(() => {
395
+ if (scrollToAnchor(locationHash)) {
396
+ lastAutoScrolledHashRef.current = locationHash;
397
+ }
398
+ }, 0);
399
+ return () => window.clearTimeout(timer);
400
+ }, [blocks, locationHash, scrollToAnchor, stickyScrollViewport]);
401
+
402
+ // Use the native copy event so clipboard writes are synchronous (Safari
403
+ // rejects the async navigator.clipboard API outside the user-gesture window).
404
+ // web-highlighter clears the DOM selection on mouseup, so the browser has
405
+ // nothing to copy by the time Cmd+C fires — we inject the captured text here.
406
+ useEffect(() => {
407
+ const handleCopy = (e: ClipboardEvent) => {
408
+ const tag = (e.target as HTMLElement)?.tagName;
409
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
410
+
411
+ if (toolbarState?.selectionText) {
412
+ e.preventDefault();
413
+ e.clipboardData?.setData('text/plain', toolbarState.selectionText);
414
+ }
415
+ };
416
+
417
+ document.addEventListener('copy', handleCopy);
418
+ return () => document.removeEventListener('copy', handleCopy);
419
+ }, [toolbarState]);
420
+
421
+ // Imperative handle — delegates to hook, extends removeHighlight for code blocks
422
+ useImperativeHandle(ref, () => ({
423
+ removeHighlight: (id: string) => {
424
+ // Code block annotations need syntax re-highlighting after removal.
425
+ // Must run BEFORE hookRemoveHighlight, which removes the <mark> elements.
426
+ const manualHighlights = containerRef.current?.querySelectorAll(`[data-bind-id="${id}"]`);
427
+ manualHighlights?.forEach(el => {
428
+ const parent = el.parentNode;
429
+ if (parent && parent.nodeName === 'CODE') {
430
+ const codeEl = parent as HTMLElement;
431
+ const plainText = el.textContent || '';
432
+ el.remove();
433
+ codeEl.textContent = plainText;
434
+ const block = blocks.find(b => b.id === codeEl.closest('[data-block-id]')?.getAttribute('data-block-id'));
435
+ codeEl.removeAttribute('data-highlighted');
436
+ codeEl.className = `hljs font-mono${block?.language ? ` language-${block.language}` : ''}`;
437
+ hljs.highlightElement(codeEl);
438
+ }
439
+ });
440
+
441
+ hookRemoveHighlight(id);
442
+ },
443
+ clearAllHighlights,
444
+ applySharedAnnotations: applyAnnotations,
445
+ }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks]);
446
+
447
+ // --- Viewer-specific: code block annotation ---
448
+
449
+ const applyCodeBlockAnnotation = (
450
+ blockId: string,
451
+ codeEl: Element,
452
+ type: AnnotationType,
453
+ text?: string,
454
+ images?: ImageAttachment[],
455
+ isQuickLabel?: boolean,
456
+ quickLabelTip?: string,
457
+ ) => {
458
+ const id = `codeblock-${Date.now()}`;
459
+ const codeText = codeEl.textContent || '';
460
+
461
+ const wrapper = document.createElement('mark');
462
+ wrapper.className = `annotation-highlight ${type === AnnotationType.DELETION ? 'deletion' : type === AnnotationType.COMMENT ? 'comment' : ''}`.trim();
463
+ wrapper.dataset.bindId = id;
464
+ wrapper.textContent = codeText;
465
+
466
+ codeEl.innerHTML = '';
467
+ codeEl.appendChild(wrapper);
468
+
469
+ const newAnnotation: Annotation = {
470
+ id,
471
+ blockId,
472
+ startOffset: 0,
473
+ endOffset: codeText.length,
474
+ type,
475
+ text,
476
+ originalText: codeText,
477
+ createdA: Date.now(),
478
+ author: getIdentity(),
479
+ images,
480
+ ...(isQuickLabel ? { isQuickLabel: true } : {}),
481
+ ...(quickLabelTip ? { quickLabelTip } : {}),
482
+ };
483
+
484
+ onAddAnnotationRef.current(newAnnotation);
485
+ window.getSelection()?.removeAllRanges();
486
+ };
487
+
488
+ const handleCodeBlockAnnotate = (type: AnnotationType) => {
489
+ if (!hoveredCodeBlock) return;
490
+ const codeEl = hoveredCodeBlock.element.querySelector('code');
491
+ if (!codeEl) return;
492
+ applyCodeBlockAnnotation(hoveredCodeBlock.block.id, codeEl, type);
493
+ setHoveredCodeBlock(null);
494
+ };
495
+
496
+ const handleCodeBlockQuickLabel = (label: QuickLabel) => {
497
+ if (!hoveredCodeBlock) return;
498
+ const codeEl = hoveredCodeBlock.element.querySelector('code');
499
+ if (!codeEl) return;
500
+ applyCodeBlockAnnotation(
501
+ hoveredCodeBlock.block.id, codeEl, AnnotationType.COMMENT,
502
+ `${label.emoji} ${label.text}`, undefined, true, label.tip
503
+ );
504
+ setHoveredCodeBlock(null);
505
+ };
506
+
507
+ const handleCodeBlockToolbarClose = () => {
508
+ setHoveredCodeBlock(null);
509
+ };
510
+
511
+ // Viewer-specific comment popover handlers (code blocks + global comments)
512
+
513
+ const handleCodeBlockRequestComment = (initialChar?: string) => {
514
+ if (!hoveredCodeBlock) return;
515
+ const codeText = hoveredCodeBlock.element.querySelector('code')?.textContent || '';
516
+ setViewerCommentPopover({
517
+ anchorEl: hoveredCodeBlock.element,
518
+ contextText: codeText.slice(0, 80),
519
+ selectedText: codeText,
520
+ initialText: initialChar,
521
+ isGlobal: false,
522
+ codeBlock: hoveredCodeBlock,
523
+ });
524
+ setHoveredCodeBlock(null);
525
+ };
526
+
527
+ const handleViewerCommentSubmit = (text: string, images?: ImageAttachment[]) => {
528
+ if (!viewerCommentPopover) return;
529
+
530
+ if (viewerCommentPopover.isGlobal) {
531
+ const newAnnotation: Annotation = {
532
+ id: `global-${Date.now()}`,
533
+ blockId: '',
534
+ startOffset: 0,
535
+ endOffset: 0,
536
+ type: AnnotationType.GLOBAL_COMMENT,
537
+ text: text.trim(),
538
+ originalText: '',
539
+ createdA: Date.now(),
540
+ author: getIdentity(),
541
+ images,
542
+ };
543
+ onAddAnnotation(newAnnotation);
544
+ } else if (viewerCommentPopover.codeBlock) {
545
+ const codeEl = viewerCommentPopover.codeBlock.element.querySelector('code');
546
+ if (codeEl) {
547
+ applyCodeBlockAnnotation(viewerCommentPopover.codeBlock.block.id, codeEl, AnnotationType.COMMENT, text, images);
548
+ }
549
+ }
550
+
551
+ setViewerCommentPopover(null);
552
+ };
553
+
554
+ const handleViewerCommentClose = useCallback(() => {
555
+ setViewerCommentPopover(null);
556
+ }, []);
557
+
558
+ const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir, disableCodePathValidation);
559
+
560
+ return (
561
+ <CodePathValidationContext.Provider value={codePathValidation}>
562
+ <div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
563
+ {taterMode && <TaterSpriteSitting />}
564
+ <article
565
+ ref={containerRef}
566
+ data-print-region="article"
567
+ className={`w-full bg-card rounded-xl py-5 md:py-8 lg:py-10 xl:py-12 relative ${gridEnabled ? 'px-5 md:px-8 lg:px-10 xl:px-12 shadow-xl border border-border/50' : ''} ${inputMethod === 'pinpoint' ? 'cursor-crosshair' : ''}`}
568
+ style={{ WebkitTouchCallout: 'none' } as React.CSSProperties}
569
+ >
570
+ {/* Repo info + plan diff badge + demo badge + linked doc badge + archive badge - top left */}
571
+ {(repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo || archiveInfo || sourceInfo || openInAppPath) && (
572
+ <div ref={docBadgesRef} data-print-hide className={`absolute top-3 md:top-4 ${gridEnabled ? 'left-3 md:left-5' : 'left-0'}`}>
573
+ <DocBadges
574
+ layout="column"
575
+ repoInfo={repoInfo}
576
+ planDiffStats={planDiffStats}
577
+ isPlanDiffActive={isPlanDiffActive}
578
+ hasPreviousVersion={hasPreviousVersion}
579
+ onPlanDiffToggle={onPlanDiffToggle}
580
+ showDemoBadge={showDemoBadge}
581
+ archiveInfo={archiveInfo}
582
+ linkedDocInfo={linkedDocInfo}
583
+ sourceInfo={sourceInfo}
584
+ openInAppPath={openInAppPath}
585
+ />
586
+ </div>
587
+ )}
588
+
589
+ {/* Clearance so document content starts below the (absolute) badge cluster
590
+ when it outgrows the card's top padding — see the measuring effect above. */}
591
+ {badgeClearance > 0 && <div data-print-hide style={{ height: badgeClearance }} aria-hidden="true" />}
592
+
593
+ {/* Sentinel for sticky detection */}
594
+ {stickyActions && <div ref={stickySentinelRef} className="h-0 w-0 float-right" aria-hidden="true" />}
595
+
596
+ {/* Header buttons - top right */}
597
+ <div data-print-hide data-sticky-actions className={`${stickyActions ? 'sticky top-3' : ''} z-30 float-right flex items-start gap-1 md:gap-2 rounded-lg p-1 md:p-2 transition-colors duration-150 ${isStuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''} ${gridEnabled ? '-mr-3 md:-mr-5 lg:-mr-7 xl:-mr-9' : '-mr-1 md:-mr-2'} mt-6 md:-mt-5 lg:-mt-7 xl:-mt-9`}>
598
+ {messagePickerInfo && (
599
+ <button
600
+ onClick={messagePickerInfo.onOpen}
601
+ className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
602
+ title="Pick a different message to annotate"
603
+ >
604
+ <MessagesIcon />
605
+ {actionsLabelMode === 'full' && (
606
+ <span>Message {messagePickerInfo.current} of {messagePickerInfo.total}</span>
607
+ )}
608
+ {actionsLabelMode === 'short' && (
609
+ <span>{messagePickerInfo.current}/{messagePickerInfo.total}</span>
610
+ )}
611
+ </button>
612
+ )}
613
+
614
+ {/* Attachments button */}
615
+ {onAddGlobalAttachment && onRemoveGlobalAttachment && (
616
+ <AttachmentsButton
617
+ images={globalAttachments}
618
+ onAdd={onAddGlobalAttachment}
619
+ onRemove={onRemoveGlobalAttachment}
620
+ variant="toolbar"
621
+ hideLabel={actionsLabelMode === 'icon'}
622
+ />
623
+ )}
624
+
625
+ {/* <span className="md:hidden">Comment</span><span className="hidden md:inline">Global comment</span> button */}
626
+ <button
627
+ ref={globalCommentButtonRef}
628
+ onClick={() => {
629
+ setViewerCommentPopover({
630
+ anchorEl: globalCommentButtonRef.current!,
631
+ contextText: '',
632
+ isGlobal: true,
633
+ });
634
+ }}
635
+ className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
636
+ title="Add global comment"
637
+ >
638
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
639
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
640
+ </svg>
641
+ {actionsLabelMode === 'full' && <span>Global comment</span>}
642
+ {actionsLabelMode === 'short' && <span>Comment</span>}
643
+ </button>
644
+
645
+ {/* Copy plan/file button */}
646
+ <button
647
+ onClick={handleCopyPlan}
648
+ className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors"
649
+ title={copied ? 'Copied!' : copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}
650
+ >
651
+ {copied ? (
652
+ <>
653
+ <svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
654
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
655
+ </svg>
656
+ Copied!
657
+ </>
658
+ ) : (
659
+ <>
660
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
661
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
662
+ </svg>
663
+ {actionsLabelMode === 'full' && <span>{copyLabel || (linkedDocInfo ? 'Copy file' : 'Copy plan')}</span>}
664
+ {actionsLabelMode === 'short' && <span>Copy</span>}
665
+ </>
666
+ )}
667
+ </button>
668
+ </div>
669
+ {frontmatter && <><div className="clear-right md:hidden" /><FrontmatterCard frontmatter={frontmatter} /></>}
670
+ {!frontmatter && blocks.length > 0 && blocks[0].type !== 'heading' && <div className="mt-4" />}
671
+ {groupBlocks(blocks).map(group =>
672
+ group.type === 'list-group' ? (
673
+ (() => {
674
+ const indices = computeListIndices(group.blocks);
675
+ return (
676
+ <div key={group.key} data-pinpoint-group="list" className="py-1 -mx-2 px-2">
677
+ {group.blocks.map((block, i) => (
678
+ <BlockRenderer
679
+ imageBaseDir={imageBaseDir}
680
+ onImageClick={(src, alt) => setLightbox({ src, alt })}
681
+ key={block.id}
682
+ block={block}
683
+ orderedIndex={indices[i]}
684
+ onOpenLinkedDoc={onOpenLinkedDoc}
685
+ onOpenCodeFile={onOpenCodeFile}
686
+ onToggleCheckbox={onToggleCheckbox}
687
+ checkboxOverrides={checkboxOverrides}
688
+ githubRepo={repoInfo?.display}
689
+ headingAnchorId={headingSlugMap.get(block.id)}
690
+ onNavigateAnchor={scrollToAnchor}
691
+ />
692
+ ))}
693
+ </div>
694
+ );
695
+ })()
696
+ ) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
697
+ <MermaidBlock key={group.block.id} block={group.block} />
698
+ ) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
699
+ <GraphvizBlock key={group.block.id} block={group.block} />
700
+ ) : group.block.type === 'table' ? (
701
+ <TableBlock
702
+ key={group.block.id}
703
+ block={group.block}
704
+ imageBaseDir={imageBaseDir}
705
+ onImageClick={(src, alt) => setLightbox({ src, alt })}
706
+ onOpenLinkedDoc={onOpenLinkedDoc}
707
+ onOpenCodeFile={onOpenCodeFile}
708
+ githubRepo={repoInfo?.display}
709
+ onNavigateAnchor={scrollToAnchor}
710
+ onHover={(element) => {
711
+ if (tableHoverTimeoutRef.current) {
712
+ clearTimeout(tableHoverTimeoutRef.current);
713
+ tableHoverTimeoutRef.current = null;
714
+ }
715
+ setIsTableToolbarExiting(false);
716
+ if (!toolbarState) {
717
+ setHoveredTable({ block: group.block, element });
718
+ }
719
+ }}
720
+ onLeave={() => {
721
+ tableHoverTimeoutRef.current = setTimeout(() => {
722
+ setIsTableToolbarExiting(true);
723
+ setTimeout(() => {
724
+ setHoveredTable(null);
725
+ setIsTableToolbarExiting(false);
726
+ }, 150);
727
+ }, 100);
728
+ }}
729
+ />
730
+ ) : group.block.type === 'code' ? (
731
+ <CodeBlock
732
+ key={group.block.id}
733
+ block={group.block}
734
+ onHover={inputMethod === 'pinpoint' ? () => {} : (element) => {
735
+ // Clear any pending leave timeout
736
+ if (hoverTimeoutRef.current) {
737
+ clearTimeout(hoverTimeoutRef.current);
738
+ hoverTimeoutRef.current = null;
739
+ }
740
+ // Cancel exit animation if re-entering
741
+ setIsCodeBlockToolbarExiting(false);
742
+ // Only show hover toolbar if no selection toolbar is active
743
+ if (!toolbarState) {
744
+ setHoveredCodeBlock({ block: group.block, element });
745
+ }
746
+ }}
747
+ onLeave={inputMethod === 'pinpoint' ? () => {} : () => {
748
+ // Delay then start exit animation
749
+ hoverTimeoutRef.current = setTimeout(() => {
750
+ setIsCodeBlockToolbarExiting(true);
751
+ // After exit animation, unmount
752
+ setTimeout(() => {
753
+ setHoveredCodeBlock(null);
754
+ setIsCodeBlockToolbarExiting(false);
755
+ }, 150);
756
+ }, 100);
757
+ }}
758
+ isHovered={inputMethod !== 'pinpoint' && hoveredCodeBlock?.block.id === group.block.id}
759
+ />
760
+ ) : (
761
+ <BlockRenderer imageBaseDir={imageBaseDir} onImageClick={(src, alt) => setLightbox({ src, alt })} key={group.block.id} block={group.block} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} onNavigateAnchor={scrollToAnchor} onToggleCheckbox={onToggleCheckbox} checkboxOverrides={checkboxOverrides} githubRepo={repoInfo?.display} headingAnchorId={headingSlugMap.get(group.block.id)} />
762
+ )
763
+ )}
764
+
765
+ {/* Text selection toolbar */}
766
+ {toolbarState && (
767
+ <ToolbarErrorBoundary>
768
+ <AnnotationToolbar
769
+ element={toolbarState.element}
770
+ positionMode="center-above"
771
+ onAnnotate={handleAnnotate}
772
+ onClose={handleToolbarClose}
773
+ onRequestComment={handleRequestComment}
774
+ onQuickLabel={handleQuickLabel}
775
+ copyText={toolbarState.selectionText}
776
+ hideCopyButton={!isTouchDevice}
777
+ closeOnScrollOut
778
+ />
779
+ </ToolbarErrorBoundary>
780
+ )}
781
+
782
+ {/* Table hover toolbar */}
783
+ {hoveredTable && !toolbarState && (
784
+ <TableToolbar
785
+ element={hoveredTable.element}
786
+ markdown={hoveredTable.block.content}
787
+ isExiting={isTableToolbarExiting}
788
+ onExpand={() => {
789
+ setPopoutTable(hoveredTable.block);
790
+ setHoveredTable(null);
791
+ setIsTableToolbarExiting(false);
792
+ if (tableHoverTimeoutRef.current) {
793
+ clearTimeout(tableHoverTimeoutRef.current);
794
+ tableHoverTimeoutRef.current = null;
795
+ }
796
+ }}
797
+ onMouseEnter={() => {
798
+ if (tableHoverTimeoutRef.current) {
799
+ clearTimeout(tableHoverTimeoutRef.current);
800
+ tableHoverTimeoutRef.current = null;
801
+ }
802
+ setIsTableToolbarExiting(false);
803
+ }}
804
+ onMouseLeave={() => {
805
+ tableHoverTimeoutRef.current = setTimeout(() => {
806
+ setIsTableToolbarExiting(true);
807
+ setTimeout(() => {
808
+ setHoveredTable(null);
809
+ setIsTableToolbarExiting(false);
810
+ }, 150);
811
+ }, 100);
812
+ }}
813
+ />
814
+ )}
815
+
816
+ {/* Code block hover toolbar */}
817
+ {hoveredCodeBlock && !toolbarState && (
818
+ <ToolbarErrorBoundary>
819
+ <AnnotationToolbar
820
+ element={hoveredCodeBlock.element}
821
+ positionMode="top-right"
822
+ onAnnotate={handleCodeBlockAnnotate}
823
+ onClose={handleCodeBlockToolbarClose}
824
+ onRequestComment={handleCodeBlockRequestComment}
825
+ onQuickLabel={handleCodeBlockQuickLabel}
826
+ isExiting={isCodeBlockToolbarExiting}
827
+ onMouseEnter={() => {
828
+ if (hoverTimeoutRef.current) {
829
+ clearTimeout(hoverTimeoutRef.current);
830
+ hoverTimeoutRef.current = null;
831
+ }
832
+ setIsCodeBlockToolbarExiting(false);
833
+ }}
834
+ onMouseLeave={() => {
835
+ hoverTimeoutRef.current = setTimeout(() => {
836
+ setIsCodeBlockToolbarExiting(true);
837
+ setTimeout(() => {
838
+ setHoveredCodeBlock(null);
839
+ setIsCodeBlockToolbarExiting(false);
840
+ }, 150);
841
+ }, 100);
842
+ }}
843
+ />
844
+ </ToolbarErrorBoundary>
845
+ )}
846
+
847
+ {/* Table popout dialog — portaled into containerRef so annotations */}
848
+ {/* can walk into its text nodes the same way they do the inline table. */}
849
+ {popoutTable && (
850
+ <TablePopout
851
+ block={popoutTable}
852
+ open={!!popoutTable}
853
+ onClose={() => setPopoutTable(null)}
854
+ container={containerRef.current}
855
+ imageBaseDir={imageBaseDir}
856
+ onImageClick={(src, alt) => setLightbox({ src, alt })}
857
+ onOpenLinkedDoc={onOpenLinkedDoc}
858
+ onOpenCodeFile={onOpenCodeFile}
859
+ githubRepo={repoInfo?.display}
860
+ onNavigateAnchor={scrollToAnchor}
861
+ />
862
+ )}
863
+
864
+ {/* Pinpoint hover overlay */}
865
+ {inputMethod === 'pinpoint' && (
866
+ <PinpointOverlay target={hoverTarget} containerRef={containerRef} />
867
+ )}
868
+
869
+ {/* Comment popover — hook handles text selection, Viewer handles global + code block */}
870
+ {hookCommentPopover && (
871
+ <CommentPopover
872
+ anchorEl={hookCommentPopover.anchorEl}
873
+ contextText={hookCommentPopover.contextText}
874
+ isGlobal={false}
875
+ initialText={hookCommentPopover.initialText}
876
+ onSubmit={hookCommentSubmit}
877
+ onClose={hookCommentClose}
878
+ onAskAI={onAskAI}
879
+ askAIContext={{
880
+ kind: 'selection',
881
+ label: 'Selected text',
882
+ text: hookCommentPopover.selectedText ?? hookCommentPopover.contextText,
883
+ sourcePath: linkedDocInfo?.filepath ?? sourceInfo,
884
+ }}
885
+ />
886
+ )}
887
+ {viewerCommentPopover && (
888
+ <CommentPopover
889
+ anchorEl={viewerCommentPopover.anchorEl}
890
+ contextText={viewerCommentPopover.contextText}
891
+ isGlobal={viewerCommentPopover.isGlobal}
892
+ initialText={viewerCommentPopover.initialText}
893
+ onSubmit={handleViewerCommentSubmit}
894
+ onClose={handleViewerCommentClose}
895
+ onAskAI={onAskAI}
896
+ askAIContext={{
897
+ kind: viewerCommentPopover.isGlobal ? 'general' : 'selection',
898
+ label: viewerCommentPopover.isGlobal ? 'Document' : 'Code block',
899
+ text: viewerCommentPopover.selectedText,
900
+ sourcePath: linkedDocInfo?.filepath ?? sourceInfo,
901
+ }}
902
+ />
903
+ )}
904
+
905
+ {/* Quick Label floating picker — hook handles text selection, Viewer handles code blocks */}
906
+ {hookQuickLabelPicker && (
907
+ <FloatingQuickLabelPicker
908
+ anchorEl={hookQuickLabelPicker.anchorEl}
909
+ cursorHint={hookQuickLabelPicker.cursorHint}
910
+ onSelect={hookFloatingQuickLabel}
911
+ onDismiss={hookQuickLabelPickerDismiss}
912
+ />
913
+ )}
914
+ {codeBlockQuickLabelPicker && (
915
+ <FloatingQuickLabelPicker
916
+ anchorEl={codeBlockQuickLabelPicker.anchorEl}
917
+ onSelect={(label: QuickLabel) => {
918
+ const codeEl = codeBlockQuickLabelPicker.codeBlock.element.querySelector('code');
919
+ if (codeEl) {
920
+ applyCodeBlockAnnotation(
921
+ codeBlockQuickLabelPicker.codeBlock.block.id, codeEl, AnnotationType.COMMENT,
922
+ `${label.emoji} ${label.text}`, undefined, true, label.tip
923
+ );
924
+ }
925
+ setCodeBlockQuickLabelPicker(null);
926
+ window.getSelection()?.removeAllRanges();
927
+ }}
928
+ onDismiss={() => {
929
+ setCodeBlockQuickLabelPicker(null);
930
+ window.getSelection()?.removeAllRanges();
931
+ }}
932
+ />
933
+ )}
934
+ </article>
935
+
936
+ {/* Image lightbox */}
937
+ {lightbox && createPortal(
938
+ <ImageLightbox src={lightbox.src} alt={lightbox.alt} onClose={() => setLightbox(null)} />,
939
+ document.body
940
+ )}
941
+ </div>
942
+ </CodePathValidationContext.Provider>
943
+ );
944
+ });
945
+
946
+ /** Simple lightbox overlay for enlarged image viewing. */
947
+ const ImageLightbox: React.FC<{ src: string; alt: string; onClose: () => void }> = ({ src, alt, onClose }) => {
948
+ useEffect(() => {
949
+ const handleKeyDown = (e: KeyboardEvent) => {
950
+ if (e.key === 'Escape') onClose();
951
+ };
952
+ window.addEventListener('keydown', handleKeyDown);
953
+ return () => window.removeEventListener('keydown', handleKeyDown);
954
+ }, [onClose]);
955
+
956
+ return (
957
+ <div
958
+ className="fixed inset-0 z-[200] flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm cursor-zoom-out"
959
+ onClick={onClose}
960
+ >
961
+ <img
962
+ src={src}
963
+ alt={alt}
964
+ className="max-w-[90vw] max-h-[85vh] object-contain rounded-lg shadow-2xl"
965
+ onClick={(e) => e.stopPropagation()}
966
+ />
967
+ {alt && (
968
+ <div className="mt-3 text-sm text-white/70 max-w-[90vw] text-center truncate">{alt}</div>
969
+ )}
970
+ </div>
971
+ );
972
+ };
973
+
974
+
975
+
976
+
977
+