@plannotator/ui 0.30.0 → 0.32.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 +46 -1
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +158 -36
- package/components/AnnotationToolbar.tsx +50 -30
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +238 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/GraphvizBlock.tsx +86 -7
- package/components/HtmlSurfaceControls.tsx +170 -0
- package/components/InlineMarkdown.tsx +25 -4
- package/components/KeyboardShortcuts.tsx +9 -0
- package/components/Landing.tsx +1 -1
- package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
- package/components/MarkdownEditor/embedPicker.ts +349 -0
- package/components/MarkdownEditor.tsx +12 -0
- package/components/MermaidBlock.tsx +60 -26
- package/components/ModeToggle.tsx +2 -1
- package/components/PermissionModeSetup.tsx +24 -5
- package/components/PinpointOverlay.tsx +11 -6
- package/components/PlanHeaderMenu.tsx +140 -1
- package/components/SearchableSelect.tsx +2 -0
- package/components/Settings.tsx +175 -10
- package/components/SkillReferenceMenu.tsx +9 -0
- package/components/StickyHeaderLane.tsx +9 -2
- package/components/TableOfContents.tsx +9 -4
- package/components/TextShimmer.tsx +8 -5
- package/components/ThemeProvider.tsx +43 -1
- package/components/ThemeTab.tsx +52 -1
- package/components/Tooltip.tsx +3 -1
- package/components/Viewer.tsx +19 -5
- package/components/VimTargetReticle.tsx +12 -4
- package/components/ai/DocumentAIChatPanel.tsx +1 -0
- package/components/blocks/MathBlock.tsx +26 -14
- package/components/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +320 -41
- package/components/html-viewer/bridge-script.ts +360 -72
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/hostThreads.ts +37 -0
- package/components/html-viewer/index.ts +9 -0
- package/components/html-viewer/unanchored.ts +47 -0
- package/components/html-viewer/useHtmlAnnotation.ts +240 -61
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
- package/components/sidebar/FileBrowser.tsx +17 -5
- package/components/sidebar/SidebarContainer.tsx +124 -28
- package/components/ui/button.tsx +10 -8
- package/components/ui/dialog.tsx +35 -25
- package/config/index.ts +6 -1
- package/config/reviewView.ts +42 -9
- package/config/settings.ts +141 -0
- package/configure.ts +32 -0
- package/hooks/useAIProviderConfig.ts +8 -7
- package/hooks/useActiveSection.ts +6 -4
- package/hooks/useAgentJobs.ts +3 -0
- package/hooks/useAnnotationHighlighter.ts +20 -0
- package/hooks/useHtmlRefresh.ts +149 -0
- package/hooks/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useMathRenderer.ts +30 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useSharing.ts +31 -5
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +5 -2
- package/shortcuts/index.ts +3 -0
- package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
- package/styles.css +1 -1
- package/theme.css +229 -0
- package/types.ts +35 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/generateIdentity.ts +64 -14
- package/utils/htmlChrome.ts +20 -16
- package/utils/identity-tater.ts +36 -0
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/math-eager.ts +25 -0
- package/utils/math.ts +146 -0
- package/utils/mermaid-eager.ts +28 -0
- package/utils/mermaid.ts +132 -0
- package/utils/parser.ts +75 -2
- package/utils/quickLabels.ts +13 -0
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -0
- package/webmcp/activity.ts +46 -0
- package/webmcp/changes.ts +227 -0
- package/webmcp/index.ts +72 -0
- package/webmcp/modelContext.ts +103 -0
- package/webmcp/nudges.ts +174 -0
- package/webmcp/policy.ts +50 -0
- package/webmcp/preference.ts +50 -0
- package/webmcp/schema.ts +81 -0
- package/webmcp/toolset.ts +337 -0
- package/webmcp/useToolset.ts +74 -0
- package/components/PlanAIAnnouncementDialog.tsx +0 -187
- package/components/VimModeAnnouncementDialog.tsx +0 -557
- package/utils/planAIAnnouncement.ts +0 -17
- package/utils/vimModeAnnouncement.ts +0 -23
package/configure.ts
CHANGED
|
@@ -8,6 +8,9 @@ import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDra
|
|
|
8
8
|
import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations';
|
|
9
9
|
import { setAITransport, type AITransport } from './hooks/useAIChat';
|
|
10
10
|
import { setSkillCatalogTransport, setSkillContentTransport, type SkillCatalogTransport, type SkillContentTransport } from './utils/skillCatalog';
|
|
11
|
+
import { setWebMcpPolicy, type WebMcpPolicy } from './webmcp/policy';
|
|
12
|
+
import { setMathRendererLoader, type MathRenderer, type MathRendererLoader } from './utils/math';
|
|
13
|
+
import { setIdentityGenerator, type IdentityGenerator } from './utils/generateIdentity';
|
|
11
14
|
import { configStore } from './config';
|
|
12
15
|
import type { ServerSyncFn } from './config/configStore';
|
|
13
16
|
import type { ExternalAnnotationEvent, VaultNode } from './types';
|
|
@@ -31,6 +34,10 @@ export type {
|
|
|
31
34
|
SkillCatalogTransport,
|
|
32
35
|
SkillContentTransport,
|
|
33
36
|
ServerSyncFn,
|
|
37
|
+
WebMcpPolicy,
|
|
38
|
+
MathRenderer,
|
|
39
|
+
MathRendererLoader,
|
|
40
|
+
IdentityGenerator,
|
|
34
41
|
};
|
|
35
42
|
|
|
36
43
|
type ExternalAnnotationBase = { id: string; source?: string };
|
|
@@ -56,6 +63,28 @@ export interface PlannotatorUIConfig {
|
|
|
56
63
|
/** Human-only skill contents request for feedback injection. Default: `GET /api/skills/content?name=` on the page origin. */
|
|
57
64
|
skillContentTransport?: SkillContentTransport;
|
|
58
65
|
serverSync?: ServerSyncFn;
|
|
66
|
+
/**
|
|
67
|
+
* WebMCP provider policy: `{ enabled, namePrefix }`. Default: enabled
|
|
68
|
+
* whenever the browser exposes `document.modelContext`, with the
|
|
69
|
+
* `plannotator.` prefix. There is no confirmation seam because the catalog
|
|
70
|
+
* exposes nothing consequential: no tool decides, submits or closes.
|
|
71
|
+
*/
|
|
72
|
+
webmcp?: WebMcpPolicy;
|
|
73
|
+
/**
|
|
74
|
+
* How the math renderer is loaded when no renderer is registered before the
|
|
75
|
+
* first math node renders. Default: `import('katex')` (JS only; the
|
|
76
|
+
* stylesheet stays the host's job). A host that wants KaTeX and its CSS on
|
|
77
|
+
* one lazy chunk passes a loader that imports both. Hosts that want math
|
|
78
|
+
* typeset on the first commit instead import `@plannotator/ui/utils/math-eager`.
|
|
79
|
+
*/
|
|
80
|
+
mathRendererLoader?: MathRendererLoader;
|
|
81
|
+
/**
|
|
82
|
+
* Synchronous generator for the default "tater" display name, used only when
|
|
83
|
+
* no `identityProvider` is installed. Default: a small built-in pool of the
|
|
84
|
+
* same `adjective-noun-tater` shape. Plannotator registers the full
|
|
85
|
+
* dictionary by importing `@plannotator/ui/utils/identity-tater`.
|
|
86
|
+
*/
|
|
87
|
+
identityGenerator?: IdentityGenerator;
|
|
59
88
|
/** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
|
|
60
89
|
loadSettingsFromBackend?: boolean;
|
|
61
90
|
}
|
|
@@ -73,6 +102,9 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
|
|
|
73
102
|
if (config.skillCatalogTransport) setSkillCatalogTransport(config.skillCatalogTransport);
|
|
74
103
|
if (config.skillContentTransport) setSkillContentTransport(config.skillContentTransport);
|
|
75
104
|
if (config.serverSync) configStore.setServerSync(config.serverSync);
|
|
105
|
+
if (config.webmcp) setWebMcpPolicy(config.webmcp);
|
|
106
|
+
if (config.mathRendererLoader) setMathRendererLoader(config.mathRendererLoader);
|
|
107
|
+
if (config.identityGenerator) setIdentityGenerator(config.identityGenerator);
|
|
76
108
|
// Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
|
|
77
109
|
if (config.loadSettingsFromBackend) configStore.loadFromBackend();
|
|
78
110
|
}
|
|
@@ -59,14 +59,15 @@ export function useAIProviderConfig({
|
|
|
59
59
|
// Auto-resolve provider/model once capabilities are known.
|
|
60
60
|
useEffect(() => {
|
|
61
61
|
if (!available || providers.length === 0) return;
|
|
62
|
+
const saved = getAIProviderSettings();
|
|
63
|
+
const selection = resolveAIProviderSelection({
|
|
64
|
+
providers,
|
|
65
|
+
origin,
|
|
66
|
+
settings: saved,
|
|
67
|
+
serverDefaultProvider: defaultProvider,
|
|
68
|
+
});
|
|
69
|
+
|
|
62
70
|
setAiConfig(prev => {
|
|
63
|
-
const saved = getAIProviderSettings();
|
|
64
|
-
const selection = resolveAIProviderSelection({
|
|
65
|
-
providers,
|
|
66
|
-
origin,
|
|
67
|
-
settings: saved,
|
|
68
|
-
serverDefaultProvider: defaultProvider,
|
|
69
|
-
});
|
|
70
71
|
if (prev.providerId === selection.providerId && prev.model === selection.model) return prev;
|
|
71
72
|
return {
|
|
72
73
|
...prev,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState, useRef } from 'react';
|
|
2
|
+
import { getScrollViewportIntersectionRoot } from './useScrollViewport';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Track which heading section is currently visible in the viewport
|
|
@@ -16,11 +17,12 @@ export function useActiveSection(
|
|
|
16
17
|
const observerRef = useRef<IntersectionObserver | null>(null);
|
|
17
18
|
|
|
18
19
|
useEffect(() => {
|
|
19
|
-
const
|
|
20
|
-
|
|
20
|
+
const contentContainer = containerRef.current ?? scrollElement;
|
|
21
|
+
const viewport = scrollElement ?? contentContainer;
|
|
22
|
+
if (!contentContainer || !viewport) return;
|
|
21
23
|
|
|
22
24
|
// Find all heading elements with data-block-id
|
|
23
|
-
const headings =
|
|
25
|
+
const headings = contentContainer.querySelectorAll('[data-block-type="heading"]');
|
|
24
26
|
if (headings.length === 0) return;
|
|
25
27
|
|
|
26
28
|
// Track which headings are currently intersecting
|
|
@@ -57,7 +59,7 @@ export function useActiveSection(
|
|
|
57
59
|
}
|
|
58
60
|
},
|
|
59
61
|
{
|
|
60
|
-
root:
|
|
62
|
+
root: getScrollViewportIntersectionRoot(viewport),
|
|
61
63
|
rootMargin: '-80px 0px -80% 0px', // Activate when heading is near top
|
|
62
64
|
threshold: [0, 0.1, 0.5, 1.0],
|
|
63
65
|
}
|
package/hooks/useAgentJobs.ts
CHANGED
|
@@ -37,6 +37,9 @@ export type AgentLaunchParams = {
|
|
|
37
37
|
* schema-capable engine and starts a new, normal guide job rather than
|
|
38
38
|
* mutating the failed one in place. */
|
|
39
39
|
repairOf?: string;
|
|
40
|
+
/** Reviewer-supplied extra instructions (#1265), appended to the Guided
|
|
41
|
+
* Review organizer prompt. Guide launches only; other providers ignore it. */
|
|
42
|
+
instructions?: string;
|
|
40
43
|
};
|
|
41
44
|
|
|
42
45
|
/** Does a job belong to the given review context? Jobs launched against a PR
|
|
@@ -27,6 +27,7 @@ export interface CommentPopoverState {
|
|
|
27
27
|
selectedText?: string;
|
|
28
28
|
initialText?: string;
|
|
29
29
|
source?: any;
|
|
30
|
+
draftKey: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export interface QuickLabelPickerState {
|
|
@@ -46,6 +47,21 @@ type MathAnnotationSource = {
|
|
|
46
47
|
const isMathAnnotationSource = (source: any): source is MathAnnotationSource =>
|
|
47
48
|
source?.kind === 'math';
|
|
48
49
|
|
|
50
|
+
function commentDraftTargetKey(source: any, selectedText: string): string {
|
|
51
|
+
if (isMathAnnotationSource(source)) {
|
|
52
|
+
return `math:${source.blockId}:${source.text}`;
|
|
53
|
+
}
|
|
54
|
+
const start = source?.startMeta;
|
|
55
|
+
const end = source?.endMeta;
|
|
56
|
+
const startKey = start
|
|
57
|
+
? `${start.parentTagName}:${start.parentIndex}:${start.textOffset}`
|
|
58
|
+
: 'unknown';
|
|
59
|
+
const endKey = end
|
|
60
|
+
? `${end.parentTagName}:${end.parentIndex}:${end.textOffset}`
|
|
61
|
+
: 'unknown';
|
|
62
|
+
return `selection:${startKey}:${endKey}:${selectedText}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
type MathAnnotationTarget = {
|
|
50
66
|
element: HTMLElement;
|
|
51
67
|
blockId: string;
|
|
@@ -859,6 +875,7 @@ export function useAnnotationHighlighter({
|
|
|
859
875
|
contextText: source.text.slice(0, 80),
|
|
860
876
|
selectedText: source.text,
|
|
861
877
|
source,
|
|
878
|
+
draftKey: commentDraftTargetKey(source, source.text),
|
|
862
879
|
});
|
|
863
880
|
} else if (effectiveMode === 'quickLabel') {
|
|
864
881
|
pendingSourceRef.current = source;
|
|
@@ -944,6 +961,7 @@ export function useAnnotationHighlighter({
|
|
|
944
961
|
contextText: source.text.slice(0, 80),
|
|
945
962
|
selectedText: source.text,
|
|
946
963
|
source,
|
|
964
|
+
draftKey: commentDraftTargetKey(source, source.text),
|
|
947
965
|
});
|
|
948
966
|
return;
|
|
949
967
|
}
|
|
@@ -1061,6 +1079,7 @@ export function useAnnotationHighlighter({
|
|
|
1061
1079
|
contextText: source.text.slice(0, 80),
|
|
1062
1080
|
selectedText: source.text,
|
|
1063
1081
|
source,
|
|
1082
|
+
draftKey: commentDraftTargetKey(source, source.text),
|
|
1064
1083
|
});
|
|
1065
1084
|
return;
|
|
1066
1085
|
}
|
|
@@ -1204,6 +1223,7 @@ export function useAnnotationHighlighter({
|
|
|
1204
1223
|
selectedText: toolbarState.selectionText,
|
|
1205
1224
|
initialText: initialChar,
|
|
1206
1225
|
source: toolbarState.source,
|
|
1226
|
+
draftKey: commentDraftTargetKey(toolbarState.source, toolbarState.selectionText),
|
|
1207
1227
|
});
|
|
1208
1228
|
setToolbarState(null);
|
|
1209
1229
|
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/** What a host's `fetchSnapshot` resolves to. */
|
|
4
|
+
export type HtmlRefreshSnapshot =
|
|
5
|
+
| { status: 'ok'; rawHtml: string }
|
|
6
|
+
| { status: 'missing' }
|
|
7
|
+
| { status: 'unavailable' };
|
|
8
|
+
|
|
9
|
+
/** The outcome of one `refresh()` call, for host notifications (toasts). */
|
|
10
|
+
export type HtmlRefreshResult = 'refreshed' | 'missing' | 'unavailable';
|
|
11
|
+
|
|
12
|
+
export interface UseHtmlRefreshOptions {
|
|
13
|
+
/** Whether refresh is offered at all. Default true. */
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Identity of the document under refresh (a path, an id). A change
|
|
17
|
+
* cancels any in-flight fetch and any pending restore acknowledgement, so
|
|
18
|
+
* a snapshot for the previous document can never land on the next one.
|
|
19
|
+
* `null` means no document: `canRefresh` is false. Omit it when the host
|
|
20
|
+
* has a single document.
|
|
21
|
+
*/
|
|
22
|
+
documentKey?: string | null;
|
|
23
|
+
/** Fetch the current bytes of the document. Called with `documentKey`.
|
|
24
|
+
* A rejection is treated as `{ status: 'unavailable' }`. */
|
|
25
|
+
fetchSnapshot: (documentKey: string | null) => Promise<HtmlRefreshSnapshot>;
|
|
26
|
+
/** Apply the refreshed bytes (the host owns the viewer's `rawHtml`). */
|
|
27
|
+
onSnapshot: (rawHtml: string) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Once per refresh: the ids the remounted viewer could not re-anchor,
|
|
30
|
+
* possibly empty. Wire the viewer's `onUnanchoredChange` to the returned
|
|
31
|
+
* `reportAnnotationRestore`; only the first report after a refresh is
|
|
32
|
+
* forwarded, and only while the document and reload generation match.
|
|
33
|
+
*/
|
|
34
|
+
onUnanchored?: (ids: string[]) => void;
|
|
35
|
+
/** The outcome of each `refresh()` call that reached a decision. */
|
|
36
|
+
onResult?: (result: HtmlRefreshResult) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface UseHtmlRefreshReturn {
|
|
40
|
+
canRefresh: boolean;
|
|
41
|
+
isRefreshing: boolean;
|
|
42
|
+
/** Bumps after every applied snapshot. Key the viewer on it to remount. */
|
|
43
|
+
reloadGeneration: number;
|
|
44
|
+
refresh: () => Promise<void>;
|
|
45
|
+
/** Feed the viewer's `onUnanchoredChange` report here. */
|
|
46
|
+
reportAnnotationRestore: (missingIds: string[]) => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Re-fetch a rendered HTML document from the host's source and remount the
|
|
51
|
+
* viewer on it, keeping the annotations the viewer can still anchor.
|
|
52
|
+
*
|
|
53
|
+
* Backend-agnostic: the host supplies `fetchSnapshot` (Plannotator wraps its
|
|
54
|
+
* `/api/doc` read; a host with a document store passes its own read). The
|
|
55
|
+
* hook owns the guards: an in-flight fetch that is superseded by a newer
|
|
56
|
+
* refresh, or by a document change, is dropped before `onSnapshot`; the
|
|
57
|
+
* restore acknowledgement is armed per reload generation and consumed by
|
|
58
|
+
* the first viewer report for that generation.
|
|
59
|
+
*/
|
|
60
|
+
export function useHtmlRefresh({
|
|
61
|
+
enabled = true,
|
|
62
|
+
documentKey,
|
|
63
|
+
fetchSnapshot,
|
|
64
|
+
onSnapshot,
|
|
65
|
+
onUnanchored,
|
|
66
|
+
onResult,
|
|
67
|
+
}: UseHtmlRefreshOptions): UseHtmlRefreshReturn {
|
|
68
|
+
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
69
|
+
const [reloadGeneration, setReloadGeneration] = useState(0);
|
|
70
|
+
const keyed = documentKey !== undefined;
|
|
71
|
+
const activeKey = keyed ? documentKey : null;
|
|
72
|
+
const activeKeyRef = useRef(activeKey);
|
|
73
|
+
const requestRef = useRef(0);
|
|
74
|
+
const reloadGenerationRef = useRef(0);
|
|
75
|
+
const restorePendingRef = useRef<{ key: string | null; generation: number } | null>(null);
|
|
76
|
+
const onUnanchoredRef = useRef(onUnanchored);
|
|
77
|
+
onUnanchoredRef.current = onUnanchored;
|
|
78
|
+
const onResultRef = useRef(onResult);
|
|
79
|
+
onResultRef.current = onResult;
|
|
80
|
+
const canRefresh = enabled && (!keyed || !!documentKey);
|
|
81
|
+
|
|
82
|
+
useLayoutEffect(() => {
|
|
83
|
+
if (activeKeyRef.current !== activeKey) {
|
|
84
|
+
requestRef.current += 1;
|
|
85
|
+
restorePendingRef.current = null;
|
|
86
|
+
setIsRefreshing(false);
|
|
87
|
+
}
|
|
88
|
+
activeKeyRef.current = activeKey;
|
|
89
|
+
}, [activeKey]);
|
|
90
|
+
|
|
91
|
+
const refresh = useCallback(async () => {
|
|
92
|
+
if (!canRefresh) return;
|
|
93
|
+
|
|
94
|
+
const requestKey = activeKey;
|
|
95
|
+
const requestId = ++requestRef.current;
|
|
96
|
+
setIsRefreshing(true);
|
|
97
|
+
try {
|
|
98
|
+
// A rejecting fetch is an unavailable snapshot: the host hears it
|
|
99
|
+
// through onResult like any other outcome, never as an unhandled
|
|
100
|
+
// rejection out of refresh().
|
|
101
|
+
let result: HtmlRefreshSnapshot;
|
|
102
|
+
try {
|
|
103
|
+
result = await fetchSnapshot(requestKey);
|
|
104
|
+
} catch {
|
|
105
|
+
result = { status: 'unavailable' };
|
|
106
|
+
}
|
|
107
|
+
if (requestId !== requestRef.current || activeKeyRef.current !== requestKey) return;
|
|
108
|
+
|
|
109
|
+
if (result.status === 'missing' || result.status === 'unavailable') {
|
|
110
|
+
onResultRef.current?.(result.status);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onSnapshot(result.rawHtml);
|
|
115
|
+
const nextGeneration = reloadGenerationRef.current + 1;
|
|
116
|
+
reloadGenerationRef.current = nextGeneration;
|
|
117
|
+
// Armed until the remounted viewer's bridge reports its restore. The
|
|
118
|
+
// bridge emits "unanchored" only when the set CHANGES from its initial
|
|
119
|
+
// empty state, so a pass that restores everything never posts and this
|
|
120
|
+
// stays armed; that is harmless because the next refresh replaces it
|
|
121
|
+
// and a document change clears it.
|
|
122
|
+
restorePendingRef.current = { key: requestKey, generation: nextGeneration };
|
|
123
|
+
setReloadGeneration(nextGeneration);
|
|
124
|
+
onResultRef.current?.('refreshed');
|
|
125
|
+
} finally {
|
|
126
|
+
if (requestId === requestRef.current) setIsRefreshing(false);
|
|
127
|
+
}
|
|
128
|
+
}, [activeKey, canRefresh, fetchSnapshot, onSnapshot]);
|
|
129
|
+
|
|
130
|
+
const reportAnnotationRestore = useCallback((missingIds: string[]) => {
|
|
131
|
+
const pending = restorePendingRef.current;
|
|
132
|
+
if (
|
|
133
|
+
!pending ||
|
|
134
|
+
pending.key !== activeKeyRef.current ||
|
|
135
|
+
pending.generation !== reloadGenerationRef.current
|
|
136
|
+
) return;
|
|
137
|
+
|
|
138
|
+
restorePendingRef.current = null;
|
|
139
|
+
onUnanchoredRef.current?.(missingIds);
|
|
140
|
+
}, []);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
canRefresh,
|
|
144
|
+
isRefreshing,
|
|
145
|
+
reloadGeneration,
|
|
146
|
+
refresh,
|
|
147
|
+
reportAnnotationRestore,
|
|
148
|
+
};
|
|
149
|
+
}
|
package/hooks/useIsMobile.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { useState, useEffect } from 'react';
|
|
2
2
|
|
|
3
|
+
/** Maximum CSS viewport width that can enter Plannotator's compact touch shell. */
|
|
4
|
+
export const COMPACT_TOUCH_LAYOUT_MAX_WIDTH = 1024;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Canonical media query for the compact application shell.
|
|
8
|
+
*
|
|
9
|
+
* The primary pointer is intentional: `any-pointer: coarse` also matches
|
|
10
|
+
* touchscreen laptops whose primary mouse or trackpad needs the desktop shell.
|
|
11
|
+
*/
|
|
12
|
+
export const COMPACT_TOUCH_LAYOUT_MEDIA_QUERY =
|
|
13
|
+
`(max-width: ${COMPACT_TOUCH_LAYOUT_MAX_WIDTH}px) and (pointer: coarse)`;
|
|
14
|
+
|
|
3
15
|
export function useIsMobile(breakpoint = 768): boolean {
|
|
4
16
|
const [isMobile, setIsMobile] = useState(
|
|
5
17
|
() => typeof window !== 'undefined' ? window.innerWidth < breakpoint : false
|
|
@@ -15,3 +27,28 @@ export function useIsMobile(breakpoint = 768): boolean {
|
|
|
15
27
|
|
|
16
28
|
return isMobile;
|
|
17
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reports whether the current viewport needs Plannotator's compact touch shell.
|
|
33
|
+
* Plan and Code Review must share this decision so responsive chrome and scroll
|
|
34
|
+
* ownership cannot diverge on hybrid devices.
|
|
35
|
+
*/
|
|
36
|
+
export function useCompactTouchLayout(): boolean {
|
|
37
|
+
const [isCompactTouchLayout, setIsCompactTouchLayout] = useState(
|
|
38
|
+
() => typeof window !== 'undefined'
|
|
39
|
+
&& window.matchMedia(COMPACT_TOUCH_LAYOUT_MEDIA_QUERY).matches,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const mediaQuery = window.matchMedia(COMPACT_TOUCH_LAYOUT_MEDIA_QUERY);
|
|
44
|
+
const onChange = (event: MediaQueryListEvent) => {
|
|
45
|
+
setIsCompactTouchLayout(event.matches);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
mediaQuery.addEventListener('change', onChange);
|
|
49
|
+
setIsCompactTouchLayout(mediaQuery.matches);
|
|
50
|
+
return () => mediaQuery.removeEventListener('change', onChange);
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
return isCompactTouchLayout;
|
|
54
|
+
}
|
package/hooks/useLinkedDoc.ts
CHANGED
|
@@ -90,6 +90,9 @@ export interface UseLinkedDocOptions {
|
|
|
90
90
|
/** Let the host initialize/restore editable document state and optionally
|
|
91
91
|
* override the markdown displayed for this file. */
|
|
92
92
|
onDocumentLoaded?: (doc: LinkedDocLoadData) => string | undefined;
|
|
93
|
+
/** Notify the host after any fetched or already-loaded destination has been
|
|
94
|
+
* activated, including HTML documents and backlinks to the source. */
|
|
95
|
+
onDocumentActivated?: (doc: LinkedDocLoadData & { filepath: string }) => void;
|
|
93
96
|
/** Read current host-owned text when caching a linked doc. */
|
|
94
97
|
getDocumentMarkdown?: (filepath: string, fallback?: string) => string | undefined;
|
|
95
98
|
/** Let the host restore any state that was suspended while a linked doc was active. */
|
|
@@ -185,6 +188,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
185
188
|
sourceConverted,
|
|
186
189
|
onBeforeNavigate,
|
|
187
190
|
onDocumentLoaded,
|
|
191
|
+
onDocumentActivated,
|
|
188
192
|
getDocumentMarkdown,
|
|
189
193
|
onAfterBack,
|
|
190
194
|
} = options;
|
|
@@ -292,6 +296,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
292
296
|
// annotations intact.
|
|
293
297
|
if (sourceFilePath && data.filepath === sourceFilePath && savedPlanState.current) {
|
|
294
298
|
back();
|
|
299
|
+
onDocumentActivated?.(data);
|
|
295
300
|
return;
|
|
296
301
|
}
|
|
297
302
|
|
|
@@ -364,6 +369,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
364
369
|
});
|
|
365
370
|
setError(null);
|
|
366
371
|
sidebar.open(targetTab ?? "toc");
|
|
372
|
+
onDocumentActivated?.(data);
|
|
367
373
|
|
|
368
374
|
// Re-apply cached annotations after DOM settles
|
|
369
375
|
if (cached?.annotations.length) {
|
|
@@ -393,6 +399,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
393
399
|
sourceFilePath,
|
|
394
400
|
onBeforeNavigate,
|
|
395
401
|
onDocumentLoaded,
|
|
402
|
+
onDocumentActivated,
|
|
396
403
|
getDocumentMarkdown,
|
|
397
404
|
back,
|
|
398
405
|
]);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
getMathRenderer,
|
|
4
|
+
loadMathRenderer,
|
|
5
|
+
subscribeMathRenderer,
|
|
6
|
+
type MathRenderer,
|
|
7
|
+
} from '../utils/math';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The registered math renderer, read synchronously during render.
|
|
11
|
+
*
|
|
12
|
+
* With the slot filled before mount (Plannotator: `utils/math-eager`) this
|
|
13
|
+
* returns KaTeX on the first render and the effect below is a no-op, so the
|
|
14
|
+
* typeset HTML is in the first commit. With the slot empty it returns `null`,
|
|
15
|
+
* kicks off `loadMathRenderer()` from an effect, and the subscription
|
|
16
|
+
* re-renders the caller once the renderer lands. A rejected load is left to
|
|
17
|
+
* the loader's retry contract; the caller keeps showing the TeX placeholder.
|
|
18
|
+
*/
|
|
19
|
+
export function useMathRenderer(): MathRenderer | null {
|
|
20
|
+
const renderer = useSyncExternalStore(subscribeMathRenderer, getMathRenderer, getMathRenderer);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (renderer) return;
|
|
24
|
+
loadMathRenderer().catch(() => {
|
|
25
|
+
/* placeholder stays; the next mount retries */
|
|
26
|
+
});
|
|
27
|
+
}, [renderer]);
|
|
28
|
+
|
|
29
|
+
return renderer;
|
|
30
|
+
}
|
|
@@ -1,5 +1,79 @@
|
|
|
1
1
|
import { createContext, useContext, createElement, type ReactNode } from 'react';
|
|
2
2
|
|
|
3
|
+
/** Return whether this element is the browser's page-scrolling element. */
|
|
4
|
+
export function isDocumentScrollViewport(
|
|
5
|
+
viewport: HTMLElement | null,
|
|
6
|
+
): boolean {
|
|
7
|
+
return viewport !== null
|
|
8
|
+
&& viewport.ownerDocument.scrollingElement === viewport;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Resolve the document scroller without assuming whether WebKit chose html or body. */
|
|
12
|
+
export function getDocumentScrollViewport(
|
|
13
|
+
targetDocument: Document = document,
|
|
14
|
+
): HTMLElement | null {
|
|
15
|
+
return targetDocument.scrollingElement as HTMLElement | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Page scrolling needs viewport geometry, not the document element's full
|
|
20
|
+
* content rect. Element-backed scroll areas retain their incumbent geometry.
|
|
21
|
+
*/
|
|
22
|
+
export function getScrollViewportRect(viewport: HTMLElement): DOMRect {
|
|
23
|
+
if (!isDocumentScrollViewport(viewport)) return viewport.getBoundingClientRect();
|
|
24
|
+
|
|
25
|
+
const targetWindow = viewport.ownerDocument.defaultView;
|
|
26
|
+
const visualViewport = targetWindow?.visualViewport;
|
|
27
|
+
const left = visualViewport?.offsetLeft ?? 0;
|
|
28
|
+
const top = visualViewport?.offsetTop ?? 0;
|
|
29
|
+
const width = visualViewport?.width ?? targetWindow?.innerWidth ?? viewport.clientWidth;
|
|
30
|
+
const height = visualViewport?.height ?? targetWindow?.innerHeight ?? viewport.clientHeight;
|
|
31
|
+
const DOMRectConstructor = targetWindow?.DOMRect ?? DOMRect;
|
|
32
|
+
return new DOMRectConstructor(left, top, width, height);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getScrollViewportTop(viewport: HTMLElement): number {
|
|
36
|
+
if (!isDocumentScrollViewport(viewport)) return viewport.scrollTop;
|
|
37
|
+
return viewport.ownerDocument.defaultView?.scrollY ?? viewport.scrollTop;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function scrollViewportTo(
|
|
41
|
+
viewport: HTMLElement,
|
|
42
|
+
options: ScrollToOptions,
|
|
43
|
+
): void {
|
|
44
|
+
if (!isDocumentScrollViewport(viewport)) {
|
|
45
|
+
viewport.scrollTo(options);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
viewport.ownerDocument.defaultView?.scrollTo(options);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function offsetScrollViewport(viewport: HTMLElement, delta: number): void {
|
|
52
|
+
if (!isDocumentScrollViewport(viewport)) {
|
|
53
|
+
viewport.scrollTop += delta;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
viewport.ownerDocument.defaultView?.scrollBy({ top: delta, behavior: 'auto' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function addScrollViewportListener(
|
|
60
|
+
viewport: HTMLElement,
|
|
61
|
+
listener: EventListener,
|
|
62
|
+
): () => void {
|
|
63
|
+
const target: EventTarget = isDocumentScrollViewport(viewport)
|
|
64
|
+
? viewport.ownerDocument.defaultView ?? viewport
|
|
65
|
+
: viewport;
|
|
66
|
+
target.addEventListener('scroll', listener, { passive: true });
|
|
67
|
+
return () => target.removeEventListener('scroll', listener);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A document scroll uses the browser viewport as its IntersectionObserver root. */
|
|
71
|
+
export function getScrollViewportIntersectionRoot(
|
|
72
|
+
viewport: HTMLElement,
|
|
73
|
+
): Element | null {
|
|
74
|
+
return isDocumentScrollViewport(viewport) ? null : viewport;
|
|
75
|
+
}
|
|
76
|
+
|
|
3
77
|
/**
|
|
4
78
|
* Provides the currently-active scroll viewport element to descendants.
|
|
5
79
|
*
|
package/hooks/useSharing.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - Tracking whether current session is from a shared link
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
11
|
+
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
12
12
|
import { Annotation, type ImageAttachment } from '../types';
|
|
13
13
|
import {
|
|
14
14
|
type SharePayload,
|
|
@@ -104,6 +104,7 @@ export function useSharing(
|
|
|
104
104
|
setRawHtml?: (h: string) => void,
|
|
105
105
|
setShareHtml?: (h: string) => void,
|
|
106
106
|
setRenderAs?: (m: 'markdown' | 'html') => void,
|
|
107
|
+
contentRevision = 0,
|
|
107
108
|
): UseSharingResult {
|
|
108
109
|
const [isSharedSession, setIsSharedSession] = useState(false);
|
|
109
110
|
const [isLoadingShared, setIsLoadingShared] = useState(true);
|
|
@@ -115,6 +116,23 @@ export function useSharing(
|
|
|
115
116
|
const [pendingSharedAnnotations, setPendingSharedAnnotations] = useState<Annotation[] | null>(null);
|
|
116
117
|
const [sharedGlobalAttachments, setSharedGlobalAttachments] = useState<ImageAttachment[] | null>(null);
|
|
117
118
|
const [shareLoadError, setShareLoadError] = useState('');
|
|
119
|
+
// Identity token for in-flight share requests: a new token invalidates any
|
|
120
|
+
// response still on the wire. Content inputs only — resolveRawHtmlForShare
|
|
121
|
+
// is deliberately NOT a dependency: its identity changes when it caches the
|
|
122
|
+
// portable HTML mid-request (App memoizes it on shareHtml), and listing it
|
|
123
|
+
// would make the first short-link generation on an HTML session discard its
|
|
124
|
+
// own result. HTML refreshes invalidate through contentRevision instead.
|
|
125
|
+
const shareRequestContext = useMemo(() => ({}), [
|
|
126
|
+
markdown,
|
|
127
|
+
annotations,
|
|
128
|
+
globalAttachments,
|
|
129
|
+
shareBaseUrl,
|
|
130
|
+
pasteApiUrl,
|
|
131
|
+
rawHtml,
|
|
132
|
+
contentRevision,
|
|
133
|
+
]);
|
|
134
|
+
const latestShareRequestContextRef = useRef(shareRequestContext);
|
|
135
|
+
latestShareRequestContextRef.current = shareRequestContext;
|
|
118
136
|
|
|
119
137
|
const clearPendingSharedAnnotations = useCallback(() => {
|
|
120
138
|
setPendingSharedAnnotations(null);
|
|
@@ -257,16 +275,19 @@ export function useSharing(
|
|
|
257
275
|
|
|
258
276
|
// Generate share URL when markdown or annotations change
|
|
259
277
|
const refreshShareUrl = useCallback(async () => {
|
|
278
|
+
const requestContext = shareRequestContext;
|
|
260
279
|
try {
|
|
261
280
|
const url = await generateShareUrl(markdown, annotations, globalAttachments, shareBaseUrl, rawHtml);
|
|
281
|
+
if (latestShareRequestContextRef.current !== requestContext) return;
|
|
262
282
|
setShareUrl(url ?? '');
|
|
263
283
|
setShareUrlSize(url ? formatUrlSize(url) : '');
|
|
264
284
|
} catch (e) {
|
|
285
|
+
if (latestShareRequestContextRef.current !== requestContext) return;
|
|
265
286
|
console.error('Failed to generate share URL:', e);
|
|
266
287
|
setShareUrl('');
|
|
267
288
|
setShareUrlSize('');
|
|
268
289
|
}
|
|
269
|
-
}, [markdown, annotations, globalAttachments, shareBaseUrl, rawHtml]);
|
|
290
|
+
}, [markdown, annotations, globalAttachments, shareBaseUrl, rawHtml, shareRequestContext]);
|
|
270
291
|
|
|
271
292
|
// Auto-refresh share URL when dependencies change
|
|
272
293
|
useEffect(() => {
|
|
@@ -280,9 +301,10 @@ export function useSharing(
|
|
|
280
301
|
useEffect(() => {
|
|
281
302
|
if (isSharedSession) { isSharedRef.current = true; return; }
|
|
282
303
|
if (isSharedRef.current) { isSharedRef.current = false; return; }
|
|
304
|
+
setIsGeneratingShortUrl(false);
|
|
283
305
|
setShortShareUrl('');
|
|
284
306
|
setShortUrlError('');
|
|
285
|
-
}, [markdown, annotations, globalAttachments, rawHtml, isSharedSession]);
|
|
307
|
+
}, [markdown, annotations, globalAttachments, rawHtml, isSharedSession, contentRevision]);
|
|
286
308
|
|
|
287
309
|
/**
|
|
288
310
|
* Generate a short URL via the paste service.
|
|
@@ -295,6 +317,7 @@ export function useSharing(
|
|
|
295
317
|
|
|
296
318
|
setIsGeneratingShortUrl(true);
|
|
297
319
|
setShortUrlError('');
|
|
320
|
+
const requestContext = shareRequestContext;
|
|
298
321
|
|
|
299
322
|
try {
|
|
300
323
|
const htmlForShare = rawHtml
|
|
@@ -308,6 +331,8 @@ export function useSharing(
|
|
|
308
331
|
htmlForShare,
|
|
309
332
|
);
|
|
310
333
|
|
|
334
|
+
if (latestShareRequestContextRef.current !== requestContext) return null;
|
|
335
|
+
|
|
311
336
|
if (result) {
|
|
312
337
|
setShortShareUrl(result.shortUrl);
|
|
313
338
|
return result.shortUrl;
|
|
@@ -317,13 +342,14 @@ export function useSharing(
|
|
|
317
342
|
return null;
|
|
318
343
|
}
|
|
319
344
|
} catch (e) {
|
|
345
|
+
if (latestShareRequestContextRef.current !== requestContext) return null;
|
|
320
346
|
setShortShareUrl('');
|
|
321
347
|
setShortUrlError(e instanceof Error ? e.message : 'Failed to generate short URL');
|
|
322
348
|
return null;
|
|
323
349
|
} finally {
|
|
324
|
-
setIsGeneratingShortUrl(false);
|
|
350
|
+
if (latestShareRequestContextRef.current === requestContext) setIsGeneratingShortUrl(false);
|
|
325
351
|
}
|
|
326
|
-
}, [markdown, annotations, globalAttachments, shareBaseUrl, pasteApiUrl, rawHtml, resolveRawHtmlForShare]);
|
|
352
|
+
}, [markdown, annotations, globalAttachments, shareBaseUrl, pasteApiUrl, rawHtml, resolveRawHtmlForShare, shareRequestContext]);
|
|
327
353
|
|
|
328
354
|
// Import annotations from a teammate's share URL (supports both hash-based and short /p/<id> URLs)
|
|
329
355
|
const importFromShareUrl = useCallback(async (url: string): Promise<ImportResult> => {
|