@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,182 @@
1
+ import { useEffect, useState, useCallback, type RefObject } from 'react';
2
+ import type Highlighter from '@plannotator/web-highlighter';
3
+ import type { InputMethod } from '../types';
4
+ import { resolvePinpointTarget, type PinpointTarget } from '../utils/blockTargeting';
5
+
6
+ export interface UsePinpointOptions {
7
+ containerRef: RefObject<HTMLElement | null>;
8
+ highlighterRef: RefObject<Highlighter | null>;
9
+ inputMethod: InputMethod;
10
+ /** Disable when toolbar/popover/diff is active */
11
+ enabled: boolean;
12
+ /** Handle code block clicks (needs special annotation path) */
13
+ onCodeBlockClick: (blockId: string, element: HTMLElement) => void;
14
+ }
15
+
16
+ export interface UsePinpointReturn {
17
+ hoverTarget: { element: HTMLElement; label: string } | null;
18
+ }
19
+
20
+ export function usePinpoint({
21
+ containerRef,
22
+ highlighterRef,
23
+ inputMethod,
24
+ enabled,
25
+ onCodeBlockClick,
26
+ }: UsePinpointOptions): UsePinpointReturn {
27
+ const [hoverTarget, setHoverTarget] = useState<{ element: HTMLElement; label: string } | null>(null);
28
+
29
+ const isActive = inputMethod === 'pinpoint' && enabled;
30
+
31
+ // Clear hover when deactivated
32
+ useEffect(() => {
33
+ if (!isActive) {
34
+ setHoverTarget((prev) => {
35
+ if (prev) prev.element.removeAttribute('data-pinpoint-hover');
36
+ return null;
37
+ });
38
+ }
39
+ }, [isActive]);
40
+
41
+ // Mousemove / touchstart — resolve target and update hover state
42
+ useEffect(() => {
43
+ const container = containerRef.current;
44
+ if (!isActive || !container) return;
45
+
46
+ let prevElement: HTMLElement | null = null;
47
+
48
+ const updateHover = (clientX: number, clientY: number, target: HTMLElement) => {
49
+ const resolved = resolvePinpointTarget(target, container, { clientX, clientY });
50
+
51
+ if (resolved) {
52
+ if (resolved.element !== prevElement) {
53
+ prevElement?.removeAttribute('data-pinpoint-hover');
54
+ resolved.element.setAttribute('data-pinpoint-hover', '');
55
+ prevElement = resolved.element;
56
+ setHoverTarget({ element: resolved.element, label: resolved.label });
57
+ }
58
+ } else {
59
+ if (prevElement) {
60
+ prevElement.removeAttribute('data-pinpoint-hover');
61
+ prevElement = null;
62
+ setHoverTarget(null);
63
+ }
64
+ }
65
+ };
66
+
67
+ const handleMouseMove = (e: MouseEvent) => {
68
+ updateHover(e.clientX, e.clientY, e.target as HTMLElement);
69
+ };
70
+
71
+ const handleTouchStart = (e: TouchEvent) => {
72
+ if (e.touches.length !== 1) return;
73
+ const touch = e.touches[0];
74
+ const target = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement;
75
+ if (target) updateHover(touch.clientX, touch.clientY, target);
76
+ };
77
+
78
+ const handleMouseLeave = () => {
79
+ prevElement?.removeAttribute('data-pinpoint-hover');
80
+ prevElement = null;
81
+ setHoverTarget(null);
82
+ };
83
+
84
+ container.addEventListener('mousemove', handleMouseMove);
85
+ container.addEventListener('mouseleave', handleMouseLeave);
86
+ container.addEventListener('touchstart', handleTouchStart, { passive: true });
87
+
88
+ return () => {
89
+ prevElement?.removeAttribute('data-pinpoint-hover');
90
+ container.removeEventListener('mousemove', handleMouseMove);
91
+ container.removeEventListener('mouseleave', handleMouseLeave);
92
+ container.removeEventListener('touchstart', handleTouchStart);
93
+ };
94
+ }, [isActive, containerRef]);
95
+
96
+ // Click — create Range and trigger web-highlighter
97
+ useEffect(() => {
98
+ const container = containerRef.current;
99
+ if (!isActive || !container) return;
100
+
101
+ const handleClick = (e: MouseEvent) => {
102
+ // Read highlighter at click time, not effect setup time.
103
+ // On remount (e.g. after exiting plan diff), the highlighter init effect
104
+ // may not have run yet when this effect sets up, but it will be ready by
105
+ // the time the user clicks.
106
+ const highlighter = highlighterRef.current;
107
+ if (!highlighter) return;
108
+
109
+ const target = e.target as HTMLElement;
110
+ const resolved = resolvePinpointTarget(target, container, { clientX: e.clientX, clientY: e.clientY });
111
+ if (!resolved) return;
112
+
113
+ // Prevent link navigation in pinpoint mode
114
+ const link = (target.closest('a') as HTMLAnchorElement | null);
115
+ if (link && container.contains(link)) {
116
+ e.preventDefault();
117
+ }
118
+
119
+ // Clear hover state
120
+ resolved.element.removeAttribute('data-pinpoint-hover');
121
+ setHoverTarget(null);
122
+
123
+ if (resolved.isCodeBlock) {
124
+ // Route to existing code block annotation path
125
+ const codeBlockContainer = container.querySelector(`[data-block-id="${resolved.blockId}"]`) as HTMLElement;
126
+ if (codeBlockContainer) {
127
+ onCodeBlockClick(resolved.blockId, codeBlockContainer);
128
+ }
129
+ return;
130
+ }
131
+
132
+ // Create a text-level Range spanning the target element's content.
133
+ // web-highlighter needs ranges anchored to text nodes, not elements.
134
+ const range = createTextRange(resolved.element);
135
+ if (!range) return;
136
+
137
+ // Set browser selection so web-highlighter's mouseup handler picks it up
138
+ const sel = window.getSelection();
139
+ sel?.removeAllRanges();
140
+ sel?.addRange(range);
141
+
142
+ // Drive web-highlighter programmatically — fires CREATE event
143
+ highlighter.fromRange(range);
144
+
145
+ // Clean up browser selection
146
+ window.getSelection()?.removeAllRanges();
147
+ };
148
+
149
+ // Use capture phase so we get the click before links navigate
150
+ container.addEventListener('click', handleClick, true);
151
+
152
+ return () => {
153
+ container.removeEventListener('click', handleClick, true);
154
+ };
155
+ }, [isActive, containerRef, highlighterRef, onCodeBlockClick]);
156
+
157
+ return { hoverTarget };
158
+ }
159
+
160
+ /**
161
+ * Create a Range anchored to text nodes (not elements).
162
+ * web-highlighter's painter expects text-node-level ranges.
163
+ */
164
+ function createTextRange(element: HTMLElement): Range | null {
165
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null);
166
+
167
+ let firstNode: Text | null = null;
168
+ let lastNode: Text | null = null;
169
+
170
+ let node: Text | null;
171
+ while ((node = walker.nextNode() as Text | null)) {
172
+ if (!firstNode) firstNode = node;
173
+ lastNode = node;
174
+ }
175
+
176
+ if (!firstNode || !lastNode) return null;
177
+
178
+ const range = document.createRange();
179
+ range.setStart(firstNode, 0);
180
+ range.setEnd(lastNode, lastNode.length);
181
+ return range;
182
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Plan Diff Hook
3
+ *
4
+ * Manages plan diff state: version fetching, diff computation, and version browsing.
5
+ * Consumes the version history API endpoints.
6
+ */
7
+
8
+ import { useState, useMemo, useCallback, useEffect } from "react";
9
+ import {
10
+ computePlanDiff,
11
+ type PlanDiffBlock,
12
+ type PlanDiffStats,
13
+ } from "../utils/planDiffEngine";
14
+
15
+ export interface VersionInfo {
16
+ version: number;
17
+ totalVersions: number;
18
+ project: string;
19
+ }
20
+
21
+ export interface VersionEntry {
22
+ version: number;
23
+ timestamp: string;
24
+ }
25
+
26
+ export interface UsePlanDiffReturn {
27
+ /** The version we're comparing against */
28
+ diffBaseVersion: number | null;
29
+ /** Content of the base version */
30
+ diffBasePlan: string | null;
31
+ /** Computed diff blocks (null if no base plan to diff against) */
32
+ diffBlocks: PlanDiffBlock[] | null;
33
+ /** Computed diff stats (null if no diff) */
34
+ diffStats: PlanDiffStats | null;
35
+ /** Whether a previous version exists to diff against */
36
+ hasPreviousVersion: boolean;
37
+ /** Change the base version to diff against */
38
+ selectBaseVersion: (version: number) => Promise<void>;
39
+ /** All versions of the current plan */
40
+ versions: VersionEntry[];
41
+ /** Whether version list is loading */
42
+ isLoadingVersions: boolean;
43
+ /** Whether a version selection fetch is in progress */
44
+ isSelectingVersion: boolean;
45
+ /** Which version is currently being fetched (null if none) */
46
+ fetchingVersion: number | null;
47
+ /** Fetch the version list for the sidebar */
48
+ fetchVersions: () => Promise<void>;
49
+ }
50
+
51
+ export interface PlanDiffFetchers {
52
+ /** Fetch a specific version's plan content. Default → GET /api/plan/version?v=N */
53
+ fetchVersion?: (version: number) => Promise<{ plan: string; version: number }>;
54
+ /** Fetch the version list. Default → GET /api/plan/versions */
55
+ fetchVersions?: () => Promise<{
56
+ project: string;
57
+ slug: string;
58
+ versions: VersionEntry[];
59
+ }>;
60
+ }
61
+
62
+ const defaultFetchVersion = async (
63
+ version: number
64
+ ): Promise<{ plan: string; version: number }> => {
65
+ const res = await fetch(`/api/plan/version?v=${version}`);
66
+ if (!res.ok) {
67
+ throw new Error(`Failed to load version ${version}.`);
68
+ }
69
+ return (await res.json()) as { plan: string; version: number };
70
+ };
71
+
72
+ const defaultFetchVersions = async (): Promise<{
73
+ project: string;
74
+ slug: string;
75
+ versions: VersionEntry[];
76
+ }> => {
77
+ const res = await fetch("/api/plan/versions");
78
+ if (!res.ok) {
79
+ throw new Error("Failed to load versions.");
80
+ }
81
+ return (await res.json()) as {
82
+ project: string;
83
+ slug: string;
84
+ versions: VersionEntry[];
85
+ };
86
+ };
87
+
88
+ export function usePlanDiff(
89
+ currentPlan: string,
90
+ initialPreviousPlan: string | null,
91
+ versionInfo: VersionInfo | null,
92
+ fetchers?: PlanDiffFetchers
93
+ ): UsePlanDiffReturn {
94
+ const fetchVersionImpl = fetchers?.fetchVersion ?? defaultFetchVersion;
95
+ const fetchVersionsImpl = fetchers?.fetchVersions ?? defaultFetchVersions;
96
+ const [diffBasePlan, setDiffBasePlan] = useState<string | null>(
97
+ initialPreviousPlan
98
+ );
99
+ const [diffBaseVersion, setDiffBaseVersion] = useState<number | null>(
100
+ versionInfo && versionInfo.version > 1 ? versionInfo.version - 1 : null
101
+ );
102
+ const [versions, setVersions] = useState<VersionEntry[]>([]);
103
+ const [isLoadingVersions, setIsLoadingVersions] = useState(false);
104
+ const [isSelectingVersion, setIsSelectingVersion] = useState(false);
105
+ const [fetchingVersion, setFetchingVersion] = useState<number | null>(null);
106
+
107
+ // Sync diffBasePlan when initialPreviousPlan arrives after mount (API response)
108
+ useEffect(() => {
109
+ if (initialPreviousPlan && !diffBasePlan) {
110
+ setDiffBasePlan(initialPreviousPlan);
111
+ }
112
+ }, [initialPreviousPlan]);
113
+
114
+ // Sync diffBaseVersion when versionInfo arrives after mount
115
+ useEffect(() => {
116
+ if (versionInfo && versionInfo.version > 1 && diffBaseVersion === null) {
117
+ setDiffBaseVersion(versionInfo.version - 1);
118
+ }
119
+ }, [versionInfo]);
120
+
121
+ const hasPreviousVersion =
122
+ versionInfo !== null && versionInfo.totalVersions > 1 && diffBasePlan !== null;
123
+
124
+ // Compute diff whenever currentPlan or diffBasePlan changes
125
+ const diffResult = useMemo(() => {
126
+ if (!diffBasePlan) return null;
127
+ return computePlanDiff(diffBasePlan, currentPlan);
128
+ }, [currentPlan, diffBasePlan]);
129
+
130
+ const diffBlocks = diffResult?.blocks ?? null;
131
+ const diffStats = diffResult?.stats ?? null;
132
+
133
+ const selectBaseVersion = useCallback(
134
+ async (version: number) => {
135
+ setIsSelectingVersion(true);
136
+ setFetchingVersion(version);
137
+ try {
138
+ const data = await fetchVersionImpl(version);
139
+ setDiffBasePlan(data.plan);
140
+ setDiffBaseVersion(version);
141
+ } catch {
142
+ alert(`Failed to load version ${version}.`);
143
+ } finally {
144
+ setIsSelectingVersion(false);
145
+ setFetchingVersion(null);
146
+ }
147
+ },
148
+ [fetchVersionImpl]
149
+ );
150
+
151
+ const fetchVersions = useCallback(async () => {
152
+ setIsLoadingVersions(true);
153
+ try {
154
+ const data = await fetchVersionsImpl();
155
+ setVersions(data.versions);
156
+ } catch {
157
+ // Failed to fetch versions
158
+ } finally {
159
+ setIsLoadingVersions(false);
160
+ }
161
+ }, [fetchVersionsImpl]);
162
+
163
+ return {
164
+ diffBaseVersion,
165
+ diffBasePlan,
166
+ diffBlocks,
167
+ diffStats,
168
+ hasPreviousVersion,
169
+ selectBaseVersion,
170
+ versions,
171
+ isLoadingVersions,
172
+ isSelectingVersion,
173
+ fetchingVersion,
174
+ fetchVersions,
175
+ };
176
+ }
@@ -0,0 +1,27 @@
1
+ import { useEffect } from 'react';
2
+
3
+ /**
4
+ * Manages print mode by toggling 'plannotator-print' class on <html>.
5
+ * Includes visibilitychange fallback for Firefox, which may not fire
6
+ * afterprint when print preview is closed without printing.
7
+ */
8
+ export function usePrintMode() {
9
+ useEffect(() => {
10
+ const onBeforePrint = () => document.documentElement.classList.add('plannotator-print');
11
+ const onAfterPrint = () => document.documentElement.classList.remove('plannotator-print');
12
+ const onVisibilityChange = () => {
13
+ if (!document.hidden && document.documentElement.classList.contains('plannotator-print')) {
14
+ document.documentElement.classList.remove('plannotator-print');
15
+ }
16
+ };
17
+ window.addEventListener('beforeprint', onBeforePrint);
18
+ window.addEventListener('afterprint', onAfterPrint);
19
+ document.addEventListener('visibilitychange', onVisibilityChange);
20
+ return () => {
21
+ window.removeEventListener('beforeprint', onBeforePrint);
22
+ window.removeEventListener('afterprint', onAfterPrint);
23
+ document.removeEventListener('visibilitychange', onVisibilityChange);
24
+ document.documentElement.classList.remove('plannotator-print');
25
+ };
26
+ }, []);
27
+ }
@@ -0,0 +1,173 @@
1
+ import { useState, useRef, useCallback } from 'react';
2
+ import { storage } from '../utils/storage';
3
+
4
+ interface UseResizablePanelOptions {
5
+ storageKey: string;
6
+ defaultWidth?: number;
7
+ minWidth?: number;
8
+ maxWidth?: number;
9
+ side?: 'left' | 'right' | 'top' | 'bottom';
10
+ /** Drag axis: 'x' resizes width (default), 'y' resizes height (uses clientY). */
11
+ axis?: 'x' | 'y';
12
+ /**
13
+ * When provided, dragging the panel narrower than `snapCloseRatio * minWidth`
14
+ * snaps it shut (calls this) instead of clamping at minWidth.
15
+ */
16
+ onSnapClose?: () => void;
17
+ snapCloseRatio?: number;
18
+ /**
19
+ * Imperative live-apply. When provided, the drag drives the width through this
20
+ * callback ONCE PER FRAME and does NOT call setState — so the host component
21
+ * never re-renders mid-drag (buttery on heavy hosts). React state is committed
22
+ * once on release. When omitted, the width is driven through React state
23
+ * (still rAF-coalesced).
24
+ */
25
+ apply?: (width: number) => void;
26
+ }
27
+
28
+ export interface ResizeHandleProps {
29
+ isDragging: boolean;
30
+ onPointerDown: (e: React.PointerEvent) => void;
31
+ onDoubleClick: () => void;
32
+ /** touch-action: none so touch drags don't scroll-hijack. */
33
+ style: React.CSSProperties;
34
+ }
35
+
36
+ export function useResizablePanel({
37
+ storageKey,
38
+ defaultWidth = 288,
39
+ minWidth = 200,
40
+ maxWidth = 600,
41
+ side = 'right',
42
+ axis = 'x',
43
+ onSnapClose,
44
+ snapCloseRatio = 0.6,
45
+ apply,
46
+ }: UseResizablePanelOptions) {
47
+ const [width, setWidth] = useState(() => {
48
+ const saved = storage.getItem(storageKey);
49
+ if (saved) {
50
+ const n = Number(saved);
51
+ if (!Number.isNaN(n) && n >= minWidth && n <= maxWidth) return n;
52
+ }
53
+ return defaultWidth;
54
+ });
55
+
56
+ const [isDragging, setIsDragging] = useState(false);
57
+
58
+ // Live/committed width. Drag math reads/writes this without re-rendering.
59
+ const widthRef = useRef(width);
60
+ const startXRef = useRef(0);
61
+ const startWidthRef = useRef(0);
62
+ const draggingRef = useRef(false);
63
+ const snappedRef = useRef(false);
64
+ const latestXRef = useRef(0);
65
+ const rafRef = useRef<number | null>(null);
66
+
67
+ // Latest callbacks via refs so the rAF loop always sees fresh values.
68
+ const applyRef = useRef(apply);
69
+ applyRef.current = apply;
70
+ const onSnapCloseRef = useRef(onSnapClose);
71
+ onSnapCloseRef.current = onSnapClose;
72
+
73
+ // rAF tick: compute the width from the most recent pointer position and apply
74
+ // it. At most one DOM/state write per frame regardless of pointer-event rate.
75
+ const flush = useCallback(() => {
76
+ rafRef.current = null;
77
+ if (!draggingRef.current) return;
78
+ const delta =
79
+ side === 'right' || side === 'bottom'
80
+ ? startXRef.current - latestXRef.current
81
+ : latestXRef.current - startXRef.current;
82
+ const raw = startWidthRef.current + delta;
83
+
84
+ // Drag-to-snap-shut.
85
+ if (onSnapCloseRef.current && raw < minWidth * snapCloseRatio) {
86
+ snappedRef.current = true;
87
+ draggingRef.current = false;
88
+ widthRef.current = startWidthRef.current;
89
+ applyRef.current?.(startWidthRef.current);
90
+ storage.setItem(storageKey, String(startWidthRef.current));
91
+ setWidth(startWidthRef.current);
92
+ setIsDragging(false);
93
+ onSnapCloseRef.current();
94
+ return;
95
+ }
96
+
97
+ const w = Math.min(maxWidth, Math.max(minWidth, raw));
98
+ widthRef.current = w;
99
+ if (applyRef.current) applyRef.current(w);
100
+ else setWidth(w);
101
+ }, [side, minWidth, maxWidth, snapCloseRatio, storageKey]);
102
+
103
+ const onPointerDown = useCallback(
104
+ (e: React.PointerEvent) => {
105
+ // Only primary button / touch / pen.
106
+ if (e.button !== 0) return;
107
+ e.preventDefault();
108
+ const pos = axis === 'y' ? e.clientY : e.clientX;
109
+ startXRef.current = pos;
110
+ startWidthRef.current = widthRef.current;
111
+ latestXRef.current = pos;
112
+ snappedRef.current = false;
113
+ draggingRef.current = true;
114
+ setIsDragging(true);
115
+
116
+ // Native window listeners — fire for EVERY pointer move anywhere on screen,
117
+ // including past the window edge and faster than the cursor. This is the
118
+ // reliable path (React's synthetic onPointerMove on the tiny handle drops
119
+ // moves once the pointer leaves it).
120
+ const onMove = (ev: PointerEvent) => {
121
+ if (!draggingRef.current) return;
122
+ latestXRef.current = axis === 'y' ? ev.clientY : ev.clientX;
123
+ if (rafRef.current == null) rafRef.current = requestAnimationFrame(flush);
124
+ };
125
+ const cleanup = () => {
126
+ window.removeEventListener('pointermove', onMove);
127
+ window.removeEventListener('pointerup', onUp);
128
+ window.removeEventListener('pointercancel', onUp);
129
+ };
130
+ function onUp() {
131
+ const wasSnapped = snappedRef.current;
132
+ draggingRef.current = false;
133
+ snappedRef.current = false;
134
+ if (rafRef.current != null) {
135
+ cancelAnimationFrame(rafRef.current);
136
+ rafRef.current = null;
137
+ }
138
+ setIsDragging(false);
139
+ if (!wasSnapped) {
140
+ // Commit the live width to React state + persist.
141
+ setWidth(widthRef.current);
142
+ storage.setItem(storageKey, String(widthRef.current));
143
+ }
144
+ cleanup();
145
+ }
146
+
147
+ window.addEventListener('pointermove', onMove);
148
+ window.addEventListener('pointerup', onUp);
149
+ window.addEventListener('pointercancel', onUp);
150
+ },
151
+ [flush, storageKey, axis],
152
+ );
153
+
154
+ const resetWidth = useCallback(() => {
155
+ widthRef.current = defaultWidth;
156
+ applyRef.current?.(defaultWidth);
157
+ setWidth(defaultWidth);
158
+ storage.setItem(storageKey, String(defaultWidth));
159
+ }, [defaultWidth, storageKey]);
160
+
161
+ return {
162
+ width,
163
+ /** Alias for `width` — reads clearer when axis is 'y' (it's a height). */
164
+ size: width,
165
+ isDragging,
166
+ handleProps: {
167
+ isDragging,
168
+ onPointerDown,
169
+ onDoubleClick: resetWidth,
170
+ style: { touchAction: 'none' },
171
+ } as ResizeHandleProps,
172
+ };
173
+ }
@@ -0,0 +1,38 @@
1
+ import { createContext, useContext, createElement, type ReactNode } from 'react';
2
+
3
+ /**
4
+ * Provides the currently-active scroll viewport element to descendants.
5
+ *
6
+ * The element that actually scrolls is the host element rendered by
7
+ * <OverlayScrollArea> (native scroll) — not <main>. Any code that needs the
8
+ * scroll container (IntersectionObserver roots, scroll event listeners,
9
+ * scrollTo / getBoundingClientRect offsets) must consume this context instead
10
+ * of `document.querySelector('main')`.
11
+ *
12
+ * The value is `null` until the scroll element has mounted. Consumers should
13
+ * handle that transient state.
14
+ */
15
+ export const ScrollViewportContext = createContext<HTMLElement | null>(null);
16
+
17
+ /** Returns the active scroll viewport element, or `null` before it mounts. */
18
+ export function useScrollViewport(): HTMLElement | null {
19
+ return useContext(ScrollViewportContext);
20
+ }
21
+
22
+ /**
23
+ * Render-transparent provider for the active scroll viewport element.
24
+ *
25
+ * The host mounts this around its layout and feeds it the MAIN content's scroll
26
+ * element, so descendants — including a sidebar Table-of-Contents rendered
27
+ * inside it — resolve to the main viewport (not the sidebar's own scroll area).
28
+ * Ships with the package so consumers work without app-shell wiring.
29
+ */
30
+ export function ScrollViewportProvider({
31
+ viewport,
32
+ children,
33
+ }: {
34
+ viewport: HTMLElement | null;
35
+ children: ReactNode;
36
+ }) {
37
+ return createElement(ScrollViewportContext.Provider, { value: viewport }, children);
38
+ }