@plannotator/ui 0.53.1 → 0.55.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/HANDOFF.md +274 -14
- package/README.md +114 -11
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +142 -24
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +22 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CodeSnippetPreview.tsx +174 -0
- package/components/CommentPopover.tsx +44 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DeferredMarkdownContentsRail.tsx +38 -0
- package/components/DiagramBlock.tsx +17 -12
- package/components/DiffFileTree.tsx +7 -6
- package/components/DocumentContents.tsx +74 -0
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +722 -126
- package/components/ImagePeek.tsx +38 -4
- package/components/ImageRegions.tsx +369 -0
- package/components/ImageThumbnail.tsx +1 -1
- package/components/ImageZoomStage.tsx +796 -0
- package/components/InboxAnnouncementDialog.tsx +202 -0
- package/components/InlineMarkdown.tsx +167 -174
- package/components/KeyboardShortcuts.tsx +23 -0
- package/components/LazyBlockBoundary.tsx +100 -0
- package/components/ListMarker.tsx +3 -3
- package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
- package/components/MarkdownContentsRail.tsx +777 -0
- package/components/MenuVersionSection.tsx +31 -9
- package/components/PlanDecisionMenuExtras.tsx +129 -0
- package/components/PlanHeaderMenu.tsx +15 -1
- package/components/ProviderIcons.tsx +8 -0
- package/components/RefHoverCard.tsx +913 -0
- package/components/SendFailureToast.tsx +126 -0
- package/components/Settings.tsx +617 -126
- package/components/TableOfContents.tsx +20 -6
- package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
- package/components/ThemeProvider.tsx +14 -1
- package/components/ThemeTab.tsx +16 -24
- package/components/Tooltip.tsx +49 -18
- package/components/Viewer.tsx +288 -43
- package/components/ai/DocumentAIChatPanel.tsx +29 -3
- package/components/ai/NewChatButton.tsx +74 -0
- package/components/blocks/CodeBlock.tsx +1 -1
- package/components/blocks/HtmlBlock.tsx +7 -1
- package/components/blocks/QuestionBlock.tsx +532 -62
- package/components/diagram/DiagramComposer.tsx +5 -1
- package/components/diagram/DiagramPending.tsx +12 -2
- package/components/diagram/DiagramViewer.tsx +19 -3
- package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
- package/components/html-embed/SvgFenceBlock.tsx +46 -0
- package/components/html-embed/index.ts +3 -0
- package/components/html-embed/languages.ts +15 -0
- package/components/html-embed/loadGate.ts +107 -0
- package/components/html-viewer/HtmlViewer.tsx +82 -6
- package/components/html-viewer/bridge-script.asset.js +173 -1
- package/components/html-viewer/bridge-script.ts +173 -1
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +276 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
- package/components/plan-diff/PlanDiffViewer.tsx +13 -2
- package/components/plan-diff/PlanRawDiffView.tsx +1 -1
- package/components/sidebar/FileBrowser.tsx +16 -2
- package/components/sidebar/SidebarTabs.tsx +3 -0
- package/config/index.ts +3 -0
- package/config/reviewView.ts +41 -1
- package/config/settings.ts +155 -4
- package/configure.ts +28 -0
- package/contents-rail.css +337 -0
- package/hooks/pfm/useCodeFilePopout.ts +7 -1
- package/hooks/useAIChat.ts +72 -25
- package/hooks/useAnnotationDraft.ts +71 -6
- package/hooks/useAnnotationHighlighter.ts +57 -3
- package/hooks/useCodeAnnotationDraft.ts +113 -17
- package/hooks/useCookieHostSetting.ts +96 -0
- package/hooks/useDocumentFont.ts +17 -0
- package/hooks/useDocumentTextSize.ts +20 -0
- package/hooks/useFileBrowser.ts +10 -1
- package/hooks/useHeadingScrollSpy.ts +77 -0
- package/hooks/useHoverPreview.ts +94 -0
- package/hooks/useLinkedDoc.ts +226 -13
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/usePrefersReducedMotion.ts +23 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSwipeStep.ts +597 -0
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/package.json +26 -23
- package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
- package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
- package/shortcuts/core.ts +63 -4
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
- package/shortcuts/runtime.ts +4 -3
- package/styles.css +1 -1
- package/theme.css +179 -1
- package/themes/plannotator-graphite.css +66 -0
- package/types.ts +26 -9
- package/utils/agentSwitch.ts +41 -20
- package/utils/agentToolSetting.ts +1 -1
- package/utils/aiChatFormat.ts +23 -7
- package/utils/annotationHelpers.ts +32 -4
- package/utils/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -0
- package/utils/blockParse.ts +45 -0
- package/utils/codeLineScroll.ts +100 -0
- package/utils/commentSubmitKey.ts +48 -0
- package/utils/cssFontFamily.ts +65 -0
- package/utils/decisionSpec.ts +190 -2
- package/utils/defaultNotesApp.ts +1 -1
- package/utils/diffFonts.ts +4 -1
- package/utils/documentFont.ts +68 -0
- package/utils/documentTextSize.ts +54 -0
- package/utils/fontDetect.ts +63 -0
- package/utils/headingScrollSpy.ts +92 -0
- package/utils/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mermaid.ts +20 -3
- package/utils/mermaidTheme.ts +3 -51
- package/utils/notion.ts +58 -0
- package/utils/parser.ts +28 -1
- package/utils/platform.ts +46 -0
- package/utils/refCard.ts +82 -0
- package/utils/richClipboard.ts +380 -0
- package/utils/richClipboardLists.ts +57 -0
- package/utils/richCopyPolicy.ts +22 -0
- package/utils/scrollAnchor.ts +82 -0
- package/utils/sectionRefs.ts +123 -0
- package/utils/selectionBoundary.ts +40 -0
- package/utils/sendFailure.ts +85 -0
- package/utils/sharing.ts +5 -2
- package/utils/themeRegistry.ts +10 -0
- package/utils/themeTokens.ts +74 -0
- package/utils/updateNotice.ts +30 -8
- package/utils/vscodePanel.ts +9 -0
|
@@ -7,12 +7,18 @@
|
|
|
7
7
|
|
|
8
8
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
9
9
|
import type { CodeAnnotation, Annotation, CommentAnnotation } from '../types';
|
|
10
|
-
import { getDraftTransport } from './useAnnotationDraft';
|
|
10
|
+
import { getDraftTransport, type DraftTransport } from './useAnnotationDraft';
|
|
11
11
|
|
|
12
12
|
const DEBOUNCE_MS = 500;
|
|
13
13
|
/** Bound on one read of a switched-onto target's draft (#1590). */
|
|
14
14
|
export const TARGET_LOAD_TIMEOUT_MS = 5000;
|
|
15
15
|
|
|
16
|
+
/** A target-bound transport refuses to write into a different review. The host
|
|
17
|
+
* must refresh/adopt a target before saving can resume; retrying cannot fix it. */
|
|
18
|
+
export class DraftTargetChangedError extends Error {
|
|
19
|
+
constructor() { super('Review draft target changed'); }
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
17
23
|
return new Promise<T>((resolve, reject) => {
|
|
18
24
|
const timer = setTimeout(() => reject(new Error('draft load timed out')), ms);
|
|
@@ -41,8 +47,8 @@ interface DraftData {
|
|
|
41
47
|
autoViewSuppressed?: string[];
|
|
42
48
|
draftGeneration?: number;
|
|
43
49
|
/**
|
|
44
|
-
* Set by the review server (never by the client) when a
|
|
45
|
-
* through
|
|
50
|
+
* Set by the review server (never by the client) when a draft is served
|
|
51
|
+
* through its target key for a patch different from the one it was
|
|
46
52
|
* saved on (#1590). The host re-checks line comments' anchors on restore.
|
|
47
53
|
*/
|
|
48
54
|
patchChanged?: boolean;
|
|
@@ -65,6 +71,8 @@ function formatTimeAgo(ts: number): string {
|
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
interface UseCodeAnnotationDraftOptions {
|
|
74
|
+
/** Optional target-bound transport; the configured host transport is the default. */
|
|
75
|
+
transport?: DraftTransport;
|
|
68
76
|
annotations: CodeAnnotation[];
|
|
69
77
|
descriptionAnnotations?: Annotation[];
|
|
70
78
|
commentAnnotations?: CommentAnnotation[];
|
|
@@ -101,6 +109,10 @@ export interface CodeDraftTargetState {
|
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
interface UseCodeAnnotationDraftResult {
|
|
112
|
+
/** Flush pending edits, then hold autosave until adoptDraftTarget resumes it. */
|
|
113
|
+
flushBeforeSwitch: () => Promise<void>;
|
|
114
|
+
/** Keep unsaved annotations in memory and offer an in-place refresh. */
|
|
115
|
+
draftTargetChanged: boolean;
|
|
104
116
|
draftBanner: { count: number; viewedCount: number; timeAgo: string } | null;
|
|
105
117
|
/** `patchChanged` is true when the server served the draft for a patch other
|
|
106
118
|
* than the one it was saved on (the host re-checks line anchors). */
|
|
@@ -114,10 +126,11 @@ interface UseCodeAnnotationDraftResult {
|
|
|
114
126
|
* that one load settles (success, failure, or a newer switch) autosave does
|
|
115
127
|
* not write under the new target, so the switch cannot overwrite a draft
|
|
116
128
|
* it has not read yet; nothing else ever waits. */
|
|
117
|
-
adoptDraftTarget: (state: CodeDraftTargetState | undefined) => void;
|
|
129
|
+
adoptDraftTarget: (state: CodeDraftTargetState | undefined, transport?: DraftTransport) => void;
|
|
118
130
|
}
|
|
119
131
|
|
|
120
132
|
export function useCodeAnnotationDraft({
|
|
133
|
+
transport: transportOverride,
|
|
121
134
|
annotations,
|
|
122
135
|
descriptionAnnotations = [],
|
|
123
136
|
commentAnnotations = [],
|
|
@@ -129,6 +142,14 @@ export function useCodeAnnotationDraft({
|
|
|
129
142
|
targetLoadTimeoutMs = TARGET_LOAD_TIMEOUT_MS,
|
|
130
143
|
persistViewedFiles = true,
|
|
131
144
|
}: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
|
|
145
|
+
const transport = transportOverride ?? getDraftTransport();
|
|
146
|
+
const transportRef = useRef(transport);
|
|
147
|
+
transportRef.current = transport;
|
|
148
|
+
const pendingSaveRef = useRef<(() => Promise<void>) | null>(null);
|
|
149
|
+
const savingRef = useRef<Promise<void> | null>(null);
|
|
150
|
+
const switchPausedRef = useRef(false);
|
|
151
|
+
const [draftTargetChanged, setDraftTargetChanged] = useState(false);
|
|
152
|
+
const targetChangedRef = useRef(false);
|
|
132
153
|
const [draftBanner, setDraftBanner] = useState<{ count: number; viewedCount: number; timeAgo: string } | null>(null);
|
|
133
154
|
const draftDataRef = useRef<DraftData | null>(null);
|
|
134
155
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -147,6 +168,14 @@ export function useCodeAnnotationDraft({
|
|
|
147
168
|
// would overwrite a draft it has not merged yet) and remembers that it did,
|
|
148
169
|
// so settling — success, failure, or supersede — always resumes saving.
|
|
149
170
|
const switchSeqRef = useRef(0);
|
|
171
|
+
const reportTargetChanged = (error: unknown, seq: number): boolean => {
|
|
172
|
+
if (!(error instanceof DraftTargetChangedError)) return false;
|
|
173
|
+
if (seq === switchSeqRef.current) {
|
|
174
|
+
targetChangedRef.current = true;
|
|
175
|
+
setDraftTargetChanged(true);
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
};
|
|
150
179
|
const awaitingTargetLoadRef = useRef(false);
|
|
151
180
|
const skippedWhileAwaitingRef = useRef(false);
|
|
152
181
|
// The switched-onto target's draft could not be read (both attempts failed
|
|
@@ -182,15 +211,19 @@ export function useCodeAnnotationDraft({
|
|
|
182
211
|
// Load draft on mount
|
|
183
212
|
useEffect(() => {
|
|
184
213
|
if (!isApiMode) return;
|
|
214
|
+
const seq = switchSeqRef.current;
|
|
215
|
+
let cancelled = false;
|
|
185
216
|
|
|
186
|
-
|
|
217
|
+
transportRef.current.load()
|
|
187
218
|
.then(({ data, generation }) => {
|
|
219
|
+
if (cancelled || seq !== switchSeqRef.current) return null;
|
|
188
220
|
if (generation !== null) {
|
|
189
221
|
draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
|
|
190
222
|
}
|
|
191
223
|
return data as DraftData | null;
|
|
192
224
|
})
|
|
193
225
|
.then((data: DraftData | null) => {
|
|
226
|
+
if (cancelled || seq !== switchSeqRef.current) return;
|
|
194
227
|
// Keep even a viewed-only draft while independent progress support is
|
|
195
228
|
// unresolved; a failed/unsupported load may need to offer it later.
|
|
196
229
|
draftDataRef.current = data;
|
|
@@ -212,9 +245,13 @@ export function useCodeAnnotationDraft({
|
|
|
212
245
|
}
|
|
213
246
|
hasMountedRef.current = true;
|
|
214
247
|
})
|
|
215
|
-
.catch(() => {
|
|
216
|
-
|
|
248
|
+
.catch((error) => {
|
|
249
|
+
if (!cancelled && seq === switchSeqRef.current) {
|
|
250
|
+
reportTargetChanged(error, seq);
|
|
251
|
+
hasMountedRef.current = true;
|
|
252
|
+
}
|
|
217
253
|
});
|
|
254
|
+
return () => { cancelled = true; };
|
|
218
255
|
}, [isApiMode]);
|
|
219
256
|
|
|
220
257
|
useEffect(() => {
|
|
@@ -248,7 +285,9 @@ export function useCodeAnnotationDraft({
|
|
|
248
285
|
|
|
249
286
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
250
287
|
|
|
251
|
-
|
|
288
|
+
const seq = switchSeqRef.current;
|
|
289
|
+
const persist = async () => {
|
|
290
|
+
if (targetChangedRef.current) return;
|
|
252
291
|
// The switched-onto target's draft is still loading: writing now would
|
|
253
292
|
// overwrite it unread. Settling the load re-runs this effect.
|
|
254
293
|
if (awaitingTargetLoadRef.current) {
|
|
@@ -268,7 +307,7 @@ export function useCodeAnnotationDraft({
|
|
|
268
307
|
// tombstone so it can't resurface on refresh and a late save can't revive
|
|
269
308
|
// it. Mirrors useAnnotationDraft.persistNow — routed through the draft
|
|
270
309
|
// transport seam so a host backend tombstones its own stored draft too.
|
|
271
|
-
|
|
310
|
+
await transport.remove(draftGeneration, { keepalive: false });
|
|
272
311
|
return;
|
|
273
312
|
}
|
|
274
313
|
|
|
@@ -284,18 +323,48 @@ export function useCodeAnnotationDraft({
|
|
|
284
323
|
ts: Date.now(),
|
|
285
324
|
};
|
|
286
325
|
|
|
287
|
-
|
|
326
|
+
await transport.save(payload, { keepalive: false });
|
|
327
|
+
};
|
|
328
|
+
const save = async () => {
|
|
329
|
+
try { await persist(); } catch (error) {
|
|
330
|
+
reportTargetChanged(error, seq);
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
pendingSaveRef.current = save;
|
|
335
|
+
timerRef.current = setTimeout(() => {
|
|
336
|
+
if (switchPausedRef.current) return;
|
|
337
|
+
pendingSaveRef.current = null;
|
|
338
|
+
savingRef.current = save();
|
|
339
|
+
savingRef.current.catch(() => {});
|
|
288
340
|
}, DEBOUNCE_MS);
|
|
289
341
|
|
|
290
342
|
return () => {
|
|
291
343
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
344
|
+
pendingSaveRef.current = null;
|
|
292
345
|
};
|
|
293
|
-
}, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted, saveNudge, persistViewedFiles]);
|
|
346
|
+
}, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted, saveNudge, persistViewedFiles, transport]);
|
|
347
|
+
|
|
348
|
+
const flushBeforeSwitch = useCallback(async () => {
|
|
349
|
+
switchPausedRef.current = true;
|
|
350
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
351
|
+
try {
|
|
352
|
+
// Persistence is best-effort: a rejected earlier write must neither
|
|
353
|
+
// block navigation nor skip a newer edit still waiting on debounce.
|
|
354
|
+
await savingRef.current?.catch(() => {});
|
|
355
|
+
const pending = pendingSaveRef.current;
|
|
356
|
+
pendingSaveRef.current = null;
|
|
357
|
+
if (pending) await pending().catch(() => {});
|
|
358
|
+
} finally {
|
|
359
|
+
savingRef.current = null;
|
|
360
|
+
}
|
|
361
|
+
}, []);
|
|
294
362
|
|
|
295
363
|
const restoreDraft = useCallback(() => {
|
|
296
364
|
// Cancel any pending autosave so it can't fire with pre-restore state and
|
|
297
365
|
// overwrite what we're about to restore.
|
|
298
366
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
367
|
+
pendingSaveRef.current = null;
|
|
299
368
|
const data = draftDataRef.current;
|
|
300
369
|
setDraftBanner(null);
|
|
301
370
|
draftDataRef.current = null;
|
|
@@ -315,19 +384,43 @@ export function useCodeAnnotationDraft({
|
|
|
315
384
|
// Cancel any pending autosave so a late save can't revive the draft the user
|
|
316
385
|
// just dismissed.
|
|
317
386
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
387
|
+
pendingSaveRef.current = null;
|
|
318
388
|
const deletedGeneration = draftGenerationRef.current + 1;
|
|
319
389
|
draftGenerationRef.current = deletedGeneration;
|
|
320
390
|
setDraftBanner(null);
|
|
321
391
|
draftDataRef.current = null;
|
|
322
|
-
|
|
392
|
+
const seq = switchSeqRef.current;
|
|
393
|
+
transportRef.current.remove(deletedGeneration, { keepalive: false }).catch(error => {
|
|
394
|
+
reportTargetChanged(error, seq);
|
|
395
|
+
});
|
|
323
396
|
}, []);
|
|
324
397
|
|
|
325
398
|
// Unmount supersedes any in-flight target read, so a retry never fires
|
|
326
399
|
// after the page (or a test host) is gone.
|
|
327
400
|
useEffect(() => () => { switchSeqRef.current += 1; }, []);
|
|
328
401
|
|
|
329
|
-
const adoptDraftTarget = useCallback((state: CodeDraftTargetState | undefined) => {
|
|
402
|
+
const adoptDraftTarget = useCallback((state: CodeDraftTargetState | undefined, nextTransport?: DraftTransport) => {
|
|
330
403
|
if (!isApiMode) return;
|
|
404
|
+
// undefined also resumes a FAILED switch, which has not repaired a
|
|
405
|
+
// target mismatch. Only an applied server response may clear the notice.
|
|
406
|
+
if (state) {
|
|
407
|
+
targetChangedRef.current = false;
|
|
408
|
+
setDraftTargetChanged(false);
|
|
409
|
+
}
|
|
410
|
+
if (switchPausedRef.current) {
|
|
411
|
+
switchPausedRef.current = false;
|
|
412
|
+
setSaveNudge(n => n + 1);
|
|
413
|
+
}
|
|
414
|
+
// A failed/superseded request did not adopt another target. Its current
|
|
415
|
+
// draft may still be loading (or unreadable); retain that write protection.
|
|
416
|
+
if (!state) return;
|
|
417
|
+
// An applied switch supersedes the page-load read (its seq guard drops
|
|
418
|
+
// the result), so it must open autosave itself. A failed/superseded
|
|
419
|
+
// switch must not: the page-load read may still be in flight, and
|
|
420
|
+
// saving before it settles could overwrite a draft the banner has not
|
|
421
|
+
// offered yet.
|
|
422
|
+
hasMountedRef.current = true;
|
|
423
|
+
const targetTransport = nextTransport ?? transportRef.current;
|
|
331
424
|
// Every switch starts clean: a previous switch's load (if still in
|
|
332
425
|
// flight) is superseded, an unreadable-target block is lifted, and any
|
|
333
426
|
// write either of them held back resumes (under the new target).
|
|
@@ -341,7 +434,6 @@ export function useCodeAnnotationDraft({
|
|
|
341
434
|
awaitingTargetLoadRef.current = false;
|
|
342
435
|
targetUnreadableRef.current = false;
|
|
343
436
|
retryTargetLoadRef.current = null;
|
|
344
|
-
if (!state) { resumeHeldWrite(); return; }
|
|
345
437
|
const floor = readDraftGeneration(state.draftGeneration);
|
|
346
438
|
if (floor !== null) draftGenerationRef.current = Math.max(draftGenerationRef.current, floor);
|
|
347
439
|
if (!state.found) { resumeHeldWrite(); return; }
|
|
@@ -381,7 +473,7 @@ export function useCodeAnnotationDraft({
|
|
|
381
473
|
awaitingTargetLoadRef.current = true;
|
|
382
474
|
const attempt = (remaining: number): void => {
|
|
383
475
|
if (switchSeqRef.current !== seq) return; // superseded or unmounted
|
|
384
|
-
withTimeout(
|
|
476
|
+
withTimeout(targetTransport.load(), loadTimeoutRef.current)
|
|
385
477
|
.then(({ data, generation }) => {
|
|
386
478
|
if (switchSeqRef.current !== seq) return; // a newer switch owns the keys now
|
|
387
479
|
// A debounced save armed before the read settled captured the
|
|
@@ -396,8 +488,12 @@ export function useCodeAnnotationDraft({
|
|
|
396
488
|
targetUnreadableRef.current = false;
|
|
397
489
|
resumeHeldWrite();
|
|
398
490
|
})
|
|
399
|
-
.catch(() => {
|
|
491
|
+
.catch((error) => {
|
|
400
492
|
if (switchSeqRef.current !== seq) return;
|
|
493
|
+
if (reportTargetChanged(error, seq)) {
|
|
494
|
+
awaitingTargetLoadRef.current = false;
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
401
497
|
if (remaining > 0) { attempt(remaining - 1); return; }
|
|
402
498
|
// Still unreadable: never overwrite it blind. Autosave keeps
|
|
403
499
|
// running for the session and retries this read on its next
|
|
@@ -412,5 +508,5 @@ export function useCodeAnnotationDraft({
|
|
|
412
508
|
read();
|
|
413
509
|
}, [isApiMode]);
|
|
414
510
|
|
|
415
|
-
return { draftBanner, restoreDraft, getDraftGeneration, dismissDraft, adoptDraftTarget };
|
|
511
|
+
return { draftBanner, draftTargetChanged, restoreDraft, getDraftGeneration, dismissDraft, adoptDraftTarget, flushBeforeSwitch };
|
|
416
512
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Open Plannotator at plannotator.localhost" (#2184) for Settings → General: on
|
|
5
|
+
* writes `urlHost: "plannotator.localhost"` to config.json, off removes it.
|
|
6
|
+
* The server sends it as `serverConfig.cookieHost` only for a local session
|
|
7
|
+
* (never remote mode or `--tailscale`), and writes it with POST /api/config.
|
|
8
|
+
* Deliberately not a settings-registry entry: the registry is bundled into
|
|
9
|
+
* the portable guides.show viewer, whose pinned build must not change.
|
|
10
|
+
*/
|
|
11
|
+
export interface CookieHostSetting {
|
|
12
|
+
on: boolean;
|
|
13
|
+
/** PLANNOTATOR_URL_HOST is set (its value; "" when set empty): the switch is locked. */
|
|
14
|
+
env?: string;
|
|
15
|
+
/** config.json holds another `urlHost` (a tailnet name, `auto`, an IP): locked, never replaced. */
|
|
16
|
+
urlHost?: string;
|
|
17
|
+
/** The last save failed: the reason, shown under the switch. */
|
|
18
|
+
error?: string;
|
|
19
|
+
onChange: (next: boolean) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The cookie switch's one sub line: what it does, or why the switch is
|
|
24
|
+
* locked. `inbox` adds that the Inbox's notifications need allowing once at
|
|
25
|
+
* the new address.
|
|
26
|
+
*/
|
|
27
|
+
export function cookieHostDescription(
|
|
28
|
+
setting: Pick<CookieHostSetting, 'on' | 'env' | 'urlHost'>,
|
|
29
|
+
options: { inbox?: boolean } = {},
|
|
30
|
+
): string {
|
|
31
|
+
if (setting.env !== undefined) {
|
|
32
|
+
return setting.env.trim() === ''
|
|
33
|
+
? 'Set by PLANNOTATOR_URL_HOST in your environment (empty).'
|
|
34
|
+
: `Set by PLANNOTATOR_URL_HOST=${setting.env.trim()} in your environment.`;
|
|
35
|
+
}
|
|
36
|
+
if (setting.urlHost !== undefined) return `config.json sets urlHost to ${setting.urlHost}; this switch never replaces it.`;
|
|
37
|
+
const notifications = options.inbox ? ' Notifications need allowing once there.' : '';
|
|
38
|
+
return `Keeps Plannotator's cookies away from your other localhost apps. Starts from the next session; this browser's preferences start fresh once.${notifications}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CookieHostState {
|
|
42
|
+
on: boolean;
|
|
43
|
+
env?: string;
|
|
44
|
+
urlHost?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseState(value: unknown): CookieHostState | undefined {
|
|
48
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
49
|
+
const v = value as Record<string, unknown>;
|
|
50
|
+
if (typeof v.on !== 'boolean') return undefined;
|
|
51
|
+
return {
|
|
52
|
+
on: v.on,
|
|
53
|
+
...(typeof v.env === 'string' && { env: v.env }),
|
|
54
|
+
...(typeof v.urlHost === 'string' && { urlHost: v.urlHost }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function useCookieHostSetting(): {
|
|
59
|
+
setting: CookieHostSetting | undefined;
|
|
60
|
+
adopt: (serverConfig: Record<string, unknown> | undefined) => void;
|
|
61
|
+
} {
|
|
62
|
+
const [state, setState] = useState<CookieHostState | undefined>();
|
|
63
|
+
const [error, setError] = useState<string | undefined>();
|
|
64
|
+
|
|
65
|
+
const adopt = useCallback((serverConfig: Record<string, unknown> | undefined) => {
|
|
66
|
+
setState(parseState(serverConfig?.cookieHost));
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const onChange = useCallback((next: boolean) => {
|
|
70
|
+
setError(undefined);
|
|
71
|
+
setState((current) => (current ? { ...current, on: next } : current));
|
|
72
|
+
void fetch('/api/config', {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({ cookieHost: next }),
|
|
76
|
+
})
|
|
77
|
+
.then(async (res) => {
|
|
78
|
+
const body = (await res.json().catch(() => ({}))) as { error?: unknown; cookieHost?: unknown };
|
|
79
|
+
const saved = parseState(body.cookieHost);
|
|
80
|
+
if (saved) setState(saved);
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
if (!saved) setState((current) => (current ? { ...current, on: !next } : current));
|
|
83
|
+
setError(typeof body.error === 'string' ? body.error : 'The setting could not be saved.');
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
.catch(() => {
|
|
87
|
+
setState((current) => (current ? { ...current, on: !next } : current));
|
|
88
|
+
setError('The setting could not be saved.');
|
|
89
|
+
});
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
setting: state ? { ...state, ...(error && { error }), onChange } : undefined,
|
|
94
|
+
adopt,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useLayoutEffect } from 'react';
|
|
2
|
+
import { useConfigValue } from '../config';
|
|
3
|
+
import { applyDocumentFont } from '../utils/documentFont';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Apply the `documentFontFamily` setting (Settings → Display → Document font)
|
|
7
|
+
* to the document element for as long as the calling component is mounted. A
|
|
8
|
+
* layout effect, so a remembered font is on the page before its first paint.
|
|
9
|
+
* On unmount the theme's font is put back.
|
|
10
|
+
*/
|
|
11
|
+
export function useDocumentFont(): void {
|
|
12
|
+
const family = useConfigValue('documentFontFamily');
|
|
13
|
+
useLayoutEffect(() => {
|
|
14
|
+
applyDocumentFont(document.documentElement, family);
|
|
15
|
+
}, [family]);
|
|
16
|
+
useLayoutEffect(() => () => applyDocumentFont(document.documentElement, ''), []);
|
|
17
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useLayoutEffect } from 'react';
|
|
2
|
+
import { useConfigValue } from '../config';
|
|
3
|
+
import { applyDocumentTextSize, DEFAULT_DOCUMENT_TEXT_SIZE } from '../utils/documentTextSize';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Apply the `documentTextSize` setting (Settings → Display → Text size) to the
|
|
7
|
+
* document element for as long as the calling component is mounted. A layout
|
|
8
|
+
* effect, so a remembered size is on the page before its first paint. On
|
|
9
|
+
* unmount the default is put back.
|
|
10
|
+
*/
|
|
11
|
+
export function useDocumentTextSize(): void {
|
|
12
|
+
const size = useConfigValue('documentTextSize');
|
|
13
|
+
useLayoutEffect(() => {
|
|
14
|
+
applyDocumentTextSize(document.documentElement, size);
|
|
15
|
+
}, [size]);
|
|
16
|
+
useLayoutEffect(
|
|
17
|
+
() => () => applyDocumentTextSize(document.documentElement, DEFAULT_DOCUMENT_TEXT_SIZE),
|
|
18
|
+
[],
|
|
19
|
+
);
|
|
20
|
+
}
|
package/hooks/useFileBrowser.ts
CHANGED
|
@@ -22,6 +22,13 @@ export interface DirState {
|
|
|
22
22
|
hasLoadedTree?: boolean;
|
|
23
23
|
/** When true, fetches via /api/reference/obsidian/files and opens docs via /api/reference/obsidian/doc */
|
|
24
24
|
isVault?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The server left supported files out because the folder holds more than
|
|
27
|
+
* its cap (PLANNOTATOR_FILE_BROWSER_MAX_FILES). `fileLimit` is that cap.
|
|
28
|
+
* Absent from a backend that does not cap.
|
|
29
|
+
*/
|
|
30
|
+
truncated?: boolean;
|
|
31
|
+
fileLimit?: number;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export interface UseFileBrowserReturn {
|
|
@@ -89,7 +96,7 @@ function remapWorkspaceStatusForDir(
|
|
|
89
96
|
* tree from its own transport instead.
|
|
90
97
|
*/
|
|
91
98
|
export interface FileTreeBackend {
|
|
92
|
-
/** Load a directory tree. Resolves to the same shape the /api/reference/files endpoint returns: a Response whose JSON is { tree, workspaceStatus?, error? }. */
|
|
99
|
+
/** Load a directory tree. Resolves to the same shape the /api/reference/files endpoint returns: a Response whose JSON is { tree, workspaceStatus?, truncated?, fileLimit?, error? }. */
|
|
93
100
|
loadTree(dirPath: string): Promise<Response>;
|
|
94
101
|
/** Load an Obsidian vault tree. Resolves to a Response whose JSON is { tree, error? }. */
|
|
95
102
|
loadVaultTree(vaultPath: string): Promise<Response>;
|
|
@@ -244,6 +251,8 @@ export function useFileBrowser(): UseFileBrowserReturn {
|
|
|
244
251
|
isLoading: false,
|
|
245
252
|
hasLoadedTree: true,
|
|
246
253
|
error: null,
|
|
254
|
+
truncated: data.truncated === true,
|
|
255
|
+
fileLimit: typeof data.fileLimit === "number" && data.fileLimit > 0 ? data.fileLimit : undefined,
|
|
247
256
|
}
|
|
248
257
|
: d
|
|
249
258
|
)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { activeHeadingBinding, bindHeadings, type HeadingBinding } from '../utils/headingScrollSpy';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The block id of the heading being read in `scrollViewport`, by the contents
|
|
6
|
+
* rail's own scroll math (utils/headingScrollSpy): cached heading tops, read
|
|
7
|
+
* on every scroll frame, the last heading at the document's end. For a
|
|
8
|
+
* Contents list on the rail's surface (`DocumentContents`), so the two never
|
|
9
|
+
* disagree. Plan review's list keeps `useActiveSection`.
|
|
10
|
+
*
|
|
11
|
+
* Reads at once on mount (a sheet opened mid-document marks the section being
|
|
12
|
+
* read on its first paint), and re-binds when the document's elements change
|
|
13
|
+
* (an edit can replace the headings and keep their count) or resize. Before
|
|
14
|
+
* any heading is drawn the answer stays what it was.
|
|
15
|
+
*/
|
|
16
|
+
export function useHeadingScrollSpy(
|
|
17
|
+
scrollViewport: HTMLElement | null,
|
|
18
|
+
blockIds: readonly string[],
|
|
19
|
+
): string | null {
|
|
20
|
+
const idsKey = blockIds.join('\n');
|
|
21
|
+
// Stable while the ids are the same, whatever array the caller passes.
|
|
22
|
+
const ids = useMemo(() => (idsKey === '' ? [] : idsKey.split('\n')), [idsKey]);
|
|
23
|
+
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
|
24
|
+
const bindingsRef = useRef<readonly HeadingBinding[]>([]);
|
|
25
|
+
|
|
26
|
+
const refresh = useCallback(() => {
|
|
27
|
+
if (scrollViewport === null) return;
|
|
28
|
+
const binding = activeHeadingBinding(bindingsRef.current, scrollViewport);
|
|
29
|
+
if (binding !== null) setActiveIndex(binding.itemIndex);
|
|
30
|
+
}, [scrollViewport]);
|
|
31
|
+
|
|
32
|
+
useLayoutEffect(() => {
|
|
33
|
+
setActiveIndex(null);
|
|
34
|
+
bindingsRef.current = [];
|
|
35
|
+
if (scrollViewport === null || ids.length === 0) return;
|
|
36
|
+
const rebind = () => {
|
|
37
|
+
bindingsRef.current = bindHeadings(scrollViewport, ids);
|
|
38
|
+
refresh();
|
|
39
|
+
};
|
|
40
|
+
rebind();
|
|
41
|
+
|
|
42
|
+
let scrollFrame: number | null = null;
|
|
43
|
+
const onScroll = () => {
|
|
44
|
+
if (scrollFrame !== null) return;
|
|
45
|
+
scrollFrame = requestAnimationFrame(() => {
|
|
46
|
+
scrollFrame = null;
|
|
47
|
+
refresh();
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
let bindFrame: number | null = null;
|
|
51
|
+
const scheduleRebind = () => {
|
|
52
|
+
if (bindFrame !== null) cancelAnimationFrame(bindFrame);
|
|
53
|
+
bindFrame = requestAnimationFrame(() => {
|
|
54
|
+
bindFrame = null;
|
|
55
|
+
rebind();
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
scrollViewport.addEventListener('scroll', onScroll, { passive: true });
|
|
59
|
+
window.addEventListener('resize', scheduleRebind);
|
|
60
|
+
const resizeObserver =
|
|
61
|
+
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(scheduleRebind);
|
|
62
|
+
resizeObserver?.observe(scrollViewport);
|
|
63
|
+
const mutationObserver =
|
|
64
|
+
typeof MutationObserver === 'undefined' ? null : new MutationObserver(scheduleRebind);
|
|
65
|
+
mutationObserver?.observe(scrollViewport, { childList: true, subtree: true });
|
|
66
|
+
return () => {
|
|
67
|
+
if (scrollFrame !== null) cancelAnimationFrame(scrollFrame);
|
|
68
|
+
if (bindFrame !== null) cancelAnimationFrame(bindFrame);
|
|
69
|
+
scrollViewport.removeEventListener('scroll', onScroll);
|
|
70
|
+
window.removeEventListener('resize', scheduleRebind);
|
|
71
|
+
resizeObserver?.disconnect();
|
|
72
|
+
mutationObserver?.disconnect();
|
|
73
|
+
};
|
|
74
|
+
}, [ids, refresh, scrollViewport]);
|
|
75
|
+
|
|
76
|
+
return activeIndex === null ? null : (ids[activeIndex] ?? null);
|
|
77
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/** How long the pointer rests on an anchor before the preview is fetched. */
|
|
4
|
+
export const HOVER_PREVIEW_SHOW_DELAY_MS = 150;
|
|
5
|
+
/** How long a preview stays after the pointer leaves, so it can move into the preview. */
|
|
6
|
+
export const HOVER_PREVIEW_HIDE_DELAY_MS = 200;
|
|
7
|
+
|
|
8
|
+
export interface HoverPreview<T> {
|
|
9
|
+
/** The loaded preview while it is open, else null. */
|
|
10
|
+
preview: T | null;
|
|
11
|
+
/** Attach to the anchor element; the preview positions against it. */
|
|
12
|
+
anchorRef: React.MutableRefObject<HTMLElement | null>;
|
|
13
|
+
/** Pointer or focus arrives on the anchor: load after the show delay. */
|
|
14
|
+
onEnter: () => void;
|
|
15
|
+
/** Pointer or focus leaves the anchor: close after the hide delay. */
|
|
16
|
+
onLeave: () => void;
|
|
17
|
+
/** Spread onto the preview so moving into it keeps it open. */
|
|
18
|
+
previewHandlers: { onMouseEnter: () => void; onMouseLeave: () => void };
|
|
19
|
+
/** Close at once (a click, a scroll). */
|
|
20
|
+
dismiss: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Dwell-then-load hover for a floating preview: the timing shared by the
|
|
25
|
+
* code-file link preview (InlineMarkdown) and Call Flow's source locations.
|
|
26
|
+
* Nothing loads before the pointer rests for `HOVER_PREVIEW_SHOW_DELAY_MS`; a
|
|
27
|
+
* load that resolves after the pointer left is dropped. `load` resolving
|
|
28
|
+
* null means "nothing to show".
|
|
29
|
+
*/
|
|
30
|
+
export function useHoverPreview<T>(load: () => Promise<T | null>, enabled = true): HoverPreview<T> {
|
|
31
|
+
const [preview, setPreview] = useState<T | null>(null);
|
|
32
|
+
const previewRef = useRef(preview);
|
|
33
|
+
previewRef.current = preview;
|
|
34
|
+
const anchorRef = useRef<HTMLElement | null>(null);
|
|
35
|
+
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
36
|
+
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
37
|
+
// Bumped on every leave so a load still in flight cannot open a stale preview.
|
|
38
|
+
const generationRef = useRef(0);
|
|
39
|
+
const loadRef = useRef(load);
|
|
40
|
+
loadRef.current = load;
|
|
41
|
+
|
|
42
|
+
const cancelShow = useCallback(() => {
|
|
43
|
+
if (showTimerRef.current) { clearTimeout(showTimerRef.current); showTimerRef.current = null; }
|
|
44
|
+
}, []);
|
|
45
|
+
const cancelHide = useCallback(() => {
|
|
46
|
+
if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null; }
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const scheduleHide = useCallback(() => {
|
|
50
|
+
cancelHide();
|
|
51
|
+
hideTimerRef.current = setTimeout(() => setPreview(null), HOVER_PREVIEW_HIDE_DELAY_MS);
|
|
52
|
+
}, [cancelHide]);
|
|
53
|
+
|
|
54
|
+
const onEnter = useCallback(() => {
|
|
55
|
+
if (!enabled) return;
|
|
56
|
+
cancelHide();
|
|
57
|
+
if (previewRef.current || showTimerRef.current) return;
|
|
58
|
+
const generation = generationRef.current;
|
|
59
|
+
showTimerRef.current = setTimeout(async () => {
|
|
60
|
+
showTimerRef.current = null;
|
|
61
|
+
try {
|
|
62
|
+
const data = await loadRef.current();
|
|
63
|
+
if (data != null && generation === generationRef.current) setPreview(data);
|
|
64
|
+
} catch {}
|
|
65
|
+
}, HOVER_PREVIEW_SHOW_DELAY_MS);
|
|
66
|
+
}, [enabled, cancelHide]);
|
|
67
|
+
|
|
68
|
+
const onLeave = useCallback(() => {
|
|
69
|
+
cancelShow();
|
|
70
|
+
generationRef.current += 1;
|
|
71
|
+
scheduleHide();
|
|
72
|
+
}, [cancelShow, scheduleHide]);
|
|
73
|
+
|
|
74
|
+
const dismiss = useCallback(() => {
|
|
75
|
+
cancelShow();
|
|
76
|
+
cancelHide();
|
|
77
|
+
generationRef.current += 1;
|
|
78
|
+
setPreview(null);
|
|
79
|
+
}, [cancelShow, cancelHide]);
|
|
80
|
+
|
|
81
|
+
useEffect(() => () => {
|
|
82
|
+
cancelShow();
|
|
83
|
+
cancelHide();
|
|
84
|
+
}, [cancelShow, cancelHide]);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
preview,
|
|
88
|
+
anchorRef,
|
|
89
|
+
onEnter,
|
|
90
|
+
onLeave,
|
|
91
|
+
previewHandlers: { onMouseEnter: cancelHide, onMouseLeave: scheduleHide },
|
|
92
|
+
dismiss,
|
|
93
|
+
};
|
|
94
|
+
}
|