@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,105 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { Annotation } from '../types';
|
|
3
|
+
import { AnnotationType } from '../types';
|
|
4
|
+
import type { ViewerHandle } from '../components/Viewer';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bridges SSE-delivered external annotations into the Viewer's imperative
|
|
8
|
+
* highlight API so tools can POST annotations with `originalText` and have
|
|
9
|
+
* them highlight real spans of the rendered plan.
|
|
10
|
+
*
|
|
11
|
+
* The Viewer's `applySharedAnnotations` already searches the DOM for
|
|
12
|
+
* `originalText` and dedupes against already-applied marks, so this hook
|
|
13
|
+
* just needs to drive it when the external list changes.
|
|
14
|
+
*
|
|
15
|
+
* - Annotations without `originalText` (or `GLOBAL_COMMENT`) stay sidebar-only.
|
|
16
|
+
* - Annotations with `diffContext` are skipped (diff view owns those).
|
|
17
|
+
* - On plan markdown change the applied set is cleared so re-rendered blocks
|
|
18
|
+
* get re-highlighted.
|
|
19
|
+
* - Callers can invoke the returned `reset()` to force a full re-apply — used
|
|
20
|
+
* by the share-import path in App.tsx after it calls `clearAllHighlights()`,
|
|
21
|
+
* which would otherwise leave our bookkeeping stale against a wiped DOM.
|
|
22
|
+
* - Disabled state no-ops WITHOUT clearing the applied set. This preserves the
|
|
23
|
+
* bookkeeping while the Viewer DOM is hidden (diff view / linked doc) so that
|
|
24
|
+
* any SSE removals that arrive while hidden are correctly reconciled when the
|
|
25
|
+
* hook re-enables.
|
|
26
|
+
*/
|
|
27
|
+
export function useExternalAnnotationHighlights(params: {
|
|
28
|
+
viewerRef: React.RefObject<ViewerHandle | null>;
|
|
29
|
+
externalAnnotations: Annotation[];
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
/** Bump to force a full re-apply (e.g. plan markdown changed and blocks re-rendered). */
|
|
32
|
+
planKey: string;
|
|
33
|
+
}): { reset: () => void } {
|
|
34
|
+
const { viewerRef, externalAnnotations, enabled, planKey } = params;
|
|
35
|
+
|
|
36
|
+
// Tracks annotation IDs currently materialized as DOM highlights, along
|
|
37
|
+
// with a fingerprint so updates trigger remove+reapply.
|
|
38
|
+
const appliedRef = useRef<Map<string, string>>(new Map());
|
|
39
|
+
|
|
40
|
+
// Bumped to force the main effect to treat every current external as a
|
|
41
|
+
// fresh application target — used by `reset()` below.
|
|
42
|
+
const [resetCount, setResetCount] = useState(0);
|
|
43
|
+
|
|
44
|
+
// Clear tracking when plan content changes — the Viewer re-parses blocks
|
|
45
|
+
// and wipes marks, so our bookkeeping is stale.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
appliedRef.current.clear();
|
|
48
|
+
}, [planKey]);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!enabled) return;
|
|
52
|
+
|
|
53
|
+
const viewer = viewerRef.current;
|
|
54
|
+
if (!viewer) return;
|
|
55
|
+
|
|
56
|
+
const eligible = externalAnnotations.filter(
|
|
57
|
+
a => a.type !== AnnotationType.GLOBAL_COMMENT && !a.diffContext && a.originalText,
|
|
58
|
+
);
|
|
59
|
+
const applied = appliedRef.current;
|
|
60
|
+
|
|
61
|
+
// Removals: previously applied but no longer present, or fingerprint changed.
|
|
62
|
+
const toRemove: string[] = [];
|
|
63
|
+
for (const [id, fp] of applied) {
|
|
64
|
+
const match = eligible.find(a => a.id === id);
|
|
65
|
+
if (!match || fingerprint(match) !== fp) {
|
|
66
|
+
toRemove.push(id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
toRemove.forEach(id => {
|
|
70
|
+
viewer.removeHighlight(id);
|
|
71
|
+
applied.delete(id);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Additions: eligible but not yet applied (includes re-adds from updates).
|
|
75
|
+
const toAdd = eligible.filter(a => !applied.has(a.id));
|
|
76
|
+
if (toAdd.length === 0) return;
|
|
77
|
+
|
|
78
|
+
// Paint delay matches the existing draft/share restore pattern —
|
|
79
|
+
// ensures blocks are mounted before we walk the DOM.
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
const v = viewerRef.current;
|
|
82
|
+
if (!v) return;
|
|
83
|
+
v.applySharedAnnotations(toAdd);
|
|
84
|
+
toAdd.forEach(a => applied.set(a.id, fingerprint(a)));
|
|
85
|
+
}, 100);
|
|
86
|
+
|
|
87
|
+
return () => clearTimeout(timer);
|
|
88
|
+
// viewerRef is a stable ref object and intentionally omitted from deps.
|
|
89
|
+
}, [externalAnnotations, enabled, planKey, resetCount]);
|
|
90
|
+
|
|
91
|
+
// Forget everything we've tracked and force a full re-apply on the next
|
|
92
|
+
// effect run. Callers invoke this after an external action has wiped the
|
|
93
|
+
// Viewer DOM out from under us (e.g. `clearAllHighlights()` during share
|
|
94
|
+
// import) so live externals get repainted.
|
|
95
|
+
const reset = useCallback(() => {
|
|
96
|
+
appliedRef.current.clear();
|
|
97
|
+
setResetCount(c => c + 1);
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
return { reset };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function fingerprint(a: Annotation): string {
|
|
104
|
+
return `${a.type}\u0000${a.originalText}`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-time external annotations via SSE with polling fallback.
|
|
3
|
+
*
|
|
4
|
+
* Primary transport: EventSource on /api/external-annotations/stream.
|
|
5
|
+
* Fallback: version-gated GET polling if SSE fails (e.g., proxy environments).
|
|
6
|
+
*
|
|
7
|
+
* Generic over the annotation type — plan editor uses Annotation,
|
|
8
|
+
* review editor uses CodeAnnotation. The hook is shape-agnostic;
|
|
9
|
+
* it just serializes/deserializes JSON.
|
|
10
|
+
*
|
|
11
|
+
* Gated by an `enabled` option — callers pass their API-mode signal
|
|
12
|
+
* to avoid SSE/polling in static or demo contexts where there is no server.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
16
|
+
import type { ExternalAnnotationEvent } from '../types';
|
|
17
|
+
|
|
18
|
+
const POLL_INTERVAL_MS = 500;
|
|
19
|
+
const STREAM_URL = '/api/external-annotations/stream';
|
|
20
|
+
const SNAPSHOT_URL = '/api/external-annotations';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Wire transport for external annotations. The hook owns the state machine
|
|
24
|
+
* (reducer, fallback-once SSE→polling, version-scoping, optimistic mutation,
|
|
25
|
+
* enabled gate); the transport owns ONLY the network/event wire.
|
|
26
|
+
*
|
|
27
|
+
* Default = Plannotator's SSE→polling behavior, verbatim. A host (Workspaces)
|
|
28
|
+
* can implement the same contract over its own backend (e.g. Durable Objects).
|
|
29
|
+
*/
|
|
30
|
+
export interface ExternalAnnotationTransport<T extends { id: string; source?: string }> {
|
|
31
|
+
/** Open the live event stream. Returns an unsubscribe fn that tears it down. */
|
|
32
|
+
subscribe(
|
|
33
|
+
onEvent: (event: ExternalAnnotationEvent<T>) => void,
|
|
34
|
+
onError: () => void,
|
|
35
|
+
): () => void;
|
|
36
|
+
/** Fetch a version-gated snapshot. Resolves null when there are no changes (304). */
|
|
37
|
+
getSnapshot(since: number): Promise<{ annotations: T[]; version: number } | null>;
|
|
38
|
+
add(items: T[]): Promise<void>;
|
|
39
|
+
remove(id: string): Promise<void>;
|
|
40
|
+
update(id: string, fields: Partial<T>): Promise<void>;
|
|
41
|
+
clear(source?: string): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Default transport — Plannotator's verbatim SSE→polling wire.
|
|
46
|
+
* EventSource on /api/external-annotations/stream; GET snapshot honoring 304→null;
|
|
47
|
+
* CRUD via DELETE/PATCH fetches (optimistic local mutation stays in the hook).
|
|
48
|
+
*/
|
|
49
|
+
function createDefaultTransport<T extends { id: string; source?: string }>(): ExternalAnnotationTransport<T> {
|
|
50
|
+
return {
|
|
51
|
+
subscribe(onEvent, onError) {
|
|
52
|
+
const es = new EventSource(STREAM_URL);
|
|
53
|
+
es.onmessage = (event) => {
|
|
54
|
+
try {
|
|
55
|
+
const parsed: ExternalAnnotationEvent<T> = JSON.parse(event.data);
|
|
56
|
+
onEvent(parsed);
|
|
57
|
+
} catch {
|
|
58
|
+
// Ignore malformed events (e.g., heartbeat comments)
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
es.onerror = () => {
|
|
62
|
+
onError();
|
|
63
|
+
};
|
|
64
|
+
return () => es.close();
|
|
65
|
+
},
|
|
66
|
+
async getSnapshot(since) {
|
|
67
|
+
const url = since > 0 ? `${SNAPSHOT_URL}?since=${since}` : SNAPSHOT_URL;
|
|
68
|
+
const res = await fetch(url);
|
|
69
|
+
if (res.status === 304) return null; // No changes
|
|
70
|
+
if (!res.ok) return null;
|
|
71
|
+
const data = await res.json();
|
|
72
|
+
// Skip (don't apply) on a malformed 200 — preserves existing annotations
|
|
73
|
+
// and the version cursor instead of clearing them, matching the
|
|
74
|
+
// pre-seam fetchSnapshot which only updated on well-formed payloads.
|
|
75
|
+
if (!Array.isArray(data.annotations) || typeof data.version !== 'number') return null;
|
|
76
|
+
return { annotations: data.annotations, version: data.version };
|
|
77
|
+
},
|
|
78
|
+
async add(items) {
|
|
79
|
+
await fetch(SNAPSHOT_URL, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ annotations: items }),
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
async remove(id) {
|
|
86
|
+
await fetch(`${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
87
|
+
},
|
|
88
|
+
async update(id, fields) {
|
|
89
|
+
await fetch(`${SNAPSHOT_URL}?id=${encodeURIComponent(id)}`, {
|
|
90
|
+
method: 'PATCH',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify(fields),
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
async clear(source) {
|
|
96
|
+
const qs = source ? `?source=${encodeURIComponent(source)}` : '';
|
|
97
|
+
await fetch(`${SNAPSHOT_URL}${qs}`, { method: 'DELETE' });
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let externalAnnotationTransport: ExternalAnnotationTransport<any> = createDefaultTransport();
|
|
103
|
+
|
|
104
|
+
/** Override the external-annotation wire transport. Call once at app startup. */
|
|
105
|
+
export function setExternalAnnotationTransport<T extends { id: string; source?: string }>(
|
|
106
|
+
transport: ExternalAnnotationTransport<T>,
|
|
107
|
+
): void {
|
|
108
|
+
externalAnnotationTransport = transport;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Reset to the default (Plannotator SSE→polling) transport. Mainly for tests. */
|
|
112
|
+
export function resetExternalAnnotationTransport(): void {
|
|
113
|
+
externalAnnotationTransport = createDefaultTransport();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface UseExternalAnnotationsReturn<T> {
|
|
117
|
+
externalAnnotations: T[];
|
|
118
|
+
updateExternalAnnotation: (id: string, updates: Partial<T>) => void;
|
|
119
|
+
deleteExternalAnnotation: (id: string) => void;
|
|
120
|
+
clearExternalAnnotations: (source?: string) => void;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function useExternalAnnotations<T extends { id: string; source?: string }>(
|
|
124
|
+
options?: { enabled?: boolean },
|
|
125
|
+
): UseExternalAnnotationsReturn<T> {
|
|
126
|
+
const enabled = options?.enabled ?? true;
|
|
127
|
+
const [annotations, setAnnotations] = useState<T[]>([]);
|
|
128
|
+
const versionRef = useRef(0);
|
|
129
|
+
const fallbackRef = useRef(false);
|
|
130
|
+
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
131
|
+
const receivedSnapshotRef = useRef(false);
|
|
132
|
+
// Holds the active transport, shared by subscribe/poll AND the CRUD callbacks so
|
|
133
|
+
// reads and writes never split across backends. (Re-)captured from the module
|
|
134
|
+
// global when the effect runs on enable (below), so a host that installs a
|
|
135
|
+
// transport before enabling annotations is honored, not the stale default.
|
|
136
|
+
const transportRef = useRef(externalAnnotationTransport as ExternalAnnotationTransport<T>);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!enabled) return;
|
|
140
|
+
let cancelled = false;
|
|
141
|
+
|
|
142
|
+
// Reset fallback state on (re-)enable so a false→true toggle re-attempts SSE
|
|
143
|
+
// instead of inheriting a stale "already fell back" flag and silently stalling.
|
|
144
|
+
fallbackRef.current = false;
|
|
145
|
+
receivedSnapshotRef.current = false;
|
|
146
|
+
|
|
147
|
+
// Capture the active transport at (re-)enable so a late host override is used.
|
|
148
|
+
transportRef.current = externalAnnotationTransport as ExternalAnnotationTransport<T>;
|
|
149
|
+
const transport = transportRef.current;
|
|
150
|
+
|
|
151
|
+
// --- Reducer (applies snapshot|add|remove|clear|update), verbatim ---
|
|
152
|
+
function applyEvent(parsed: ExternalAnnotationEvent<T>) {
|
|
153
|
+
switch (parsed.type) {
|
|
154
|
+
case 'snapshot':
|
|
155
|
+
receivedSnapshotRef.current = true;
|
|
156
|
+
setAnnotations(parsed.annotations);
|
|
157
|
+
break;
|
|
158
|
+
case 'add':
|
|
159
|
+
setAnnotations((prev) => [...prev, ...parsed.annotations]);
|
|
160
|
+
break;
|
|
161
|
+
case 'remove':
|
|
162
|
+
setAnnotations((prev) =>
|
|
163
|
+
prev.filter((a) => !parsed.ids.includes(a.id)),
|
|
164
|
+
);
|
|
165
|
+
break;
|
|
166
|
+
case 'clear':
|
|
167
|
+
setAnnotations((prev) =>
|
|
168
|
+
parsed.source
|
|
169
|
+
? prev.filter((a) => a.source !== parsed.source)
|
|
170
|
+
: [],
|
|
171
|
+
);
|
|
172
|
+
break;
|
|
173
|
+
case 'update':
|
|
174
|
+
setAnnotations((prev) =>
|
|
175
|
+
prev.map((a) => a.id === parsed.id ? (parsed.annotation as T) : a),
|
|
176
|
+
);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- SSE primary transport ---
|
|
182
|
+
// `let` (not `const`) so onError firing synchronously during subscribe — a host
|
|
183
|
+
// transport may do this when its channel is immediately unavailable — reads a
|
|
184
|
+
// declared-but-undefined binding (no-op) instead of hitting the TDZ and throwing.
|
|
185
|
+
let unsubscribe: (() => void) | undefined;
|
|
186
|
+
unsubscribe = transport.subscribe(
|
|
187
|
+
(parsed) => {
|
|
188
|
+
if (cancelled) return;
|
|
189
|
+
applyEvent(parsed);
|
|
190
|
+
},
|
|
191
|
+
() => {
|
|
192
|
+
// If we never received a snapshot, SSE isn't working — fall back to polling
|
|
193
|
+
if (!receivedSnapshotRef.current && !fallbackRef.current) {
|
|
194
|
+
fallbackRef.current = true;
|
|
195
|
+
unsubscribe?.();
|
|
196
|
+
startPolling();
|
|
197
|
+
}
|
|
198
|
+
// Otherwise, EventSource will auto-reconnect and we'll get a fresh snapshot
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
// --- Polling fallback ---
|
|
203
|
+
function startPolling() {
|
|
204
|
+
if (cancelled) return;
|
|
205
|
+
|
|
206
|
+
// Initial fetch
|
|
207
|
+
fetchSnapshot();
|
|
208
|
+
|
|
209
|
+
pollTimerRef.current = setInterval(() => {
|
|
210
|
+
if (cancelled) return;
|
|
211
|
+
fetchSnapshot();
|
|
212
|
+
}, POLL_INTERVAL_MS);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function fetchSnapshot() {
|
|
216
|
+
try {
|
|
217
|
+
const snap = await transport.getSnapshot(versionRef.current);
|
|
218
|
+
if (snap === null) return; // No changes (304) or unavailable
|
|
219
|
+
setAnnotations(snap.annotations);
|
|
220
|
+
versionRef.current = snap.version;
|
|
221
|
+
} catch {
|
|
222
|
+
// Silent — next poll will retry
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return () => {
|
|
227
|
+
cancelled = true;
|
|
228
|
+
unsubscribe?.();
|
|
229
|
+
if (pollTimerRef.current) {
|
|
230
|
+
clearInterval(pollTimerRef.current);
|
|
231
|
+
pollTimerRef.current = null;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}, [enabled]);
|
|
235
|
+
|
|
236
|
+
const deleteExternalAnnotation = useCallback(async (id: string) => {
|
|
237
|
+
// Optimistic update
|
|
238
|
+
setAnnotations((prev) => prev.filter((a) => a.id !== id));
|
|
239
|
+
try {
|
|
240
|
+
await transportRef.current.remove(id);
|
|
241
|
+
} catch {
|
|
242
|
+
// SSE will reconcile on next event
|
|
243
|
+
}
|
|
244
|
+
}, []);
|
|
245
|
+
|
|
246
|
+
const clearExternalAnnotations = useCallback(async (source?: string) => {
|
|
247
|
+
// Optimistic update
|
|
248
|
+
setAnnotations((prev) =>
|
|
249
|
+
source ? prev.filter((a) => a.source !== source) : [],
|
|
250
|
+
);
|
|
251
|
+
try {
|
|
252
|
+
await transportRef.current.clear(source);
|
|
253
|
+
} catch {
|
|
254
|
+
// SSE will reconcile on next event
|
|
255
|
+
}
|
|
256
|
+
}, []);
|
|
257
|
+
|
|
258
|
+
const updateExternalAnnotation = useCallback(async (id: string, updates: Partial<T>) => {
|
|
259
|
+
setAnnotations((prev) => prev.map((a) => (a.id === id ? { ...a, ...updates } : a)));
|
|
260
|
+
try {
|
|
261
|
+
await transportRef.current.update(id, updates);
|
|
262
|
+
} catch {
|
|
263
|
+
// SSE will reconcile on next event
|
|
264
|
+
}
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
return { externalAnnotations: annotations, updateExternalAnnotation, deleteExternalAnnotation, clearExternalAnnotations };
|
|
268
|
+
}
|