@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,410 @@
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { ImageThumbnail, getImageSrc } from './ImageThumbnail';
4
+ import { ImageAnnotator } from './ImageAnnotator';
5
+ import type { ImageAttachment } from '../types';
6
+ import { modKey } from '../utils/platform';
7
+ import { getUploadTransport } from '../utils/upload';
8
+
9
+ /**
10
+ * Derive a clean, human-readable name from an original filename.
11
+ * "Login Mockup.png" → "login-mockup"
12
+ * "annotated.png" or generic names → "image-N"
13
+ */
14
+ export function deriveImageName(originalName: string, existingNames: string[]): string {
15
+ const base = originalName.replace(/\.[^.]+$/, '');
16
+ const generic = ['annotated', 'image', 'screenshot', 'paste', 'clipboard', 'untitled'];
17
+
18
+ if (generic.includes(base.toLowerCase())) {
19
+ let n = 1;
20
+ while (existingNames.includes(`image-${n}`)) n++;
21
+ return `image-${n}`;
22
+ }
23
+
24
+ let name = base.toLowerCase()
25
+ .replace(/[_\s]+/g, '-')
26
+ .replace(/[^a-z0-9-]/g, '')
27
+ .replace(/-+/g, '-')
28
+ .replace(/^-|-$/g, '');
29
+
30
+ if (!name) {
31
+ let n = 1;
32
+ while (existingNames.includes(`image-${n}`)) n++;
33
+ return `image-${n}`;
34
+ }
35
+
36
+ if (existingNames.includes(name)) {
37
+ let n = 2;
38
+ while (existingNames.includes(`${name}-${n}`)) n++;
39
+ name = `${name}-${n}`;
40
+ }
41
+
42
+ return name;
43
+ }
44
+
45
+ interface AttachmentsButtonProps {
46
+ images: ImageAttachment[];
47
+ onAdd: (image: ImageAttachment) => void;
48
+ onRemove: (path: string) => void;
49
+ variant?: 'toolbar' | 'inline';
50
+ /** Hide the "Images" label (icon-only). When images.length > 0 the
51
+ * numeric badge still shows so the user can see the count. */
52
+ hideLabel?: boolean;
53
+ }
54
+
55
+ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
56
+ images,
57
+ onAdd,
58
+ onRemove,
59
+ variant = 'toolbar',
60
+ hideLabel = false,
61
+ }) => {
62
+ const [isOpen, setIsOpen] = useState(false);
63
+ const [manualPath, setManualPath] = useState('');
64
+ const [uploading, setUploading] = useState(false);
65
+ const [dragOver, setDragOver] = useState(false);
66
+ const buttonRef = useRef<HTMLButtonElement>(null);
67
+ const fileInputRef = useRef<HTMLInputElement>(null);
68
+ const [position, setPosition] = useState({ top: 0, left: 0 });
69
+
70
+ // Annotator state
71
+ const [annotatorImage, setAnnotatorImage] = useState<{ file: File; blobUrl: string; initialName: string } | null>(null);
72
+ const [editingImage, setEditingImage] = useState<{ path: string; name: string } | null>(null);
73
+
74
+ // Update popover position when opened
75
+ useEffect(() => {
76
+ if (isOpen && buttonRef.current) {
77
+ const rect = buttonRef.current.getBoundingClientRect();
78
+ setPosition({
79
+ top: rect.bottom + 8,
80
+ left: Math.max(8, rect.left - 100), // Center-ish, but keep on screen
81
+ });
82
+ }
83
+ }, [isOpen]);
84
+
85
+ // Close on escape
86
+ useEffect(() => {
87
+ const handleKeyDown = (e: KeyboardEvent) => {
88
+ if (e.key === 'Escape' && isOpen) {
89
+ setIsOpen(false);
90
+ }
91
+ };
92
+ window.addEventListener('keydown', handleKeyDown);
93
+ return () => window.removeEventListener('keydown', handleKeyDown);
94
+ }, [isOpen]);
95
+
96
+ // Paste image from clipboard when popover is open (per-annotation attachments).
97
+ // Uses capture phase + stopPropagation to prevent the global paste handler in
98
+ // App.tsx from also processing the same event.
99
+ useEffect(() => {
100
+ if (!isOpen || annotatorImage) return;
101
+ const handlePaste = (e: ClipboardEvent) => {
102
+ const items = e.clipboardData?.items;
103
+ if (!items) return;
104
+ for (const item of items) {
105
+ if (item.type.startsWith('image/')) {
106
+ e.preventDefault();
107
+ e.stopPropagation();
108
+ const file = item.getAsFile();
109
+ if (file) handleFileSelect(file);
110
+ return;
111
+ }
112
+ }
113
+ };
114
+ document.addEventListener('paste', handlePaste, true);
115
+ return () => document.removeEventListener('paste', handlePaste, true);
116
+ }, [isOpen, annotatorImage]);
117
+
118
+ const handleFileSelect = (file: File) => {
119
+ // Derive name before opening annotator so user sees it immediately
120
+ const initialName = deriveImageName(file.name, images.map(i => i.name));
121
+ const blobUrl = URL.createObjectURL(file);
122
+ setAnnotatorImage({ file, blobUrl, initialName });
123
+ setIsOpen(false); // Close popover when annotator opens
124
+ };
125
+
126
+ const handleAnnotatorAccept = async (blob: Blob, hasDrawings: boolean, name: string) => {
127
+ setUploading(true);
128
+ try {
129
+ // Use annotated blob if drawings exist, otherwise original file
130
+ const fileToUpload = annotatorImage
131
+ ? (hasDrawings
132
+ ? new File([blob], 'annotated.png', { type: 'image/png' })
133
+ : annotatorImage.file)
134
+ : editingImage
135
+ // Re-editing: always upload the new blob
136
+ ? new File([blob], 'annotated.png', { type: 'image/png' })
137
+ : null;
138
+
139
+ if (fileToUpload) {
140
+ const data = await getUploadTransport().upload(fileToUpload);
141
+ if (data.path) {
142
+ // If re-editing, remove old path first
143
+ if (editingImage) {
144
+ onRemove(editingImage.path);
145
+ }
146
+ // Use the name from the annotator (user may have edited it)
147
+ onAdd({ path: data.path, name });
148
+ }
149
+ }
150
+ } catch (err) {
151
+ console.error('Upload failed:', err);
152
+ } finally {
153
+ setUploading(false);
154
+ // Cleanup
155
+ if (annotatorImage) {
156
+ URL.revokeObjectURL(annotatorImage.blobUrl);
157
+ setAnnotatorImage(null);
158
+ }
159
+ setEditingImage(null);
160
+ }
161
+ };
162
+
163
+ const handleAnnotatorClose = () => {
164
+ if (annotatorImage) {
165
+ URL.revokeObjectURL(annotatorImage.blobUrl);
166
+ setAnnotatorImage(null);
167
+ }
168
+ setEditingImage(null);
169
+ };
170
+
171
+ const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
172
+ const file = e.target.files?.[0];
173
+ if (file) {
174
+ handleFileSelect(file);
175
+ }
176
+ e.target.value = ''; // Reset for re-selection
177
+ };
178
+
179
+ const handleDrop = (e: React.DragEvent) => {
180
+ e.preventDefault();
181
+ setDragOver(false);
182
+ const file = e.dataTransfer.files[0];
183
+ if (file && file.type.startsWith('image/')) {
184
+ handleFileSelect(file);
185
+ }
186
+ };
187
+
188
+ const handleManualAdd = () => {
189
+ const trimmed = manualPath.trim();
190
+ if (trimmed) {
191
+ const name = deriveImageName(
192
+ trimmed.split('/').pop() || 'image',
193
+ images.map(i => i.name)
194
+ );
195
+ onAdd({ path: trimmed, name });
196
+ setManualPath('');
197
+ }
198
+ };
199
+
200
+ const handleClearAll = (e: React.MouseEvent) => {
201
+ e.stopPropagation();
202
+ images.forEach(img => onRemove(img.path));
203
+ };
204
+
205
+ // Determine annotator props
206
+ const annotatorOpen = !!annotatorImage || !!editingImage;
207
+ const annotatorSrc = annotatorImage?.blobUrl ?? (editingImage ? getImageSrc(editingImage.path) : '');
208
+ const annotatorInitialName = annotatorImage?.initialName ?? editingImage?.name ?? '';
209
+
210
+ return (
211
+ <>
212
+ {/* Trigger Button */}
213
+ <button
214
+ ref={buttonRef}
215
+ type="button"
216
+ onClick={() => setIsOpen(!isOpen)}
217
+ aria-label="Attachments"
218
+ title="Attachments"
219
+ className="group relative flex items-center gap-1.5 px-2 py-1.5 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
220
+ >
221
+ {/* Show stacked thumbnails if we have images */}
222
+ {images.length > 0 ? (
223
+ <>
224
+ <div className="relative flex items-center">
225
+ {images.slice(0, 3).map((img, idx) => (
226
+ <div
227
+ key={img.path}
228
+ className="relative w-5 h-5 rounded border border-background"
229
+ style={{ marginLeft: idx > 0 ? '-6px' : 0, zIndex: 3 - idx }}
230
+ >
231
+ <img
232
+ src={getImageSrc(img.path)}
233
+ alt={img.name}
234
+ loading="lazy"
235
+ className="w-5 h-5 rounded object-cover"
236
+ />
237
+ </div>
238
+ ))}
239
+ {images.length > 3 && (
240
+ <div
241
+ className="relative w-5 h-5 rounded bg-muted border border-background flex items-center justify-center text-[9px] font-medium"
242
+ style={{ marginLeft: '-6px', zIndex: 0 }}
243
+ >
244
+ +{images.length - 3}
245
+ </div>
246
+ )}
247
+ </div>
248
+ {/* Clear all button on hover */}
249
+ <button
250
+ onClick={handleClearAll}
251
+ className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-destructive text-destructive-foreground rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
252
+ >
253
+ <svg className="w-2 h-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
254
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
255
+ </svg>
256
+ </button>
257
+ </>
258
+ ) : (
259
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
260
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
261
+ </svg>
262
+ )}
263
+ {(!hideLabel || images.length > 0) && (
264
+ <span className={variant === 'inline' ? 'sr-only' : ''}>
265
+ {images.length > 0 ? `${images.length}` : 'Images'}
266
+ </span>
267
+ )}
268
+ </button>
269
+
270
+ {/* Popover */}
271
+ {isOpen && createPortal(
272
+ <>
273
+ {/* Backdrop */}
274
+ <div
275
+ className="fixed inset-0 z-[90]"
276
+ data-popover-layer
277
+ onClick={() => setIsOpen(false)}
278
+ />
279
+
280
+ {/* Popover content */}
281
+ <div
282
+ className="fixed z-[100] w-72 bg-card border border-border rounded-xl shadow-2xl p-3"
283
+ data-popover-layer
284
+ style={{ top: position.top, left: position.left }}
285
+ onClick={e => e.stopPropagation()}
286
+ >
287
+ <div className="space-y-3">
288
+ {/* Header */}
289
+ <div className="flex items-center justify-between">
290
+ <div className="text-sm font-medium">Attachments</div>
291
+ {images.length > 0 && (
292
+ <span className="text-[10px] text-muted-foreground">
293
+ {images.length} image{images.length !== 1 ? 's' : ''}
294
+ </span>
295
+ )}
296
+ </div>
297
+ <p className="text-[11px] text-muted-foreground -mt-1">
298
+ Add images to include with your feedback
299
+ </p>
300
+
301
+ {/* Drop zone / file picker */}
302
+ <div
303
+ onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
304
+ onDragLeave={() => setDragOver(false)}
305
+ onDrop={handleDrop}
306
+ onClick={() => fileInputRef.current?.click()}
307
+ className={`flex flex-col items-center justify-center gap-2 px-3 py-4 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
308
+ dragOver
309
+ ? 'border-primary bg-primary/5'
310
+ : 'border-border hover:border-muted-foreground'
311
+ }`}
312
+ >
313
+ {uploading ? (
314
+ <div className="flex items-center gap-2 text-muted-foreground">
315
+ <svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
316
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
317
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
318
+ </svg>
319
+ <span className="text-xs">Uploading...</span>
320
+ </div>
321
+ ) : (
322
+ <>
323
+ <svg className="w-6 h-6 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
324
+ <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
325
+ </svg>
326
+ <span className="text-xs text-muted-foreground">
327
+ Drop image or click to browse
328
+ </span>
329
+ <span className="text-[10px] text-muted-foreground/70">
330
+ {modKey}+V to paste from clipboard
331
+ </span>
332
+ </>
333
+ )}
334
+ </div>
335
+ <input
336
+ ref={fileInputRef}
337
+ type="file"
338
+ accept="image/*"
339
+ onChange={handleFileInputChange}
340
+ className="hidden"
341
+ />
342
+
343
+ {/* Manual path input */}
344
+ <div className="flex gap-2">
345
+ <input
346
+ type="text"
347
+ value={manualPath}
348
+ onChange={(e) => setManualPath(e.target.value)}
349
+ onKeyDown={(e) => e.key === 'Enter' && !e.nativeEvent.isComposing && handleManualAdd()}
350
+ placeholder="Paste path or URL..."
351
+ className="flex-1 px-2 py-1.5 text-xs bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
352
+ />
353
+ <button
354
+ type="button"
355
+ onClick={handleManualAdd}
356
+ disabled={!manualPath.trim()}
357
+ className="px-2 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed"
358
+ >
359
+ Add
360
+ </button>
361
+ </div>
362
+
363
+ {/* Grid of current attachments */}
364
+ {images.length > 0 && (
365
+ <div className="space-y-2">
366
+ <div className="text-xs text-muted-foreground">Current</div>
367
+ <div className="grid grid-cols-4 gap-2">
368
+ {images.map((img) => (
369
+ <div key={img.path} className="text-center">
370
+ <ImageThumbnail
371
+ path={img.path}
372
+ size="md"
373
+ onClick={() => {
374
+ setEditingImage({ path: img.path, name: img.name });
375
+ setIsOpen(false);
376
+ }}
377
+ onRemove={() => onRemove(img.path)}
378
+ showRemove
379
+ />
380
+ <div
381
+ className="text-[9px] text-muted-foreground truncate max-w-[3.5rem] mt-0.5 mx-auto"
382
+ title={img.name}
383
+ >
384
+ {img.name}
385
+ </div>
386
+ </div>
387
+ ))}
388
+ </div>
389
+ </div>
390
+ )}
391
+ </div>
392
+ </div>
393
+ </>,
394
+ document.body
395
+ )}
396
+
397
+ {/* Image Annotator Dialog - portaled to body for correct positioning */}
398
+ {annotatorOpen && createPortal(
399
+ <ImageAnnotator
400
+ isOpen={annotatorOpen}
401
+ imageSrc={annotatorSrc}
402
+ initialName={annotatorInitialName}
403
+ onAccept={handleAnnotatorAccept}
404
+ onClose={handleAnnotatorClose}
405
+ />,
406
+ document.body
407
+ )}
408
+ </>
409
+ );
410
+ };
@@ -0,0 +1,163 @@
1
+ import React from "react";
2
+ import { Block } from "../types";
3
+ import { InlineMarkdown } from "./InlineMarkdown";
4
+ import { ListItemBody } from "./ListItemBody";
5
+ import { CodeBlock } from "./blocks/CodeBlock";
6
+ import { HtmlBlock } from "./blocks/HtmlBlock";
7
+ import { Callout } from "./blocks/Callout";
8
+ import { AlertBlock } from "./blocks/AlertBlock";
9
+ import { TableBlock } from "./blocks/TableBlock";
10
+ import { MathBlock } from "./blocks/MathBlock";
11
+
12
+ export const BlockRenderer: React.FC<{
13
+ block: Block;
14
+ onOpenLinkedDoc?: (path: string) => void;
15
+ onOpenCodeFile?: (path: string) => void;
16
+ imageBaseDir?: string;
17
+ onImageClick?: (src: string, alt: string) => void;
18
+ onToggleCheckbox?: (blockId: string, checked: boolean) => void;
19
+ checkboxOverrides?: Map<string, boolean>;
20
+ orderedIndex?: number | null;
21
+ githubRepo?: string;
22
+ headingAnchorId?: string;
23
+ onNavigateAnchor?: (hash: string) => void;
24
+ }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId, onNavigateAnchor }) => {
25
+ switch (block.type) {
26
+ case 'heading': {
27
+ const Tag = `h${block.level || 1}` as React.ElementType;
28
+ const styles = {
29
+ 1: 'text-2xl font-bold mb-4 mt-6 first:mt-0 tracking-tight',
30
+ 2: 'text-xl font-semibold mb-3 mt-8 text-foreground/90',
31
+ 3: 'text-base font-semibold mb-2 mt-6 text-foreground/80',
32
+ }[block.level || 1] || 'text-base font-semibold mb-2 mt-4';
33
+ return (
34
+ <Tag
35
+ id={headingAnchorId}
36
+ className={styles}
37
+ data-block-id={block.id}
38
+ data-block-type="heading"
39
+ >
40
+ <InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
41
+ </Tag>
42
+ );
43
+ }
44
+
45
+ case 'blockquote': {
46
+ if (block.alertKind) {
47
+ return (
48
+ <AlertBlock
49
+ blockId={block.id}
50
+ kind={block.alertKind}
51
+ body={block.content}
52
+ onOpenLinkedDoc={onOpenLinkedDoc}
53
+ onOpenCodeFile={onOpenCodeFile}
54
+ imageBaseDir={imageBaseDir}
55
+ onImageClick={onImageClick}
56
+ githubRepo={githubRepo}
57
+ onNavigateAnchor={onNavigateAnchor}
58
+ />
59
+ );
60
+ }
61
+ // Content may span multiple merged `>` lines. Split on blank-line
62
+ // paragraph breaks so `> a\n>\n> b` renders as two <p> children.
63
+ const paragraphs = block.content.split(/\n\n+/);
64
+ return (
65
+ <blockquote
66
+ className="border-l-2 border-primary/50 pl-4 my-4 text-muted-foreground italic"
67
+ data-block-id={block.id}
68
+ >
69
+ {paragraphs.map((para, i) => (
70
+ <p key={i} className={i > 0 ? 'mt-2' : ''}>
71
+ <InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={para} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
72
+ </p>
73
+ ))}
74
+ </blockquote>
75
+ );
76
+ }
77
+
78
+ case 'list-item': {
79
+ const indent = (block.level || 0) * 1.25; // 1.25rem per level
80
+ const isCheckbox = block.checked !== undefined;
81
+ const isChecked = checkboxOverrides?.has(block.id)
82
+ ? checkboxOverrides.get(block.id)!
83
+ : block.checked;
84
+ const isInteractive = isCheckbox && !!onToggleCheckbox;
85
+ const textClass = `text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`;
86
+ const inlineProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, onNavigateAnchor };
87
+ return (
88
+ <div
89
+ className="flex items-start gap-3 my-1.5"
90
+ data-block-id={block.id}
91
+ style={{ marginLeft: `${indent}rem` }}
92
+ >
93
+ <ListItemBody
94
+ level={block.level || 0}
95
+ ordered={block.ordered}
96
+ orderedIndex={orderedIndex}
97
+ checked={isChecked}
98
+ interactive={isInteractive}
99
+ onToggle={isInteractive ? () => onToggleCheckbox!(block.id, !isChecked) : undefined}
100
+ textClassName={textClass}
101
+ content={block.content}
102
+ renderInline={(text) => <InlineMarkdown {...inlineProps} text={text} />}
103
+ />
104
+ </div>
105
+ );
106
+ }
107
+
108
+ case 'code':
109
+ return <CodeBlock block={block} onHover={() => {}} onLeave={() => {}} isHovered={false} />;
110
+
111
+ case 'table':
112
+ return (
113
+ <TableBlock
114
+ block={block}
115
+ imageBaseDir={imageBaseDir}
116
+ onImageClick={onImageClick}
117
+ onOpenLinkedDoc={onOpenLinkedDoc}
118
+ onOpenCodeFile={onOpenCodeFile}
119
+ githubRepo={githubRepo}
120
+ onNavigateAnchor={onNavigateAnchor}
121
+ />
122
+ );
123
+
124
+ case 'math':
125
+ return <MathBlock block={block} />;
126
+
127
+ case 'hr':
128
+ return <hr className="border-border/30 my-8" data-block-id={block.id} />;
129
+
130
+ case 'html':
131
+ return <HtmlBlock block={block} imageBaseDir={imageBaseDir} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} onNavigateAnchor={onNavigateAnchor} />;
132
+
133
+ case 'directive': {
134
+ const kind = block.directiveKind || 'note';
135
+ return (
136
+ <Callout
137
+ blockId={block.id}
138
+ kind={kind}
139
+ body={block.content}
140
+ containerClassName={`directive directive-${kind} my-4 px-4 py-3 rounded-md border`}
141
+ blockType="directive"
142
+ kindAttribute={kind}
143
+ onOpenLinkedDoc={onOpenLinkedDoc}
144
+ onOpenCodeFile={onOpenCodeFile}
145
+ imageBaseDir={imageBaseDir}
146
+ onImageClick={onImageClick}
147
+ githubRepo={githubRepo}
148
+ onNavigateAnchor={onNavigateAnchor}
149
+ />
150
+ );
151
+ }
152
+
153
+ default:
154
+ return (
155
+ <p
156
+ className="mb-4 leading-relaxed text-foreground/90 text-[15px]"
157
+ data-block-id={block.id}
158
+ >
159
+ <InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
160
+ </p>
161
+ );
162
+ }
163
+ };
@@ -0,0 +1,38 @@
1
+ import { motion, type Transition } from 'motion/react';
2
+
3
+ export interface BorderTrailProps {
4
+ className?: string;
5
+ size?: number;
6
+ transition?: Transition;
7
+ style?: React.CSSProperties;
8
+ }
9
+
10
+ export function BorderTrail({
11
+ className,
12
+ size = 60,
13
+ transition,
14
+ style,
15
+ }: BorderTrailProps) {
16
+ const defaultTransition: Transition = {
17
+ repeat: Infinity,
18
+ duration: 5,
19
+ ease: 'linear',
20
+ };
21
+
22
+ return (
23
+ <div className="pointer-events-none absolute inset-0 rounded-[inherit] border border-transparent [mask-clip:padding-box,border-box] [mask-composite:intersect] [mask-image:linear-gradient(transparent,transparent),linear-gradient(#000,#000)]">
24
+ <motion.div
25
+ className={['absolute aspect-square bg-zinc-500', className].filter(Boolean).join(' ')}
26
+ style={{
27
+ width: size,
28
+ offsetPath: `rect(0 auto auto 0 round ${size}px)`,
29
+ ...style,
30
+ }}
31
+ animate={{
32
+ offsetDistance: ['0%', '100%'],
33
+ }}
34
+ transition={transition ?? defaultTransition}
35
+ />
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,64 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
4
+
5
+ /**
6
+ * Floating popover anchored to a code-file link with multiple repo matches.
7
+ * Selecting an entry calls `onPick(path)`. Esc / outside-click dismisses.
8
+ */
9
+ export const CodeFilePicker: React.FC<{
10
+ anchorEl: HTMLElement | null;
11
+ matches: string[];
12
+ onPick: (path: string) => void;
13
+ onDismiss: () => void;
14
+ }> = ({ anchorEl, matches, onPick, onDismiss }) => {
15
+ const popoverRef = useRef<HTMLDivElement>(null);
16
+ const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
17
+
18
+ useEffect(() => {
19
+ if (!anchorEl) return;
20
+ const rect = anchorEl.getBoundingClientRect();
21
+ setPos({
22
+ top: rect.bottom + 4,
23
+ left: rect.left,
24
+ });
25
+ }, [anchorEl]);
26
+
27
+ useDismissOnOutsideAndEscape({
28
+ enabled: true,
29
+ ref: popoverRef as React.RefObject<HTMLElement>,
30
+ onDismiss,
31
+ });
32
+
33
+ if (!pos) return null;
34
+
35
+ return createPortal(
36
+ <div
37
+ ref={popoverRef}
38
+ role="menu"
39
+ aria-label="Choose file"
40
+ className="fixed z-[100] min-w-[240px] max-w-[480px] bg-popover text-popover-foreground border border-border/70 rounded-lg shadow-xl py-1"
41
+ style={{ top: pos.top, left: pos.left }}
42
+ >
43
+ <div className="px-3 py-1.5 text-xs uppercase tracking-wide opacity-60 border-b border-border/40">
44
+ {matches.length} matches
45
+ </div>
46
+ <ul className="max-h-72 overflow-y-auto">
47
+ {matches.map((m) => (
48
+ <li key={m}>
49
+ <button
50
+ type="button"
51
+ role="menuitem"
52
+ onClick={() => onPick(m)}
53
+ className="w-full text-left px-3 py-1.5 text-sm font-mono hover:bg-muted hover:text-primary transition-colors truncate"
54
+ title={m}
55
+ >
56
+ {m}
57
+ </button>
58
+ </li>
59
+ ))}
60
+ </ul>
61
+ </div>,
62
+ document.body,
63
+ );
64
+ };