@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.
- package/README.md +60 -0
- package/assets/diff-options.png +0 -0
- package/assets/icon-codex.png +0 -0
- package/assets/look-flat.png +0 -0
- package/assets/look-grid.png +0 -0
- package/assets/review-sections.png +0 -0
- package/assets/review-tree.png +0 -0
- package/assets/workspaces.webp +0 -0
- package/components/AISettingsTab.tsx +145 -0
- package/components/ActionMenu.tsx +101 -0
- package/components/AgentControls.tsx +216 -0
- package/components/AgentsTab.tsx +1294 -0
- package/components/AnnotationPanel.tsx +731 -0
- package/components/AnnotationSidebar.tsx +86 -0
- package/components/AnnotationToolbar.tsx +323 -0
- package/components/AnnotationToolstrip.tsx +355 -0
- package/components/ApproveDropdown.tsx +170 -0
- package/components/AttachmentsButton.tsx +410 -0
- package/components/BlockRenderer.tsx +163 -0
- package/components/BorderTrail.tsx +38 -0
- package/components/CodeFilePicker.tsx +64 -0
- package/components/CodeFilePopout.tsx +602 -0
- package/components/CodePathValidationContext.tsx +14 -0
- package/components/CommentPopover.tsx +512 -0
- package/components/CompletionOverlay.tsx +99 -0
- package/components/ConfirmDialog.tsx +128 -0
- package/components/DocBadges.tsx +231 -0
- package/components/EditorAnnotationCard.tsx +65 -0
- package/components/ExportModal.tsx +565 -0
- package/components/FloatingQuickLabelPicker.tsx +144 -0
- package/components/GitHubIcon.tsx +16 -0
- package/components/GitLabIcon.tsx +30 -0
- package/components/GraphvizBlock.tsx +511 -0
- package/components/ImageAnnotator/Canvas.tsx +118 -0
- package/components/ImageAnnotator/Toolbar.tsx +218 -0
- package/components/ImageAnnotator/index.tsx +272 -0
- package/components/ImageAnnotator/types.ts +39 -0
- package/components/ImageAnnotator/utils.ts +147 -0
- package/components/ImageThumbnail.tsx +126 -0
- package/components/ImportModal.tsx +146 -0
- package/components/InlineMarkdown.tsx +1094 -0
- package/components/KeyboardShortcuts.tsx +208 -0
- package/components/Landing.tsx +496 -0
- package/components/ListItemBody.tsx +62 -0
- package/components/ListMarker.tsx +73 -0
- package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
- package/components/MarkdownEditor.tsx +44 -0
- package/components/MenuVersionSection.tsx +91 -0
- package/components/MermaidBlock.tsx +579 -0
- package/components/ModeToggle.tsx +68 -0
- package/components/OpenInAppButton.tsx +299 -0
- package/components/OverlayScrollArea.tsx +82 -0
- package/components/PermissionModeSetup.tsx +92 -0
- package/components/PinpointOverlay.tsx +99 -0
- package/components/PlanAIAnnouncementDialog.tsx +187 -0
- package/components/PlanHeaderMenu.tsx +299 -0
- package/components/PopoutDialog.tsx +91 -0
- package/components/Popover.tsx +27 -0
- package/components/ProviderIcons.tsx +51 -0
- package/components/PullRequestIcon.tsx +12 -0
- package/components/QuickLabelDropdown.tsx +63 -0
- package/components/RenderedMarkdown.tsx +54 -0
- package/components/RepoIcon.tsx +12 -0
- package/components/ResizeHandle.tsx +86 -0
- package/components/ReviewAgentsIcon.tsx +18 -0
- package/components/SearchableSelect.tsx +157 -0
- package/components/Settings.tsx +2220 -0
- package/components/SparklesIcon.tsx +45 -0
- package/components/StickyHeaderLane.tsx +260 -0
- package/components/TableOfContents.tsx +157 -0
- package/components/TaterSpritePullup.tsx +34 -0
- package/components/TaterSpriteRunning.tsx +54 -0
- package/components/TaterSpriteSitting.tsx +37 -0
- package/components/TextShimmer.tsx +57 -0
- package/components/ThemeProvider.tsx +162 -0
- package/components/ThemeTab.tsx +139 -0
- package/components/ToolbarButtons.tsx +116 -0
- package/components/Tooltip.tsx +45 -0
- package/components/Viewer.tsx +977 -0
- package/components/ai/AIProviderBar.tsx +95 -0
- package/components/ai/DocumentAIChatPanel.tsx +313 -0
- package/components/blocks/AlertBlock.tsx +58 -0
- package/components/blocks/Callout.tsx +60 -0
- package/components/blocks/CodeBlock.tsx +75 -0
- package/components/blocks/HtmlBlock.tsx +122 -0
- package/components/blocks/MathBlock.tsx +36 -0
- package/components/blocks/TableBlock.tsx +146 -0
- package/components/blocks/TablePopout.tsx +279 -0
- package/components/blocks/TableToolbar.tsx +153 -0
- package/components/blocks/proseBody.tsx +102 -0
- package/components/core/button.tsx +44 -0
- package/components/core/textarea.tsx +26 -0
- package/components/diagramLanguages.ts +14 -0
- package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
- package/components/html-viewer/HtmlViewer.tsx +386 -0
- package/components/html-viewer/bridge-script.ts +505 -0
- package/components/html-viewer/index.ts +1 -0
- package/components/html-viewer/useHtmlAnnotation.ts +400 -0
- package/components/icons/AgentIcons.tsx +66 -0
- package/components/icons/AppIcon.tsx +56 -0
- package/components/icons/MessagesIcon.tsx +11 -0
- package/components/icons/ObsidianIcons.tsx +208 -0
- package/components/icons/app/android-studio.svg +369 -0
- package/components/icons/app/antigravity.svg +97 -0
- package/components/icons/app/cursor.svg +16 -0
- package/components/icons/app/file-explorer.svg +20 -0
- package/components/icons/app/finder.png +0 -0
- package/components/icons/app/ghostty.svg +13 -0
- package/components/icons/app/iterm2.svg +13 -0
- package/components/icons/app/powershell.svg +14 -0
- package/components/icons/app/sublime-text.svg +17 -0
- package/components/icons/app/terminal.png +0 -0
- package/components/icons/app/textmate.png +0 -0
- package/components/icons/app/vscode.svg +39 -0
- package/components/icons/app/warp.png +0 -0
- package/components/icons/app/xcode.png +0 -0
- package/components/icons/app/zed-dark.svg +15 -0
- package/components/icons/app/zed.svg +15 -0
- package/components/icons/themeIcons.tsx +47 -0
- package/components/mermaidSvg.ts +33 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
- package/components/plan-diff/PlanDiffBadge.tsx +48 -0
- package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
- package/components/plan-diff/PlanDiffViewer.tsx +214 -0
- package/components/plan-diff/PlanRawDiffView.tsx +102 -0
- package/components/plan-diff/VSCodeIcon.tsx +132 -0
- package/components/settings/HooksTab.tsx +208 -0
- package/components/sidebar/ArchiveBrowser.tsx +98 -0
- package/components/sidebar/CountBadge.tsx +12 -0
- package/components/sidebar/FileBrowser.tsx +498 -0
- package/components/sidebar/MessagesBrowser.tsx +109 -0
- package/components/sidebar/SidebarContainer.tsx +353 -0
- package/components/sidebar/SidebarTabs.tsx +155 -0
- package/components/sidebar/VersionBrowser.tsx +141 -0
- package/components/types.d.ts +7 -0
- package/components/ui/badge.tsx +44 -0
- package/components/ui/button.tsx +83 -0
- package/components/ui/card.tsx +58 -0
- package/components/ui/dialog.tsx +106 -0
- package/components/ui/dropdown-menu.tsx +240 -0
- package/components/ui/state-pill.tsx +45 -0
- package/components/ui/tabs.tsx +46 -0
- package/components/ui/textarea.tsx +25 -0
- package/config/configStore.ts +214 -0
- package/config/index.ts +4 -0
- package/config/reviewView.ts +42 -0
- package/config/settings.ts +310 -0
- package/config/useConfig.ts +20 -0
- package/configure.ts +69 -0
- package/globals.d.ts +14 -0
- package/hooks/pfm/useCodeFilePopout.ts +111 -0
- package/hooks/useAIChat.ts +532 -0
- package/hooks/useAIProviderConfig.ts +115 -0
- package/hooks/useActiveSection.ts +78 -0
- package/hooks/useAgentJobs.ts +306 -0
- package/hooks/useAgentSettings.ts +579 -0
- package/hooks/useAgents.ts +90 -0
- package/hooks/useAnnotationDraft.ts +523 -0
- package/hooks/useAnnotationHighlighter.ts +1170 -0
- package/hooks/useArchive.ts +171 -0
- package/hooks/useAutoClose.ts +104 -0
- package/hooks/useCodeAnnotationDraft.ts +194 -0
- package/hooks/useCodeFilePopout.ts +1 -0
- package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
- package/hooks/useDraggable.ts +108 -0
- package/hooks/useEditorAnnotations.ts +64 -0
- package/hooks/useExternalAnnotationHighlights.ts +105 -0
- package/hooks/useExternalAnnotations.ts +268 -0
- package/hooks/useFileBrowser.ts +402 -0
- package/hooks/useInputMethodSwitch.ts +89 -0
- package/hooks/useIsMobile.ts +17 -0
- package/hooks/useLinkedDoc.ts +494 -0
- package/hooks/useOverlayViewport.ts +36 -0
- package/hooks/usePinpoint.ts +182 -0
- package/hooks/usePlanDiff.ts +176 -0
- package/hooks/usePrintMode.ts +27 -0
- package/hooks/useResizablePanel.ts +173 -0
- package/hooks/useScrollViewport.ts +38 -0
- package/hooks/useSharing.ts +441 -0
- package/hooks/useSidebar.ts +55 -0
- package/hooks/useUpdateCheck.ts +132 -0
- package/hooks/useValidatedCodePaths.ts +94 -0
- package/icons/GitUser.tsx +17 -0
- package/lib/utils.ts +11 -0
- package/package.json +117 -0
- package/plannotator.webp +0 -0
- package/print.css +427 -0
- package/shortcuts/code-review/ai.shortcuts.ts +25 -0
- package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
- package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
- package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
- package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
- package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
- package/shortcuts/core.ts +399 -0
- package/shortcuts/index.ts +21 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
- package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
- package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
- package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
- package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
- package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
- package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
- package/shortcuts/runtime.ts +259 -0
- package/sprite_package_additional/index.html +34 -0
- package/sprite_package_additional/sprite.png +0 -0
- package/sprite_package_new/index.html +34 -0
- package/sprite_package_new/sprite.png +0 -0
- package/sprite_package_pulluphang/index.html +34 -0
- package/sprite_package_pulluphang/sprite.png +0 -0
- package/styles.css +1 -0
- package/theme.css +893 -0
- package/themes/adwaita.css +62 -0
- package/themes/andromeeda.css +66 -0
- package/themes/aurora-x.css +66 -0
- package/themes/ayu-dark.css +66 -0
- package/themes/caffeine.css +113 -0
- package/themes/catppuccin.css +62 -0
- package/themes/claude-plus.css +60 -0
- package/themes/cursor-hc.css +34 -0
- package/themes/cursor-midnight.css +34 -0
- package/themes/cursor.css +62 -0
- package/themes/dark-plus.css +66 -0
- package/themes/doom-64.css +109 -0
- package/themes/dracula.css +33 -0
- package/themes/everforest-hard.css +62 -0
- package/themes/everforest-soft.css +62 -0
- package/themes/everforest.css +62 -0
- package/themes/github.css +66 -0
- package/themes/gruvbox.css +62 -0
- package/themes/houston.css +66 -0
- package/themes/kanagawa-dragon.css +34 -0
- package/themes/kanagawa-lotus.css +34 -0
- package/themes/kanagawa-wave.css +34 -0
- package/themes/laserwave.css +66 -0
- package/themes/material.css +62 -0
- package/themes/min.css +66 -0
- package/themes/monokai-pro.css +34 -0
- package/themes/neutral.css +59 -0
- package/themes/night-owl.css +66 -0
- package/themes/nord.css +66 -0
- package/themes/one-dark-pro.css +66 -0
- package/themes/one-light.css +66 -0
- package/themes/paulmillr.css +34 -0
- package/themes/plannotator.css +60 -0
- package/themes/plastic.css +66 -0
- package/themes/poimandres.css +66 -0
- package/themes/quantum-rose.css +109 -0
- package/themes/red.css +66 -0
- package/themes/rose-pine.css +62 -0
- package/themes/simple.css +124 -0
- package/themes/slack.css +66 -0
- package/themes/snazzy-light.css +66 -0
- package/themes/soft-pop.css +60 -0
- package/themes/solar-dusk.css +109 -0
- package/themes/solarized.css +66 -0
- package/themes/synthwave-84.css +34 -0
- package/themes/terminal.css +62 -0
- package/themes/tinacious.css +57 -0
- package/themes/tokyo-night.css +62 -0
- package/themes/vesper.css +62 -0
- package/themes/vitesse-black.css +66 -0
- package/themes/vitesse.css +62 -0
- package/types.ts +264 -0
- package/utils/agentSwitch.ts +71 -0
- package/utils/aiChatFormat.ts +33 -0
- package/utils/aiPrompt.ts +29 -0
- package/utils/aiProvider.ts +217 -0
- package/utils/anchors.ts +11 -0
- package/utils/annotateAgentTerminal.ts +29 -0
- package/utils/annotationHelpers.ts +101 -0
- package/utils/bear.ts +59 -0
- package/utils/blockTargeting.ts +240 -0
- package/utils/callback.ts +99 -0
- package/utils/commentContent.ts +8 -0
- package/utils/defaultNotesApp.ts +20 -0
- package/utils/diffFonts.ts +33 -0
- package/utils/editorMode.ts +31 -0
- package/utils/fileBrowser.ts +40 -0
- package/utils/generateId.ts +7 -0
- package/utils/generateIdentity.ts +29 -0
- package/utils/identity.ts +112 -0
- package/utils/inlineTransforms.ts +41 -0
- package/utils/inputMethod.ts +17 -0
- package/utils/lookAndFeelAnnouncement.ts +18 -0
- package/utils/obsidian.ts +201 -0
- package/utils/octarine.ts +53 -0
- package/utils/parser.ts +991 -0
- package/utils/permissionMode.ts +77 -0
- package/utils/planAIAnnouncement.ts +17 -0
- package/utils/planAgentInstructions.ts +137 -0
- package/utils/planDiffEngine.ts +590 -0
- package/utils/planSave.ts +49 -0
- package/utils/platform.ts +11 -0
- package/utils/quickLabels.ts +77 -0
- package/utils/reviewAgentInstructions.ts +182 -0
- package/utils/sanitizeHtml.ts +45 -0
- package/utils/sharing.ts +350 -0
- package/utils/slugify.ts +37 -0
- package/utils/storage.ts +141 -0
- package/utils/themeRegistry.ts +567 -0
- package/utils/uiPreferences.ts +34 -0
- package/utils/upload.ts +56 -0
- 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
|
+
}
|