@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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive Hook
|
|
3
|
+
*
|
|
4
|
+
* Manages state and handlers for the archive browser — both standalone
|
|
5
|
+
* archive mode and in-session browsing via the sidebar tab.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useRef, useMemo, useCallback } from "react";
|
|
9
|
+
import type { ArchivedPlan } from "@plannotator/core/storage-types";
|
|
10
|
+
import type { UseLinkedDocReturn } from "./useLinkedDoc";
|
|
11
|
+
import type { ViewerHandle } from "../components/Viewer";
|
|
12
|
+
import type { Annotation } from "../types";
|
|
13
|
+
import { getPlanSaveSettings } from "../utils/planSave";
|
|
14
|
+
|
|
15
|
+
export interface UseArchiveOptions {
|
|
16
|
+
markdown: string;
|
|
17
|
+
viewerRef: React.RefObject<ViewerHandle | null>;
|
|
18
|
+
linkedDocHook: UseLinkedDocReturn;
|
|
19
|
+
setMarkdown: (md: string) => void;
|
|
20
|
+
setAnnotations: (anns: Annotation[]) => void;
|
|
21
|
+
setSelectedAnnotationId: (id: string | null) => void;
|
|
22
|
+
setSubmitted: (s: "approved" | "denied" | null) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UseArchiveReturn {
|
|
26
|
+
/** Whether running in standalone archive mode */
|
|
27
|
+
archiveMode: boolean;
|
|
28
|
+
/** List of archived plans */
|
|
29
|
+
plans: ArchivedPlan[];
|
|
30
|
+
/** Currently selected archive filename */
|
|
31
|
+
selectedFile: string | null;
|
|
32
|
+
/** Whether the plan list is loading */
|
|
33
|
+
isLoading: boolean;
|
|
34
|
+
/** Info about the currently selected archive plan */
|
|
35
|
+
currentInfo: { status: ArchivedPlan["status"]; timestamp: string; title: string } | null;
|
|
36
|
+
/** Initialize archive state from /api/plan response */
|
|
37
|
+
init: (plans: ArchivedPlan[]) => void;
|
|
38
|
+
/** Select an archive plan to view */
|
|
39
|
+
select: (filename: string) => Promise<void>;
|
|
40
|
+
/** Lazy-fetch the plan list (for in-session mode) */
|
|
41
|
+
fetchPlans: () => Promise<void>;
|
|
42
|
+
/** Close the archive browser */
|
|
43
|
+
done: () => Promise<void>;
|
|
44
|
+
/** Copy plan content to clipboard (strips annotations) */
|
|
45
|
+
copy: () => void;
|
|
46
|
+
/** Clear the selected file (used by linked doc back) */
|
|
47
|
+
clearSelection: () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useArchive(options: UseArchiveOptions): UseArchiveReturn {
|
|
51
|
+
const {
|
|
52
|
+
markdown,
|
|
53
|
+
viewerRef,
|
|
54
|
+
linkedDocHook,
|
|
55
|
+
setMarkdown,
|
|
56
|
+
setAnnotations,
|
|
57
|
+
setSelectedAnnotationId,
|
|
58
|
+
setSubmitted,
|
|
59
|
+
} = options;
|
|
60
|
+
|
|
61
|
+
const [archiveMode, setArchiveMode] = useState(false);
|
|
62
|
+
const [plans, setPlans] = useState<ArchivedPlan[]>([]);
|
|
63
|
+
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
64
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
65
|
+
const hasFetched = useRef(false);
|
|
66
|
+
|
|
67
|
+
const customPath = useMemo(() => getPlanSaveSettings().customPath || undefined, []);
|
|
68
|
+
|
|
69
|
+
const currentInfo = useMemo(() => {
|
|
70
|
+
if (!selectedFile) return null;
|
|
71
|
+
const plan = plans.find(p => p.filename === selectedFile);
|
|
72
|
+
return plan ? { status: plan.status, timestamp: plan.timestamp, title: plan.title } : null;
|
|
73
|
+
}, [selectedFile, plans]);
|
|
74
|
+
|
|
75
|
+
const init = useCallback((archivePlans: ArchivedPlan[]) => {
|
|
76
|
+
setArchiveMode(true);
|
|
77
|
+
setPlans(archivePlans);
|
|
78
|
+
if (archivePlans.length > 0) {
|
|
79
|
+
setSelectedFile(archivePlans[0].filename);
|
|
80
|
+
}
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const select = useCallback(async (filename: string) => {
|
|
84
|
+
try {
|
|
85
|
+
const params = new URLSearchParams({ filename });
|
|
86
|
+
if (customPath) params.set("customPath", customPath);
|
|
87
|
+
const res = await fetch(`/api/archive/plan?${params}`);
|
|
88
|
+
if (!res.ok) return;
|
|
89
|
+
const data = await res.json() as { markdown: string; filepath: string };
|
|
90
|
+
|
|
91
|
+
if (archiveMode) {
|
|
92
|
+
// Standalone: direct swap
|
|
93
|
+
viewerRef.current?.clearAllHighlights();
|
|
94
|
+
setMarkdown(data.markdown);
|
|
95
|
+
setAnnotations([]);
|
|
96
|
+
setSelectedAnnotationId(null);
|
|
97
|
+
setSelectedFile(filename);
|
|
98
|
+
} else {
|
|
99
|
+
// In-session: use linked doc overlay
|
|
100
|
+
const buildUrl = (f: string) => {
|
|
101
|
+
const p = new URLSearchParams({ filename: f });
|
|
102
|
+
if (customPath) p.set("customPath", customPath);
|
|
103
|
+
return `/api/archive/plan?${p}`;
|
|
104
|
+
};
|
|
105
|
+
linkedDocHook.open(filename, buildUrl, "archive");
|
|
106
|
+
setSelectedFile(filename);
|
|
107
|
+
}
|
|
108
|
+
} catch { /* ignore */ }
|
|
109
|
+
}, [archiveMode, customPath, viewerRef, setMarkdown, setAnnotations, setSelectedAnnotationId, linkedDocHook]);
|
|
110
|
+
|
|
111
|
+
const fetchPlans = useCallback(async () => {
|
|
112
|
+
if (hasFetched.current || isLoading) return;
|
|
113
|
+
hasFetched.current = true;
|
|
114
|
+
setIsLoading(true);
|
|
115
|
+
try {
|
|
116
|
+
const params = new URLSearchParams();
|
|
117
|
+
if (customPath) params.set("customPath", customPath);
|
|
118
|
+
const qs = params.toString();
|
|
119
|
+
const res = await fetch(`/api/archive/plans${qs ? `?${qs}` : ""}`);
|
|
120
|
+
if (!res.ok) return;
|
|
121
|
+
const data = await res.json() as { plans: ArchivedPlan[] };
|
|
122
|
+
setPlans(data.plans);
|
|
123
|
+
// In standalone archive mode, auto-select and load the first plan
|
|
124
|
+
// so the viewer reflects the customPath results, not stale server data
|
|
125
|
+
if (archiveMode && data.plans.length > 0) {
|
|
126
|
+
const first = data.plans[0].filename;
|
|
127
|
+
setSelectedFile(first);
|
|
128
|
+
const fetchParams = new URLSearchParams({ filename: first });
|
|
129
|
+
if (customPath) fetchParams.set("customPath", customPath);
|
|
130
|
+
const planRes = await fetch(`/api/archive/plan?${fetchParams}`);
|
|
131
|
+
if (planRes.ok) {
|
|
132
|
+
const planData = await planRes.json() as { markdown: string };
|
|
133
|
+
setMarkdown(planData.markdown);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
hasFetched.current = false;
|
|
138
|
+
} finally {
|
|
139
|
+
setIsLoading(false);
|
|
140
|
+
}
|
|
141
|
+
}, [archiveMode, customPath, isLoading, setMarkdown]);
|
|
142
|
+
|
|
143
|
+
const done = useCallback(async () => {
|
|
144
|
+
try {
|
|
145
|
+
await fetch("/api/done", { method: "POST" });
|
|
146
|
+
setSubmitted("approved");
|
|
147
|
+
} catch { /* ignore */ }
|
|
148
|
+
}, [setSubmitted]);
|
|
149
|
+
|
|
150
|
+
const copy = useCallback(() => {
|
|
151
|
+
navigator.clipboard.writeText(markdown);
|
|
152
|
+
}, [markdown]);
|
|
153
|
+
|
|
154
|
+
const clearSelection = useCallback(() => {
|
|
155
|
+
setSelectedFile(null);
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
archiveMode,
|
|
160
|
+
plans,
|
|
161
|
+
selectedFile,
|
|
162
|
+
isLoading,
|
|
163
|
+
currentInfo,
|
|
164
|
+
init,
|
|
165
|
+
select,
|
|
166
|
+
fetchPlans,
|
|
167
|
+
done,
|
|
168
|
+
copy,
|
|
169
|
+
clearSelection,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import { getAutoCloseDelay, setAutoCloseDelay } from '../utils/storage';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Phases of the auto-close lifecycle after a form submission.
|
|
6
|
+
*
|
|
7
|
+
* - idle: nothing submitted yet
|
|
8
|
+
* - counting: countdown is ticking (seconds remaining in `remaining`)
|
|
9
|
+
* - prompt: auto-close is disabled; offer the user a checkbox to opt in
|
|
10
|
+
* - closed: window.close() succeeded (terminal state)
|
|
11
|
+
* - closeFailed: window.close() was blocked by the browser
|
|
12
|
+
*/
|
|
13
|
+
type AutoClosePhase =
|
|
14
|
+
| { phase: 'idle' }
|
|
15
|
+
| { phase: 'counting'; remaining: number }
|
|
16
|
+
| { phase: 'prompt' }
|
|
17
|
+
| { phase: 'closed' }
|
|
18
|
+
| { phase: 'closeFailed' };
|
|
19
|
+
|
|
20
|
+
interface UseAutoCloseReturn {
|
|
21
|
+
state: AutoClosePhase;
|
|
22
|
+
/** User opted in via the checkbox — persist "3s" and start countdown. */
|
|
23
|
+
enableAndStart: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type GlimpseWindow = Window & {
|
|
27
|
+
glimpse?: {
|
|
28
|
+
close?: () => void;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function requestGlimpseClose(): boolean {
|
|
33
|
+
const glimpseClose = (window as GlimpseWindow).glimpse?.close;
|
|
34
|
+
if (typeof glimpseClose === 'function') {
|
|
35
|
+
glimpseClose();
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (window.parent && window.parent !== window) {
|
|
40
|
+
window.parent.postMessage({ __plannotator_glimpse_close: true }, '*');
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function tryClose(onFail: () => void): void {
|
|
48
|
+
const requestedNativeClose = requestGlimpseClose();
|
|
49
|
+
if (requestedNativeClose) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
window.close();
|
|
54
|
+
// window.close() is silently ignored when the tab wasn't opened by script.
|
|
55
|
+
// Check after a short delay whether we're still alive.
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
if (!window.closed) onFail();
|
|
58
|
+
}, 300);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Manages the auto-close countdown that runs after a submission overlay appears.
|
|
63
|
+
*
|
|
64
|
+
* @param active - pass `true` once the overlay should appear (i.e. form was submitted)
|
|
65
|
+
*/
|
|
66
|
+
export function useAutoClose(active: boolean): UseAutoCloseReturn {
|
|
67
|
+
const [state, setState] = useState<AutoClosePhase>({ phase: 'idle' });
|
|
68
|
+
|
|
69
|
+
// On activation, read persisted delay and transition to the right phase.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!active) return;
|
|
72
|
+
|
|
73
|
+
const delay = getAutoCloseDelay();
|
|
74
|
+
if (delay === '0') {
|
|
75
|
+
tryClose(() => setState({ phase: 'closeFailed' }));
|
|
76
|
+
setState({ phase: 'closed' });
|
|
77
|
+
} else if (delay !== 'off') {
|
|
78
|
+
setState({ phase: 'counting', remaining: Number(delay) });
|
|
79
|
+
} else {
|
|
80
|
+
setState({ phase: 'prompt' });
|
|
81
|
+
}
|
|
82
|
+
}, [active]);
|
|
83
|
+
|
|
84
|
+
// Tick the countdown once per second.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (state.phase !== 'counting') return;
|
|
87
|
+
if (state.remaining <= 0) {
|
|
88
|
+
tryClose(() => setState({ phase: 'closeFailed' }));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const timer = setTimeout(
|
|
92
|
+
() => setState((prev) => (prev.phase === 'counting' ? { phase: 'counting', remaining: prev.remaining - 1 } : prev)),
|
|
93
|
+
1000,
|
|
94
|
+
);
|
|
95
|
+
return () => clearTimeout(timer);
|
|
96
|
+
}, [state]);
|
|
97
|
+
|
|
98
|
+
const enableAndStart = useCallback(() => {
|
|
99
|
+
setAutoCloseDelay('3');
|
|
100
|
+
setState({ phase: 'counting', remaining: 3 });
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
return { state, enableAndStart };
|
|
104
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-save code review annotation drafts to the server.
|
|
3
|
+
*
|
|
4
|
+
* Similar to useAnnotationDraft but stores CodeAnnotation[] directly
|
|
5
|
+
* (they're already compact — no tuple conversion needed).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
9
|
+
import type { CodeAnnotation, Annotation, CommentAnnotation } from '../types';
|
|
10
|
+
import { getDraftTransport } from './useAnnotationDraft';
|
|
11
|
+
|
|
12
|
+
const DEBOUNCE_MS = 500;
|
|
13
|
+
|
|
14
|
+
interface DraftData {
|
|
15
|
+
codeAnnotations: CodeAnnotation[];
|
|
16
|
+
descriptionAnnotations?: Annotation[];
|
|
17
|
+
commentAnnotations?: CommentAnnotation[];
|
|
18
|
+
viewedFiles?: string[];
|
|
19
|
+
draftGeneration?: number;
|
|
20
|
+
ts: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface MissingDraftData {
|
|
24
|
+
found?: false;
|
|
25
|
+
draftGeneration?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readDraftGeneration(value: unknown): number | null {
|
|
29
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatTimeAgo(ts: number): string {
|
|
33
|
+
const seconds = Math.floor((Date.now() - ts) / 1000);
|
|
34
|
+
if (seconds < 60) return 'just now';
|
|
35
|
+
const minutes = Math.floor(seconds / 60);
|
|
36
|
+
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
|
37
|
+
const hours = Math.floor(minutes / 60);
|
|
38
|
+
if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
|
39
|
+
const days = Math.floor(hours / 24);
|
|
40
|
+
return `${days} day${days !== 1 ? 's' : ''} ago`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface UseCodeAnnotationDraftOptions {
|
|
44
|
+
annotations: CodeAnnotation[];
|
|
45
|
+
descriptionAnnotations?: Annotation[];
|
|
46
|
+
commentAnnotations?: CommentAnnotation[];
|
|
47
|
+
viewedFiles: Set<string>;
|
|
48
|
+
isApiMode: boolean;
|
|
49
|
+
submitted: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface UseCodeAnnotationDraftResult {
|
|
53
|
+
draftBanner: { count: number; viewedCount: number; timeAgo: string } | null;
|
|
54
|
+
restoreDraft: () => { annotations: CodeAnnotation[]; descriptionAnnotations: Annotation[]; commentAnnotations: CommentAnnotation[]; viewedFiles: string[] };
|
|
55
|
+
getDraftGeneration: () => number;
|
|
56
|
+
dismissDraft: () => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function useCodeAnnotationDraft({
|
|
60
|
+
annotations,
|
|
61
|
+
descriptionAnnotations = [],
|
|
62
|
+
commentAnnotations = [],
|
|
63
|
+
viewedFiles,
|
|
64
|
+
isApiMode,
|
|
65
|
+
submitted,
|
|
66
|
+
}: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
|
|
67
|
+
const [draftBanner, setDraftBanner] = useState<{ count: number; viewedCount: number; timeAgo: string } | null>(null);
|
|
68
|
+
const draftDataRef = useRef<DraftData | null>(null);
|
|
69
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
70
|
+
const hasMountedRef = useRef(false);
|
|
71
|
+
const draftGenerationRef = useRef(0);
|
|
72
|
+
// True once the user has actually had annotations this session. Used to decide
|
|
73
|
+
// whether an empty state is a real "cleared everything" edit (persist it) vs a
|
|
74
|
+
// fresh/unengaged session (leave the server alone). Keyed on annotations only —
|
|
75
|
+
// see the autosave effect for why viewedFiles must not count.
|
|
76
|
+
const hasHadAnnotationsRef = useRef(false);
|
|
77
|
+
|
|
78
|
+
// Load draft on mount
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!isApiMode) return;
|
|
81
|
+
|
|
82
|
+
getDraftTransport().load()
|
|
83
|
+
.then(({ data, generation }) => {
|
|
84
|
+
if (generation !== null) {
|
|
85
|
+
draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
|
|
86
|
+
}
|
|
87
|
+
return data as DraftData | null;
|
|
88
|
+
})
|
|
89
|
+
.then((data: DraftData | null) => {
|
|
90
|
+
const generation = readDraftGeneration(data?.draftGeneration);
|
|
91
|
+
if (generation !== null) {
|
|
92
|
+
draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
|
|
93
|
+
}
|
|
94
|
+
const annotationCount = (Array.isArray(data?.codeAnnotations) ? data.codeAnnotations.length : 0)
|
|
95
|
+
+ (Array.isArray(data?.descriptionAnnotations) ? data.descriptionAnnotations.length : 0)
|
|
96
|
+
+ (Array.isArray(data?.commentAnnotations) ? data.commentAnnotations.length : 0);
|
|
97
|
+
const viewedCount = Array.isArray(data?.viewedFiles) ? data.viewedFiles.length : 0;
|
|
98
|
+
if (annotationCount > 0 || viewedCount > 0) {
|
|
99
|
+
draftDataRef.current = data;
|
|
100
|
+
setDraftBanner({
|
|
101
|
+
count: annotationCount,
|
|
102
|
+
viewedCount,
|
|
103
|
+
timeAgo: formatTimeAgo(data?.ts || 0),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
hasMountedRef.current = true;
|
|
107
|
+
})
|
|
108
|
+
.catch(() => {
|
|
109
|
+
hasMountedRef.current = true;
|
|
110
|
+
});
|
|
111
|
+
}, [isApiMode]);
|
|
112
|
+
|
|
113
|
+
// Debounced auto-save on annotation/viewed changes
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!isApiMode || submitted) return;
|
|
116
|
+
if (!hasMountedRef.current) return;
|
|
117
|
+
|
|
118
|
+
// Track engagement on USER-AUTHORED annotations only. Two things that arrive
|
|
119
|
+
// without user action must NOT count as "had content", or a later empty state
|
|
120
|
+
// would look like the user deleted everything and wrongly delete the draft:
|
|
121
|
+
// - viewedFiles are seeded from GitHub's already-viewed state on mount
|
|
122
|
+
// (review App.tsx) before the user does anything.
|
|
123
|
+
// - external/SSE annotations (source-tagged, e.g. an eslint plugin) arrive
|
|
124
|
+
// via `allAnnotations` and have their own lifecycle, separate from the draft.
|
|
125
|
+
if (annotations.some((a) => !a.source) || descriptionAnnotations.length > 0 || commentAnnotations.length > 0) hasHadAnnotationsRef.current = true;
|
|
126
|
+
|
|
127
|
+
const isEmpty = annotations.length === 0 && descriptionAnnotations.length === 0 && commentAnnotations.length === 0 && viewedFiles.size === 0;
|
|
128
|
+
// Leave the server alone for an empty state until the user has actually had
|
|
129
|
+
// annotations this session. This preserves an unrestored draft sitting on disk
|
|
130
|
+
// at mount (the draft-recovery banner can still offer it).
|
|
131
|
+
if (isEmpty && !hasHadAnnotationsRef.current) return;
|
|
132
|
+
|
|
133
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
134
|
+
|
|
135
|
+
timerRef.current = setTimeout(() => {
|
|
136
|
+
const draftGeneration = draftGenerationRef.current + 1;
|
|
137
|
+
draftGenerationRef.current = draftGeneration;
|
|
138
|
+
|
|
139
|
+
if (isEmpty) {
|
|
140
|
+
// The user cleared everything (#948). Delete the draft with a generation
|
|
141
|
+
// tombstone so it can't resurface on refresh and a late save can't revive
|
|
142
|
+
// it. Mirrors useAnnotationDraft.persistNow — routed through the draft
|
|
143
|
+
// transport seam so a host backend tombstones its own stored draft too.
|
|
144
|
+
getDraftTransport().remove(draftGeneration, { keepalive: false }).catch(() => {});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const payload: DraftData = {
|
|
149
|
+
codeAnnotations: annotations,
|
|
150
|
+
descriptionAnnotations,
|
|
151
|
+
commentAnnotations,
|
|
152
|
+
viewedFiles: [...viewedFiles],
|
|
153
|
+
draftGeneration,
|
|
154
|
+
ts: Date.now(),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
getDraftTransport().save(payload, { keepalive: false }).catch(() => {});
|
|
158
|
+
}, DEBOUNCE_MS);
|
|
159
|
+
|
|
160
|
+
return () => {
|
|
161
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
162
|
+
};
|
|
163
|
+
}, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, isApiMode, submitted]);
|
|
164
|
+
|
|
165
|
+
const restoreDraft = useCallback(() => {
|
|
166
|
+
// Cancel any pending autosave so it can't fire with pre-restore state and
|
|
167
|
+
// overwrite what we're about to restore.
|
|
168
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
169
|
+
const data = draftDataRef.current;
|
|
170
|
+
setDraftBanner(null);
|
|
171
|
+
draftDataRef.current = null;
|
|
172
|
+
return {
|
|
173
|
+
annotations: data?.codeAnnotations ?? [],
|
|
174
|
+
descriptionAnnotations: data?.descriptionAnnotations ?? [],
|
|
175
|
+
commentAnnotations: data?.commentAnnotations ?? [],
|
|
176
|
+
viewedFiles: data?.viewedFiles ?? [],
|
|
177
|
+
};
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
const getDraftGeneration = useCallback(() => draftGenerationRef.current + 1, []);
|
|
181
|
+
|
|
182
|
+
const dismissDraft = useCallback(() => {
|
|
183
|
+
// Cancel any pending autosave so a late save can't revive the draft the user
|
|
184
|
+
// just dismissed.
|
|
185
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
186
|
+
const deletedGeneration = draftGenerationRef.current + 1;
|
|
187
|
+
draftGenerationRef.current = deletedGeneration;
|
|
188
|
+
setDraftBanner(null);
|
|
189
|
+
draftDataRef.current = null;
|
|
190
|
+
getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
return { draftBanner, restoreDraft, getDraftGeneration, dismissDraft };
|
|
194
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useCodeFilePopout, type UseCodeFilePopoutReturn } from "./pfm/useCodeFilePopout";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
export function useDismissOnOutsideAndEscape({
|
|
4
|
+
enabled,
|
|
5
|
+
ref,
|
|
6
|
+
onDismiss,
|
|
7
|
+
}: {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
ref: React.RefObject<HTMLElement | null>;
|
|
10
|
+
onDismiss: () => void;
|
|
11
|
+
}) {
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!enabled) return;
|
|
14
|
+
|
|
15
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
16
|
+
// Don't dismiss on multi-click (double-click, triple-click). These
|
|
17
|
+
// are part of an active selection gesture — the web-highlighter
|
|
18
|
+
// CREATE handler already cleans up any stale pending highlight when
|
|
19
|
+
// a new selection commits. Dismissing here triggers DOM mutations
|
|
20
|
+
// (mark removal + text-node normalisation) that reset the browser's
|
|
21
|
+
// click-count tracking and prevent the triple-click paragraph
|
|
22
|
+
// selection from ever firing.
|
|
23
|
+
// Note: we use mousedown (not pointerdown) because MouseEvent.detail
|
|
24
|
+
// is spec-guaranteed to carry the click count, whereas the Pointer
|
|
25
|
+
// Events spec says PointerEvent.detail SHOULD be 0.
|
|
26
|
+
if (event.detail >= 2) return;
|
|
27
|
+
|
|
28
|
+
const target = event.target as Node | null;
|
|
29
|
+
if (!target) return;
|
|
30
|
+
if (ref.current && ref.current.contains(target)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
onDismiss();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
37
|
+
if (event.key === "Escape") {
|
|
38
|
+
onDismiss();
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
document.addEventListener("mousedown", handleMouseDown, true);
|
|
43
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
document.removeEventListener("mousedown", handleMouseDown, true);
|
|
47
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
48
|
+
};
|
|
49
|
+
}, [enabled, ref, onDismiss]);
|
|
50
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
const DRAG_THRESHOLD = 3;
|
|
4
|
+
const VISIBLE_MIN = 50;
|
|
5
|
+
|
|
6
|
+
interface DragPosition {
|
|
7
|
+
top: number;
|
|
8
|
+
left: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Makes a fixed-position element draggable by its header/handle.
|
|
13
|
+
* Reads the element's actual rendered position on drag start via getBoundingClientRect,
|
|
14
|
+
* so it works regardless of CSS transforms (flipAbove, translateX, etc.).
|
|
15
|
+
*
|
|
16
|
+
* @param elementRef - Ref to the positioned element (the popover/toolbar container)
|
|
17
|
+
*/
|
|
18
|
+
export function useDraggable(elementRef: React.RefObject<HTMLElement | null>) {
|
|
19
|
+
const [dragPosition, setDragPosition] = useState<DragPosition | null>(null);
|
|
20
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
21
|
+
const [wasDragged, setWasDragged] = useState(false);
|
|
22
|
+
|
|
23
|
+
const startPointerRef = useRef({ x: 0, y: 0 });
|
|
24
|
+
const startElRef = useRef({ top: 0, left: 0 });
|
|
25
|
+
const thresholdMetRef = useRef(false);
|
|
26
|
+
|
|
27
|
+
const reset = useCallback(() => {
|
|
28
|
+
setDragPosition(null);
|
|
29
|
+
setIsDragging(false);
|
|
30
|
+
setWasDragged(false);
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
// Document-level pointermove/pointerup while dragging
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!isDragging) return;
|
|
36
|
+
|
|
37
|
+
const onMove = (e: PointerEvent) => {
|
|
38
|
+
const dx = e.clientX - startPointerRef.current.x;
|
|
39
|
+
const dy = e.clientY - startPointerRef.current.y;
|
|
40
|
+
|
|
41
|
+
if (!thresholdMetRef.current) {
|
|
42
|
+
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return;
|
|
43
|
+
thresholdMetRef.current = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Clamp to keep at least VISIBLE_MIN px visible on screen
|
|
47
|
+
const top = Math.max(
|
|
48
|
+
-((elementRef.current?.offsetHeight ?? 0) - VISIBLE_MIN),
|
|
49
|
+
Math.min(startElRef.current.top + dy, window.innerHeight - VISIBLE_MIN),
|
|
50
|
+
);
|
|
51
|
+
const left = Math.max(
|
|
52
|
+
-((elementRef.current?.offsetWidth ?? 0) - VISIBLE_MIN),
|
|
53
|
+
Math.min(startElRef.current.left + dx, window.innerWidth - VISIBLE_MIN),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
setDragPosition({ top, left });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const onUp = () => {
|
|
60
|
+
setIsDragging(false);
|
|
61
|
+
if (thresholdMetRef.current) {
|
|
62
|
+
setWasDragged(true);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
document.addEventListener('pointermove', onMove);
|
|
67
|
+
document.addEventListener('pointerup', onUp);
|
|
68
|
+
return () => {
|
|
69
|
+
document.removeEventListener('pointermove', onMove);
|
|
70
|
+
document.removeEventListener('pointerup', onUp);
|
|
71
|
+
};
|
|
72
|
+
}, [isDragging, elementRef]);
|
|
73
|
+
|
|
74
|
+
const onPointerDown = useCallback(
|
|
75
|
+
(e: React.PointerEvent) => {
|
|
76
|
+
// Only primary button (left click / single touch)
|
|
77
|
+
if (e.button !== 0) return;
|
|
78
|
+
// Don't drag if clicking on an interactive element inside the handle
|
|
79
|
+
const target = e.target as HTMLElement;
|
|
80
|
+
if (target.closest('button, a, input, textarea, select')) return;
|
|
81
|
+
|
|
82
|
+
const el = elementRef.current;
|
|
83
|
+
if (!el) return;
|
|
84
|
+
|
|
85
|
+
const rect = el.getBoundingClientRect();
|
|
86
|
+
startPointerRef.current = { x: e.clientX, y: e.clientY };
|
|
87
|
+
startElRef.current = { top: rect.top, left: rect.left };
|
|
88
|
+
thresholdMetRef.current = false;
|
|
89
|
+
setIsDragging(true);
|
|
90
|
+
},
|
|
91
|
+
[elementRef],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
dragPosition,
|
|
96
|
+
isDragging,
|
|
97
|
+
wasDragged,
|
|
98
|
+
reset,
|
|
99
|
+
dragHandleProps: {
|
|
100
|
+
onPointerDown,
|
|
101
|
+
style: {
|
|
102
|
+
cursor: isDragging ? 'grabbing' : 'grab',
|
|
103
|
+
userSelect: 'none' as const,
|
|
104
|
+
touchAction: 'none' as const,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
+
import type { EditorAnnotation } from '../types';
|
|
3
|
+
|
|
4
|
+
const POLL_INTERVAL = 500;
|
|
5
|
+
const IS_VSCODE = typeof window !== 'undefined' && (window as any).__PLANNOTATOR_VSCODE === true;
|
|
6
|
+
|
|
7
|
+
interface UseEditorAnnotationsReturn {
|
|
8
|
+
editorAnnotations: EditorAnnotation[];
|
|
9
|
+
deleteEditorAnnotation: (id: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Polls the server for editor annotations created by the VS Code extension.
|
|
14
|
+
*
|
|
15
|
+
* Only activates when running inside a VS Code webview (detected via
|
|
16
|
+
* window.__PLANNOTATOR_VSCODE set by the theme bridge). In browser/shared URL
|
|
17
|
+
* contexts, returns an empty array with zero network cost.
|
|
18
|
+
*/
|
|
19
|
+
export function useEditorAnnotations(): UseEditorAnnotationsReturn {
|
|
20
|
+
const [annotations, setAnnotations] = useState<EditorAnnotation[]>([]);
|
|
21
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
22
|
+
|
|
23
|
+
const fetchAnnotations = useCallback(async () => {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch('/api/editor-annotations');
|
|
26
|
+
if (!res.ok) return;
|
|
27
|
+
const data = await res.json();
|
|
28
|
+
const incoming: EditorAnnotation[] = data.annotations ?? [];
|
|
29
|
+
setAnnotations((prev) => {
|
|
30
|
+
if (prev.length === incoming.length && prev.every((a, i) => a.id === incoming[i].id)) return prev;
|
|
31
|
+
return incoming;
|
|
32
|
+
});
|
|
33
|
+
} catch {
|
|
34
|
+
// Silently fail — next poll will retry
|
|
35
|
+
}
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!IS_VSCODE) return;
|
|
40
|
+
|
|
41
|
+
// Initial fetch + start polling
|
|
42
|
+
fetchAnnotations();
|
|
43
|
+
intervalRef.current = setInterval(fetchAnnotations, POLL_INTERVAL);
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
if (intervalRef.current) {
|
|
47
|
+
clearInterval(intervalRef.current);
|
|
48
|
+
intervalRef.current = null;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}, [fetchAnnotations]);
|
|
52
|
+
|
|
53
|
+
const deleteEditorAnnotation = useCallback(async (id: string) => {
|
|
54
|
+
if (!IS_VSCODE) return;
|
|
55
|
+
try {
|
|
56
|
+
await fetch(`/api/editor-annotation?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
57
|
+
setAnnotations((prev) => prev.filter((a) => a.id !== id));
|
|
58
|
+
} catch {
|
|
59
|
+
// Silently fail — next poll will reconcile
|
|
60
|
+
}
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
return { editorAnnotations: annotations, deleteEditorAnnotation };
|
|
64
|
+
}
|