@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,1170 @@
1
+ /**
2
+ * useAnnotationHighlighter — annotation infrastructure for Viewer.
3
+ *
4
+ * Manages: web-highlighter lifecycle, toolbar/popover/quicklabel state,
5
+ * annotation creation, text-based restoration (drafts/shares), scroll-to-selected.
6
+ */
7
+
8
+ import { useEffect, useRef, useState, useCallback, type RefObject } from 'react';
9
+ import Highlighter from '@plannotator/web-highlighter';
10
+ import type { Annotation, EditorMode, ImageAttachment } from '../types';
11
+ import { AnnotationType } from '../types';
12
+ import type { QuickLabel } from '../utils/quickLabels';
13
+ import { getIdentity } from '../utils/identity';
14
+ import { transformPlainText } from '../utils/inlineTransforms';
15
+
16
+ // --- Exported state types ---
17
+
18
+ export interface ToolbarState {
19
+ element: HTMLElement;
20
+ source: any;
21
+ selectionText: string;
22
+ }
23
+
24
+ export interface CommentPopoverState {
25
+ anchorEl: HTMLElement;
26
+ contextText: string;
27
+ selectedText?: string;
28
+ initialText?: string;
29
+ source?: any;
30
+ }
31
+
32
+ export interface QuickLabelPickerState {
33
+ anchorEl: HTMLElement;
34
+ cursorHint?: { x: number; y: number };
35
+ source?: any;
36
+ }
37
+
38
+ type MathAnnotationSource = {
39
+ kind: 'math';
40
+ element: HTMLElement;
41
+ text: string;
42
+ blockId: string;
43
+ displayMode: boolean;
44
+ };
45
+
46
+ const isMathAnnotationSource = (source: any): source is MathAnnotationSource =>
47
+ source?.kind === 'math';
48
+
49
+ type MathAnnotationTarget = {
50
+ element: HTMLElement;
51
+ blockId: string;
52
+ tex: string;
53
+ displayMode: boolean;
54
+ };
55
+
56
+ const elementFromNode = (node: Node | null): HTMLElement | null => {
57
+ if (!node) return null;
58
+ if (node.nodeType === Node.ELEMENT_NODE) return node as HTMLElement;
59
+ return node.parentNode instanceof HTMLElement ? node.parentNode : null;
60
+ };
61
+
62
+ const closestMathElement = (node: Node | null, root: HTMLElement | null): HTMLElement | null => {
63
+ const element = elementFromNode(node);
64
+ const math = element?.closest<HTMLElement>('.math-annotatable[data-math-tex]');
65
+ if (!math || !root?.contains(math)) return null;
66
+ return math;
67
+ };
68
+
69
+ const mathElementFromSelection = (selection: Selection | null, root: HTMLElement | null): HTMLElement | null => {
70
+ if (!selection || selection.rangeCount === 0) return null;
71
+ return (
72
+ closestMathElement(selection.anchorNode, root) ||
73
+ closestMathElement(selection.focusNode, root) ||
74
+ closestMathElement(selection.getRangeAt(0).commonAncestorContainer, root)
75
+ );
76
+ };
77
+
78
+ const selectionContainsNode = (range: Range, node: Node): boolean => {
79
+ try {
80
+ return range.intersectsNode(node);
81
+ } catch {
82
+ return false;
83
+ }
84
+ };
85
+
86
+ const mathSourceFromElement = (element: HTMLElement): MathAnnotationSource | null => {
87
+ const text = element.dataset.mathTex;
88
+ if (!text) return null;
89
+
90
+ const block = element.closest<HTMLElement>('[data-block-id]');
91
+ const blockId = block?.dataset.blockId;
92
+ if (!blockId) return null;
93
+
94
+ return {
95
+ kind: 'math',
96
+ element,
97
+ text,
98
+ blockId,
99
+ displayMode: element.dataset.mathDisplay === 'true',
100
+ };
101
+ };
102
+
103
+ const mathTargetsFromSelection = (
104
+ selection: Selection | null,
105
+ root: HTMLElement | null,
106
+ ): MathAnnotationTarget[] => {
107
+ if (!selection || selection.rangeCount === 0 || !root) return [];
108
+
109
+ const seen = new Set<HTMLElement>();
110
+ const targets: MathAnnotationTarget[] = [];
111
+ const candidates = root.querySelectorAll<HTMLElement>('.math-annotatable[data-math-tex]');
112
+
113
+ for (let i = 0; i < selection.rangeCount; i += 1) {
114
+ const range = selection.getRangeAt(i);
115
+ candidates.forEach(element => {
116
+ if (seen.has(element) || !selectionContainsNode(range, element)) return;
117
+ const source = mathSourceFromElement(element);
118
+ if (!source) return;
119
+ seen.add(element);
120
+ targets.push({
121
+ element,
122
+ blockId: source.blockId,
123
+ tex: source.text,
124
+ displayMode: source.displayMode,
125
+ });
126
+ });
127
+ }
128
+
129
+ return targets;
130
+ };
131
+
132
+ const selectionHasNonMathContent = (
133
+ selection: Selection | null,
134
+ root: HTMLElement | null,
135
+ ): boolean => {
136
+ if (!selection || selection.rangeCount === 0 || !root) return false;
137
+
138
+ for (let i = 0; i < selection.rangeCount; i += 1) {
139
+ const range = selection.getRangeAt(i);
140
+ if (!root.contains(range.commonAncestorContainer)) continue;
141
+
142
+ // Whole selection sits inside a single formula (both endpoints within the
143
+ // rendered KaTeX): a pure-math selection, not mixed content.
144
+ if (closestMathElement(range.commonAncestorContainer, root)) continue;
145
+
146
+ // Otherwise clone the selection, drop any whole math elements it covers,
147
+ // and see if meaningful text is left. Leftover text = genuinely mixed
148
+ // (prose + formula) and should fall through to the normal text path.
149
+ // Nothing left = the selection only spans formulas — even when an endpoint
150
+ // spills into an adjacent text node at offset 0, which the browser does on
151
+ // nearly every drag-select of an inline formula.
152
+ const fragment = range.cloneContents();
153
+ fragment.querySelectorAll?.('.math-annotatable[data-math-tex]').forEach(element => {
154
+ element.remove();
155
+ });
156
+ if (fragment.textContent?.trim()) return true;
157
+ }
158
+
159
+ return false;
160
+ };
161
+
162
+ const annotationId = (): string => {
163
+ const cryptoRef = globalThis.crypto;
164
+ if (cryptoRef && typeof cryptoRef.randomUUID === 'function') {
165
+ return cryptoRef.randomUUID();
166
+ }
167
+ return `ann-${Date.now()}-${Math.random().toString(36).slice(2)}`;
168
+ };
169
+
170
+ const escapeAttrValue = (value: string): string => {
171
+ if (globalThis.CSS && typeof globalThis.CSS.escape === 'function') {
172
+ return globalThis.CSS.escape(value);
173
+ }
174
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
175
+ };
176
+
177
+ const applyMathAnnotationClass = (
178
+ element: HTMLElement,
179
+ id: string,
180
+ type: AnnotationType,
181
+ displayMode: boolean,
182
+ ) => {
183
+ element.classList.add(
184
+ 'annotation-highlight',
185
+ displayMode ? 'math-block-annotation' : 'math-inline-annotation',
186
+ );
187
+ element.classList.remove('deletion', 'comment');
188
+ if (type === AnnotationType.DELETION) {
189
+ element.classList.add('deletion');
190
+ } else if (type === AnnotationType.COMMENT) {
191
+ element.classList.add('comment');
192
+ }
193
+ element.dataset.bindId = id;
194
+ element.dataset.mathAnnotation = 'true';
195
+ };
196
+
197
+ const applyMathTargets = (
198
+ targets: MathAnnotationTarget[],
199
+ id: string,
200
+ type: AnnotationType,
201
+ ) => {
202
+ targets.forEach(target => {
203
+ applyMathAnnotationClass(target.element, id, type, target.displayMode);
204
+ });
205
+ };
206
+
207
+ const clearMathAnnotationClass = (element: Element) => {
208
+ element.classList.remove(
209
+ 'annotation-highlight',
210
+ 'math-block-annotation',
211
+ 'math-inline-annotation',
212
+ 'deletion',
213
+ 'comment',
214
+ 'focused',
215
+ );
216
+ delete (element as HTMLElement).dataset.bindId;
217
+ delete (element as HTMLElement).dataset.mathAnnotation;
218
+ };
219
+
220
+ // --- Hook options & return ---
221
+
222
+ export interface UseAnnotationHighlighterOptions {
223
+ containerRef: RefObject<HTMLElement | null>;
224
+ annotations: Annotation[];
225
+ onAddAnnotation?: (ann: Annotation) => void;
226
+ onSelectAnnotation?: (id: string | null) => void;
227
+ selectedAnnotationId: string | null;
228
+ mode: EditorMode;
229
+ enabled?: boolean;
230
+ }
231
+
232
+ export interface UseAnnotationHighlighterReturn {
233
+ highlighterRef: RefObject<Highlighter | null>;
234
+
235
+ toolbarState: ToolbarState | null;
236
+ commentPopover: CommentPopoverState | null;
237
+ quickLabelPicker: QuickLabelPickerState | null;
238
+
239
+ handleAnnotate: (type: AnnotationType) => void;
240
+ handleQuickLabel: (label: QuickLabel) => void;
241
+ handleToolbarClose: () => void;
242
+ handleRequestComment: (initialChar?: string) => void;
243
+ handleCommentSubmit: (text: string, images?: ImageAttachment[]) => void;
244
+ handleCommentClose: () => void;
245
+ handleFloatingQuickLabel: (label: QuickLabel) => void;
246
+ handleQuickLabelPickerDismiss: () => void;
247
+
248
+ removeHighlight: (id: string) => void;
249
+ clearAllHighlights: () => void;
250
+ applyAnnotations: (annotations: Annotation[]) => void;
251
+ }
252
+
253
+ export function useAnnotationHighlighter({
254
+ containerRef,
255
+ annotations,
256
+ onAddAnnotation,
257
+ onSelectAnnotation,
258
+ selectedAnnotationId,
259
+ mode,
260
+ enabled = true,
261
+ }: UseAnnotationHighlighterOptions): UseAnnotationHighlighterReturn {
262
+ const highlighterRef = useRef<Highlighter | null>(null);
263
+ const modeRef = useRef<EditorMode>(mode);
264
+ const onAddAnnotationRef = useRef(onAddAnnotation);
265
+ const onSelectAnnotationRef = useRef(onSelectAnnotation);
266
+ const pendingSourceRef = useRef<any>(null);
267
+ const pendingMathTargetsRef = useRef<MathAnnotationTarget[]>([]);
268
+ const pendingMathElementRef = useRef<HTMLElement | null>(null);
269
+ const justCreatedIdRef = useRef<string | null>(null);
270
+ const lastMousePosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
271
+ const mouseDownMathRef = useRef<HTMLElement | null>(null);
272
+
273
+ const [toolbarState, setToolbarState] = useState<ToolbarState | null>(null);
274
+ const [commentPopover, setCommentPopover] = useState<CommentPopoverState | null>(null);
275
+ const [quickLabelPicker, setQuickLabelPicker] = useState<QuickLabelPickerState | null>(null);
276
+
277
+ // Keep refs in sync
278
+ useEffect(() => { modeRef.current = mode; }, [mode]);
279
+ useEffect(() => { onAddAnnotationRef.current = onAddAnnotation; }, [onAddAnnotation]);
280
+ useEffect(() => { onSelectAnnotationRef.current = onSelectAnnotation; }, [onSelectAnnotation]);
281
+
282
+ const clearPendingSelection = useCallback(() => {
283
+ pendingSourceRef.current = null;
284
+ pendingMathTargetsRef.current = [];
285
+ // Strip the pre-submission preview highlight if the annotation was never
286
+ // finalized (createAnnotationFromMathSource nulls this ref once committed).
287
+ if (pendingMathElementRef.current) {
288
+ clearMathAnnotationClass(pendingMathElementRef.current);
289
+ pendingMathElementRef.current = null;
290
+ }
291
+ }, []);
292
+
293
+ // Paint a preview highlight on a formula the moment its toolbar/popover opens,
294
+ // so the target is visible before the user commits the annotation.
295
+ const showPendingMathPreview = useCallback((source: MathAnnotationSource) => {
296
+ applyMathAnnotationClass(source.element, 'pending-math', AnnotationType.COMMENT, source.displayMode);
297
+ pendingMathElementRef.current = source.element;
298
+ }, []);
299
+
300
+ // Track mouse position for quick label picker
301
+ useEffect(() => {
302
+ const track = (e: MouseEvent) => { lastMousePosRef.current = { x: e.clientX, y: e.clientY }; };
303
+ document.addEventListener('mouseup', track, true);
304
+ return () => document.removeEventListener('mouseup', track, true);
305
+ }, [clearPendingSelection]);
306
+
307
+ // --- Helpers ---
308
+
309
+ const findTextInDOM = useCallback((searchText: string): Range | null => {
310
+ if (!containerRef.current) return null;
311
+
312
+ // Search for an exact substring match inside the container's text tree.
313
+ // Falls back to a multi-text-node walk when the match spans siblings.
314
+ const searchOnce = (needle: string): Range | null => {
315
+ if (!needle || !containerRef.current) return null;
316
+
317
+ const rangeFromTextOffsets = (startIndex: number, endIndex: number): Range | null => {
318
+ const walker = document.createTreeWalker(
319
+ containerRef.current!,
320
+ NodeFilter.SHOW_TEXT,
321
+ null
322
+ );
323
+
324
+ let charCount = 0;
325
+ let startNode: Text | null = null;
326
+ let startOffset = 0;
327
+ let endNode: Text | null = null;
328
+ let endOffset = 0;
329
+ let node: Text | null;
330
+
331
+ while ((node = walker.nextNode() as Text | null)) {
332
+ const nodeLength = node.textContent?.length || 0;
333
+
334
+ if (!startNode && charCount + nodeLength > startIndex) {
335
+ startNode = node;
336
+ startOffset = startIndex - charCount;
337
+ }
338
+
339
+ if (startNode && charCount + nodeLength >= endIndex) {
340
+ endNode = node;
341
+ endOffset = endIndex - charCount;
342
+ break;
343
+ }
344
+
345
+ charCount += nodeLength;
346
+ }
347
+
348
+ if (startNode && endNode) {
349
+ const range = document.createRange();
350
+ range.setStart(startNode, startOffset);
351
+ range.setEnd(endNode, endOffset);
352
+ return range;
353
+ }
354
+
355
+ return null;
356
+ };
357
+
358
+ const normalizeWithMap = (text: string): { text: string; map: number[] } => {
359
+ let normalized = '';
360
+ const map: number[] = [];
361
+ let inWhitespace = false;
362
+
363
+ for (let i = 0; i < text.length; i++) {
364
+ const ch = text[i];
365
+ if (/\s/.test(ch)) {
366
+ if (!inWhitespace) {
367
+ normalized += ' ';
368
+ map.push(i);
369
+ inWhitespace = true;
370
+ }
371
+ } else {
372
+ normalized += ch;
373
+ map.push(i);
374
+ inWhitespace = false;
375
+ }
376
+ }
377
+
378
+ let start = 0;
379
+ let end = normalized.length;
380
+ while (start < end && normalized[start] === ' ') start++;
381
+ while (end > start && normalized[end - 1] === ' ') end--;
382
+
383
+ return {
384
+ text: normalized.slice(start, end),
385
+ map: map.slice(start, end),
386
+ };
387
+ };
388
+
389
+ const walker = document.createTreeWalker(
390
+ containerRef.current,
391
+ NodeFilter.SHOW_TEXT,
392
+ null
393
+ );
394
+
395
+ let node: Text | null;
396
+ while ((node = walker.nextNode() as Text | null)) {
397
+ const text = node.textContent || '';
398
+ const index = text.indexOf(needle);
399
+ if (index !== -1) {
400
+ const range = document.createRange();
401
+ range.setStart(node, index);
402
+ range.setEnd(node, index + needle.length);
403
+ return range;
404
+ }
405
+ }
406
+
407
+ const fullText = containerRef.current.textContent || '';
408
+ const searchIndex = fullText.indexOf(needle);
409
+ if (searchIndex !== -1) {
410
+ return rangeFromTextOffsets(searchIndex, searchIndex + needle.length);
411
+ }
412
+
413
+ const haystack = normalizeWithMap(fullText);
414
+ const normalizedNeedle = normalizeWithMap(needle).text;
415
+ const normalizedIndex = haystack.text.indexOf(normalizedNeedle);
416
+ if (normalizedNeedle && normalizedIndex !== -1) {
417
+ const originalStart = haystack.map[normalizedIndex];
418
+ const originalEnd = haystack.map[normalizedIndex + normalizedNeedle.length - 1] + 1;
419
+ return rangeFromTextOffsets(originalStart, originalEnd);
420
+ }
421
+
422
+ return null;
423
+ };
424
+
425
+ // First try the literal text. If that misses, re-try with the same
426
+ // transform the renderer applies to plain text (emoji shortcodes +
427
+ // smart punctuation) so annotations made before those transforms
428
+ // shipped can still re-bind to their target after reload.
429
+ const direct = searchOnce(searchText);
430
+ if (direct) return direct;
431
+
432
+ const transformed = transformPlainText(searchText);
433
+ if (transformed !== searchText) {
434
+ return searchOnce(transformed);
435
+ }
436
+
437
+ return null;
438
+ }, []);
439
+
440
+ const createAnnotationFromSource = (
441
+ highlighter: Highlighter,
442
+ source: any,
443
+ type: AnnotationType,
444
+ text?: string,
445
+ images?: ImageAttachment[],
446
+ isQuickLabel?: boolean,
447
+ quickLabelTip?: string,
448
+ ) => {
449
+ const doms = highlighter.getDoms(source.id);
450
+ let blockId = '';
451
+ let startOffset = 0;
452
+
453
+ if (doms?.length > 0) {
454
+ const el = doms[0] as HTMLElement;
455
+ let parent = el.parentElement;
456
+ while (parent && !parent.dataset.blockId) {
457
+ parent = parent.parentElement;
458
+ }
459
+ if (parent?.dataset.blockId) {
460
+ blockId = parent.dataset.blockId;
461
+ const blockText = parent.textContent || '';
462
+ const beforeText = blockText.split(source.text)[0];
463
+ startOffset = beforeText?.length || 0;
464
+ }
465
+ }
466
+
467
+ const mathTargets = pendingMathTargetsRef.current;
468
+
469
+ const newAnnotation: Annotation = {
470
+ id: source.id,
471
+ blockId,
472
+ startOffset,
473
+ endOffset: startOffset + source.text.length,
474
+ type,
475
+ text,
476
+ originalText: source.text,
477
+ createdA: Date.now(),
478
+ author: getIdentity(),
479
+ startMeta: source.startMeta,
480
+ endMeta: source.endMeta,
481
+ images,
482
+ ...(mathTargets.length > 0 ? {
483
+ mathTargets: mathTargets.map(target => ({
484
+ blockId: target.blockId,
485
+ tex: target.tex,
486
+ displayMode: target.displayMode,
487
+ })),
488
+ } : {}),
489
+ ...(isQuickLabel ? { isQuickLabel: true } : {}),
490
+ ...(quickLabelTip ? { quickLabelTip } : {}),
491
+ };
492
+
493
+ if (type === AnnotationType.DELETION) {
494
+ highlighter.addClass('deletion', source.id);
495
+ } else if (type === AnnotationType.COMMENT) {
496
+ highlighter.addClass('comment', source.id);
497
+ }
498
+ applyMathTargets(mathTargets, source.id, type);
499
+
500
+ justCreatedIdRef.current = newAnnotation.id;
501
+ onAddAnnotationRef.current?.(newAnnotation);
502
+ };
503
+
504
+ const createAnnotationFromMathSource = (
505
+ source: MathAnnotationSource,
506
+ type: AnnotationType,
507
+ text?: string,
508
+ images?: ImageAttachment[],
509
+ isQuickLabel?: boolean,
510
+ quickLabelTip?: string,
511
+ ) => {
512
+ const id = annotationId();
513
+ applyMathAnnotationClass(source.element, id, type, source.displayMode);
514
+ // Committed — hand ownership of the highlight to the annotation so a later
515
+ // clearPendingSelection() doesn't strip it.
516
+ pendingMathElementRef.current = null;
517
+
518
+ const newAnnotation: Annotation = {
519
+ id,
520
+ blockId: source.blockId,
521
+ startOffset: 0,
522
+ endOffset: source.text.length,
523
+ type,
524
+ text,
525
+ originalText: source.text,
526
+ createdA: Date.now(),
527
+ author: getIdentity(),
528
+ images,
529
+ ...(isQuickLabel ? { isQuickLabel: true } : {}),
530
+ ...(quickLabelTip ? { quickLabelTip } : {}),
531
+ };
532
+
533
+ justCreatedIdRef.current = newAnnotation.id;
534
+ onAddAnnotationRef.current?.(newAnnotation);
535
+ };
536
+
537
+ const findMathElementForAnnotation = useCallback((ann: Annotation): HTMLElement | null => {
538
+ if (!containerRef.current || !ann.blockId || !ann.originalText) return null;
539
+
540
+ const block = containerRef.current.querySelector<HTMLElement>(
541
+ `[data-block-id="${escapeAttrValue(ann.blockId)}"]`
542
+ );
543
+ if (!block) return null;
544
+
545
+ if (
546
+ block.matches('.math-annotatable[data-math-tex]') &&
547
+ block.dataset.mathTex === ann.originalText
548
+ ) {
549
+ return block;
550
+ }
551
+
552
+ const candidates = block.querySelectorAll<HTMLElement>('.math-annotatable[data-math-tex]');
553
+ for (const candidate of Array.from(candidates)) {
554
+ if (candidate.dataset.mathTex === ann.originalText) return candidate;
555
+ }
556
+
557
+ return null;
558
+ }, []);
559
+
560
+ const findMathElementsForAnnotation = useCallback((ann: Annotation): MathAnnotationTarget[] => {
561
+ if (!containerRef.current) return [];
562
+
563
+ if (ann.mathTargets?.length) {
564
+ const targets: MathAnnotationTarget[] = [];
565
+ const seen = new Set<HTMLElement>();
566
+
567
+ ann.mathTargets.forEach(target => {
568
+ const block = containerRef.current?.querySelector<HTMLElement>(
569
+ `[data-block-id="${escapeAttrValue(target.blockId)}"]`
570
+ );
571
+ if (!block) return;
572
+
573
+ const candidates = [
574
+ ...(block.matches('.math-annotatable[data-math-tex]') ? [block] : []),
575
+ ...Array.from(block.querySelectorAll<HTMLElement>('.math-annotatable[data-math-tex]')),
576
+ ];
577
+ const element = candidates.find(candidate => candidate.dataset.mathTex === target.tex);
578
+ if (!element || seen.has(element)) return;
579
+
580
+ seen.add(element);
581
+ targets.push({
582
+ element,
583
+ blockId: target.blockId,
584
+ tex: target.tex,
585
+ displayMode: target.displayMode,
586
+ });
587
+ });
588
+
589
+ return targets;
590
+ }
591
+
592
+ const mathElement = findMathElementForAnnotation(ann);
593
+ if (!mathElement) return [];
594
+
595
+ return [{
596
+ element: mathElement,
597
+ blockId: ann.blockId,
598
+ tex: mathElement.dataset.mathTex ?? ann.originalText,
599
+ displayMode: mathElement.dataset.mathDisplay === 'true',
600
+ }];
601
+ }, [findMathElementForAnnotation]);
602
+
603
+ // --- Imperative methods ---
604
+
605
+ const applyAnnotationsInternal = useCallback((anns: Annotation[]) => {
606
+ const highlighter = highlighterRef.current;
607
+ if (!highlighter || !containerRef.current) return;
608
+
609
+ anns.forEach(ann => {
610
+ if (ann.type === AnnotationType.GLOBAL_COMMENT) return;
611
+
612
+ // Skip if already highlighted
613
+ try {
614
+ const existingDoms = highlighter.getDoms(ann.id);
615
+ if (existingDoms && existingDoms.length > 0) return;
616
+ } catch {}
617
+ const existingManual = containerRef.current?.querySelector(`[data-bind-id="${ann.id}"]`);
618
+ if (existingManual) return;
619
+
620
+ const mathTargets = findMathElementsForAnnotation(ann);
621
+ if (mathTargets.length > 0) {
622
+ applyMathTargets(mathTargets, ann.id, ann.type);
623
+ if (!ann.startMeta && !ann.endMeta && !ann.mathTargets?.length) {
624
+ return;
625
+ }
626
+ }
627
+
628
+ if (ann.startMeta && ann.endMeta) {
629
+ try {
630
+ highlighter.fromStore(ann.startMeta, ann.endMeta, ann.originalText, ann.id);
631
+ const restoredDoms = highlighter.getDoms(ann.id);
632
+ if (restoredDoms && restoredDoms.length > 0) {
633
+ if (ann.type === AnnotationType.DELETION) {
634
+ highlighter.addClass('deletion', ann.id);
635
+ } else if (ann.type === AnnotationType.COMMENT) {
636
+ highlighter.addClass('comment', ann.id);
637
+ }
638
+ return;
639
+ }
640
+ } catch {}
641
+ }
642
+
643
+ const range = findTextInDOM(ann.originalText);
644
+ if (!range) {
645
+ console.warn(`Could not find text for annotation ${ann.id}: "${ann.originalText.slice(0, 50)}..."`);
646
+ return;
647
+ }
648
+
649
+ try {
650
+ const textNodes: { node: Text; start: number; end: number }[] = [];
651
+ const walker = document.createTreeWalker(
652
+ range.commonAncestorContainer.nodeType === Node.TEXT_NODE
653
+ ? range.commonAncestorContainer.parentNode!
654
+ : range.commonAncestorContainer,
655
+ NodeFilter.SHOW_TEXT,
656
+ null
657
+ );
658
+
659
+ let node: Text | null;
660
+ let inRange = false;
661
+
662
+ while ((node = walker.nextNode() as Text | null)) {
663
+ if (node === range.startContainer) {
664
+ inRange = true;
665
+ const start = range.startOffset;
666
+ const end = node === range.endContainer ? range.endOffset : node.length;
667
+ if (end > start) {
668
+ textNodes.push({ node, start, end });
669
+ }
670
+ if (node === range.endContainer) break;
671
+ continue;
672
+ }
673
+
674
+ if (node === range.endContainer) {
675
+ if (inRange) {
676
+ const end = range.endOffset;
677
+ if (end > 0) {
678
+ textNodes.push({ node, start: 0, end });
679
+ }
680
+ }
681
+ break;
682
+ }
683
+
684
+ if (inRange && node.length > 0) {
685
+ textNodes.push({ node, start: 0, end: node.length });
686
+ }
687
+ }
688
+
689
+ if (textNodes.length === 0) {
690
+ console.warn(`No text nodes found for annotation ${ann.id}`);
691
+ return;
692
+ }
693
+
694
+ textNodes.reverse().forEach(({ node, start, end }) => {
695
+ try {
696
+ const nodeRange = document.createRange();
697
+ nodeRange.setStart(node, start);
698
+ nodeRange.setEnd(node, end);
699
+
700
+ const mark = document.createElement('mark');
701
+ mark.className = 'annotation-highlight';
702
+ mark.dataset.bindId = ann.id;
703
+
704
+ if (ann.type === AnnotationType.DELETION) {
705
+ mark.classList.add('deletion');
706
+ } else if (ann.type === AnnotationType.COMMENT) {
707
+ mark.classList.add('comment');
708
+ }
709
+
710
+ nodeRange.surroundContents(mark);
711
+
712
+ mark.addEventListener('click', () => {
713
+ onSelectAnnotationRef.current?.(ann.id);
714
+ });
715
+ } catch (e) {
716
+ console.warn(`Failed to wrap text node for annotation ${ann.id}:`, e);
717
+ }
718
+ });
719
+ } catch (e) {
720
+ console.warn(`Failed to apply highlight for annotation ${ann.id}:`, e);
721
+ }
722
+ });
723
+ }, [findMathElementsForAnnotation, findTextInDOM]);
724
+
725
+ const removeHighlight = useCallback((id: string) => {
726
+ highlighterRef.current?.remove(id);
727
+
728
+ const mathHighlights = containerRef.current?.querySelectorAll(
729
+ `[data-bind-id="${id}"][data-math-annotation="true"]`
730
+ );
731
+ mathHighlights?.forEach(clearMathAnnotationClass);
732
+
733
+ const manualHighlights = containerRef.current?.querySelectorAll(`[data-bind-id="${id}"]`);
734
+ manualHighlights?.forEach(el => {
735
+ if ((el as HTMLElement).dataset.mathAnnotation === 'true') return;
736
+ const parent = el.parentNode;
737
+ while (el.firstChild) {
738
+ parent?.insertBefore(el.firstChild, el);
739
+ }
740
+ el.remove();
741
+ });
742
+ }, []);
743
+
744
+ const clearAllHighlights = useCallback(() => {
745
+ const mathHighlights = containerRef.current?.querySelectorAll('[data-math-annotation="true"]');
746
+ mathHighlights?.forEach(clearMathAnnotationClass);
747
+
748
+ const manualHighlights = containerRef.current?.querySelectorAll('[data-bind-id]');
749
+ manualHighlights?.forEach(el => {
750
+ if ((el as HTMLElement).dataset.mathAnnotation === 'true') return;
751
+ const parent = el.parentNode;
752
+ while (el.firstChild) {
753
+ parent?.insertBefore(el.firstChild, el);
754
+ }
755
+ el.remove();
756
+ });
757
+
758
+ const webHighlights = containerRef.current?.querySelectorAll('.annotation-highlight');
759
+ webHighlights?.forEach(el => {
760
+ if ((el as HTMLElement).dataset.mathAnnotation === 'true') return;
761
+ const parent = el.parentNode;
762
+ while (el.firstChild) {
763
+ parent?.insertBefore(el.firstChild, el);
764
+ }
765
+ el.remove();
766
+ });
767
+ }, []);
768
+
769
+ // --- Effects ---
770
+
771
+ // Initialize web-highlighter (no callback deps — reads from refs)
772
+ useEffect(() => {
773
+ if (!containerRef.current || !enabled) return;
774
+
775
+ const highlighter = new Highlighter({
776
+ $root: containerRef.current,
777
+ exceptSelectors: ['.annotation-toolbar', 'button', '.math-annotatable', '.katex'],
778
+ wrapTag: 'mark',
779
+ style: { className: 'annotation-highlight' },
780
+ });
781
+
782
+ highlighterRef.current = highlighter;
783
+
784
+ highlighter.on(Highlighter.event.CREATE, ({ sources, type }: { sources: any[]; type?: string }) => {
785
+ if (type === 'from-store') return;
786
+ if (sources.length > 0) {
787
+ const source = sources[0];
788
+ const doms = highlighter.getDoms(source.id);
789
+ if (doms?.length > 0) {
790
+ // Clean up previous pending
791
+ if (pendingSourceRef.current) {
792
+ highlighter.remove(pendingSourceRef.current.id);
793
+ }
794
+ clearPendingSelection();
795
+ pendingMathTargetsRef.current = mathTargetsFromSelection(window.getSelection(), containerRef.current);
796
+ setCommentPopover(null);
797
+ setQuickLabelPicker(null);
798
+
799
+ if (modeRef.current === 'redline') {
800
+ createAnnotationFromSource(highlighter, source, AnnotationType.DELETION);
801
+ window.getSelection()?.removeAllRanges();
802
+ } else if (modeRef.current === 'comment') {
803
+ pendingSourceRef.current = source;
804
+ setCommentPopover({
805
+ anchorEl: doms[0] as HTMLElement,
806
+ contextText: source.text.slice(0, 80),
807
+ selectedText: source.text,
808
+ source,
809
+ });
810
+ } else if (modeRef.current === 'quickLabel') {
811
+ pendingSourceRef.current = source;
812
+ setQuickLabelPicker({
813
+ anchorEl: doms[0] as HTMLElement,
814
+ cursorHint: lastMousePosRef.current,
815
+ source,
816
+ });
817
+ } else {
818
+ // Selection mode — show toolbar
819
+ pendingSourceRef.current = source;
820
+ setToolbarState({
821
+ element: doms[0] as HTMLElement,
822
+ source,
823
+ selectionText: source.text,
824
+ });
825
+ }
826
+ }
827
+ }
828
+ });
829
+
830
+ highlighter.on(Highlighter.event.CLICK, ({ id }: { id: string }) => {
831
+ onSelectAnnotationRef.current?.(id);
832
+ });
833
+
834
+ highlighter.run();
835
+
836
+ const handleMathMouseDown = (event: MouseEvent) => {
837
+ mouseDownMathRef.current = closestMathElement(event.target as Node, containerRef.current);
838
+ if (mouseDownMathRef.current) {
839
+ event.stopPropagation();
840
+ }
841
+ };
842
+
843
+ const handleMathMouseUp = (event: MouseEvent) => {
844
+ if (!containerRef.current) return;
845
+
846
+ const selection = window.getSelection();
847
+ const mathElement =
848
+ closestMathElement(event.target as Node, containerRef.current) ||
849
+ mouseDownMathRef.current ||
850
+ mathElementFromSelection(selection, containerRef.current);
851
+ mouseDownMathRef.current = null;
852
+
853
+ if (!mathElement) return;
854
+
855
+ const existingId = mathElement.dataset.bindId;
856
+ const selectedText = selection?.toString().trim() ?? '';
857
+ if (!selectedText && existingId && modeRef.current !== 'redline') {
858
+ event.preventDefault();
859
+ event.stopPropagation();
860
+ onSelectAnnotationRef.current?.(existingId);
861
+ return;
862
+ }
863
+ if (selectionHasNonMathContent(selection, containerRef.current)) {
864
+ return;
865
+ }
866
+ event.preventDefault();
867
+ event.stopPropagation();
868
+
869
+ const source = mathSourceFromElement(mathElement);
870
+ if (!source) return;
871
+
872
+ if (pendingSourceRef.current && !isMathAnnotationSource(pendingSourceRef.current)) {
873
+ highlighter.remove(pendingSourceRef.current.id);
874
+ }
875
+ clearPendingSelection();
876
+ setToolbarState(null);
877
+ setCommentPopover(null);
878
+ setQuickLabelPicker(null);
879
+
880
+ if (modeRef.current === 'redline') {
881
+ createAnnotationFromMathSource(source, AnnotationType.DELETION);
882
+ selection?.removeAllRanges();
883
+ return;
884
+ }
885
+
886
+ if (modeRef.current === 'comment') {
887
+ pendingSourceRef.current = source;
888
+ showPendingMathPreview(source);
889
+ setCommentPopover({
890
+ anchorEl: source.element,
891
+ contextText: source.text.slice(0, 80),
892
+ selectedText: source.text,
893
+ source,
894
+ });
895
+ return;
896
+ }
897
+
898
+ if (modeRef.current === 'quickLabel') {
899
+ pendingSourceRef.current = source;
900
+ showPendingMathPreview(source);
901
+ setQuickLabelPicker({
902
+ anchorEl: source.element,
903
+ cursorHint: lastMousePosRef.current,
904
+ source,
905
+ });
906
+ return;
907
+ }
908
+
909
+ pendingSourceRef.current = source;
910
+ showPendingMathPreview(source);
911
+ setToolbarState({
912
+ element: source.element,
913
+ source,
914
+ selectionText: source.text,
915
+ });
916
+ };
917
+
918
+ containerRef.current.addEventListener('mousedown', handleMathMouseDown, true);
919
+ containerRef.current.addEventListener('mouseup', handleMathMouseUp, true);
920
+
921
+ // Mobile bridge
922
+ const isTouchPrimary = window.matchMedia('(pointer: coarse)').matches;
923
+ let selectionTimer: ReturnType<typeof setTimeout>;
924
+ const handleSelectionChange = isTouchPrimary
925
+ ? () => {
926
+ clearTimeout(selectionTimer);
927
+ selectionTimer = setTimeout(() => {
928
+ const sel = window.getSelection();
929
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
930
+ if (!containerRef.current?.contains(sel.anchorNode)) return;
931
+ highlighter.fromRange(sel.getRangeAt(0));
932
+ }, 400);
933
+ }
934
+ : null;
935
+
936
+ if (handleSelectionChange) {
937
+ document.addEventListener('selectionchange', handleSelectionChange);
938
+ }
939
+
940
+ return () => {
941
+ if (handleSelectionChange) {
942
+ clearTimeout(selectionTimer);
943
+ document.removeEventListener('selectionchange', handleSelectionChange);
944
+ }
945
+ containerRef.current?.removeEventListener('mousedown', handleMathMouseDown, true);
946
+ containerRef.current?.removeEventListener('mouseup', handleMathMouseUp, true);
947
+ highlighter.dispose();
948
+ };
949
+ }, [clearPendingSelection, enabled]);
950
+
951
+ // Apply CSS classes to existing annotations
952
+ useEffect(() => {
953
+ const highlighter = highlighterRef.current;
954
+ if (!highlighter) return;
955
+
956
+ annotations.forEach(ann => {
957
+ try {
958
+ const doms = highlighter.getDoms(ann.id);
959
+ if (doms && doms.length > 0) {
960
+ if (ann.type === AnnotationType.DELETION) {
961
+ highlighter.addClass('deletion', ann.id);
962
+ } else if (ann.type === AnnotationType.COMMENT) {
963
+ highlighter.addClass('comment', ann.id);
964
+ }
965
+ }
966
+ } catch {}
967
+ });
968
+ }, [annotations]);
969
+
970
+ // Scroll to selected annotation
971
+ useEffect(() => {
972
+ if (!containerRef.current) return;
973
+
974
+ // Clear all previously focused highlights
975
+ containerRef.current.querySelectorAll('.annotation-highlight.focused').forEach(el => {
976
+ el.classList.remove('focused');
977
+ });
978
+
979
+ if (!selectedAnnotationId) return;
980
+
981
+ // Skip scroll if we just created this annotation
982
+ if (justCreatedIdRef.current === selectedAnnotationId) {
983
+ justCreatedIdRef.current = null;
984
+ return;
985
+ }
986
+
987
+ const highlighter = highlighterRef.current;
988
+ let targetElements: Element[] = [];
989
+
990
+ if (highlighter) {
991
+ try {
992
+ const doms = highlighter.getDoms(selectedAnnotationId);
993
+ if (doms && doms.length > 0) targetElements = Array.from(doms);
994
+ } catch {}
995
+ }
996
+
997
+ if (targetElements.length === 0) {
998
+ const manualMarks = containerRef.current.querySelectorAll(
999
+ `[data-bind-id="${selectedAnnotationId}"]`
1000
+ );
1001
+ if (manualMarks.length > 0) targetElements = Array.from(manualMarks);
1002
+ }
1003
+
1004
+ if (targetElements.length === 0) return;
1005
+
1006
+ targetElements.forEach(el => el.classList.add('focused'));
1007
+ targetElements[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
1008
+
1009
+ const timer = setTimeout(() => {
1010
+ targetElements.forEach(el => el.classList.remove('focused'));
1011
+ }, 2000);
1012
+ return () => clearTimeout(timer);
1013
+ }, [selectedAnnotationId]);
1014
+
1015
+ // --- Handlers ---
1016
+
1017
+ const handleAnnotate = (type: AnnotationType) => {
1018
+ const highlighter = highlighterRef.current;
1019
+ if (!toolbarState) return;
1020
+ if (isMathAnnotationSource(toolbarState.source)) {
1021
+ createAnnotationFromMathSource(toolbarState.source, type);
1022
+ clearPendingSelection();
1023
+ setToolbarState(null);
1024
+ window.getSelection()?.removeAllRanges();
1025
+ return;
1026
+ }
1027
+ if (!highlighter) return;
1028
+ createAnnotationFromSource(highlighter, toolbarState.source, type);
1029
+ clearPendingSelection();
1030
+ setToolbarState(null);
1031
+ window.getSelection()?.removeAllRanges();
1032
+ };
1033
+
1034
+ const handleQuickLabel = (label: QuickLabel) => {
1035
+ const highlighter = highlighterRef.current;
1036
+ if (!toolbarState) return;
1037
+ if (isMathAnnotationSource(toolbarState.source)) {
1038
+ createAnnotationFromMathSource(
1039
+ toolbarState.source, AnnotationType.COMMENT,
1040
+ `${label.emoji} ${label.text}`, undefined, true, label.tip
1041
+ );
1042
+ clearPendingSelection();
1043
+ setToolbarState(null);
1044
+ window.getSelection()?.removeAllRanges();
1045
+ return;
1046
+ }
1047
+ if (!highlighter) return;
1048
+ createAnnotationFromSource(
1049
+ highlighter, toolbarState.source, AnnotationType.COMMENT,
1050
+ `${label.emoji} ${label.text}`, undefined, true, label.tip
1051
+ );
1052
+ clearPendingSelection();
1053
+ setToolbarState(null);
1054
+ window.getSelection()?.removeAllRanges();
1055
+ };
1056
+
1057
+ const handleToolbarClose = () => {
1058
+ if (toolbarState && highlighterRef.current && !isMathAnnotationSource(toolbarState.source)) {
1059
+ highlighterRef.current.remove(toolbarState.source.id);
1060
+ }
1061
+ clearPendingSelection();
1062
+ setToolbarState(null);
1063
+ window.getSelection()?.removeAllRanges();
1064
+ };
1065
+
1066
+ const handleRequestComment = (initialChar?: string) => {
1067
+ if (!toolbarState) return;
1068
+ setCommentPopover({
1069
+ anchorEl: toolbarState.element,
1070
+ contextText: toolbarState.selectionText.slice(0, 80),
1071
+ selectedText: toolbarState.selectionText,
1072
+ initialText: initialChar,
1073
+ source: toolbarState.source,
1074
+ });
1075
+ setToolbarState(null);
1076
+ };
1077
+
1078
+ const handleCommentSubmit = (text: string, images?: ImageAttachment[]) => {
1079
+ if (!commentPopover) return;
1080
+ if (isMathAnnotationSource(commentPopover.source)) {
1081
+ createAnnotationFromMathSource(
1082
+ commentPopover.source,
1083
+ AnnotationType.COMMENT,
1084
+ text,
1085
+ images,
1086
+ );
1087
+ clearPendingSelection();
1088
+ window.getSelection()?.removeAllRanges();
1089
+ setCommentPopover(null);
1090
+ return;
1091
+ }
1092
+ if (commentPopover.source && highlighterRef.current) {
1093
+ createAnnotationFromSource(
1094
+ highlighterRef.current, commentPopover.source,
1095
+ AnnotationType.COMMENT, text, images
1096
+ );
1097
+ clearPendingSelection();
1098
+ window.getSelection()?.removeAllRanges();
1099
+ }
1100
+ setCommentPopover(null);
1101
+ };
1102
+
1103
+ const handleCommentClose = useCallback(() => {
1104
+ setCommentPopover(prev => {
1105
+ if (prev?.source && highlighterRef.current && !isMathAnnotationSource(prev.source)) {
1106
+ highlighterRef.current.remove(prev.source.id);
1107
+ }
1108
+ return null;
1109
+ });
1110
+ clearPendingSelection();
1111
+ window.getSelection()?.removeAllRanges();
1112
+ }, [clearPendingSelection]);
1113
+
1114
+ const handleFloatingQuickLabel = useCallback((label: QuickLabel) => {
1115
+ if (!quickLabelPicker?.source) return;
1116
+ if (isMathAnnotationSource(quickLabelPicker.source)) {
1117
+ createAnnotationFromMathSource(
1118
+ quickLabelPicker.source,
1119
+ AnnotationType.COMMENT,
1120
+ `${label.emoji} ${label.text}`,
1121
+ undefined,
1122
+ true,
1123
+ label.tip,
1124
+ );
1125
+ clearPendingSelection();
1126
+ setQuickLabelPicker(null);
1127
+ window.getSelection()?.removeAllRanges();
1128
+ return;
1129
+ }
1130
+ if (!highlighterRef.current) return;
1131
+ createAnnotationFromSource(
1132
+ highlighterRef.current, quickLabelPicker.source, AnnotationType.COMMENT,
1133
+ `${label.emoji} ${label.text}`, undefined, true, label.tip
1134
+ );
1135
+ clearPendingSelection();
1136
+ setQuickLabelPicker(null);
1137
+ window.getSelection()?.removeAllRanges();
1138
+ }, [clearPendingSelection, quickLabelPicker]);
1139
+
1140
+ const handleQuickLabelPickerDismiss = useCallback(() => {
1141
+ if (
1142
+ quickLabelPicker?.source &&
1143
+ highlighterRef.current &&
1144
+ !isMathAnnotationSource(quickLabelPicker.source)
1145
+ ) {
1146
+ highlighterRef.current.remove(quickLabelPicker.source.id);
1147
+ }
1148
+ clearPendingSelection();
1149
+ setQuickLabelPicker(null);
1150
+ window.getSelection()?.removeAllRanges();
1151
+ }, [clearPendingSelection, quickLabelPicker]);
1152
+
1153
+ return {
1154
+ highlighterRef,
1155
+ toolbarState,
1156
+ commentPopover,
1157
+ quickLabelPicker,
1158
+ handleAnnotate,
1159
+ handleQuickLabel,
1160
+ handleToolbarClose,
1161
+ handleRequestComment,
1162
+ handleCommentSubmit,
1163
+ handleCommentClose,
1164
+ handleFloatingQuickLabel,
1165
+ handleQuickLabelPickerDismiss,
1166
+ removeHighlight,
1167
+ clearAllHighlights,
1168
+ applyAnnotations: applyAnnotationsInternal,
1169
+ };
1170
+ }