@plannotator/ui 0.53.0 → 0.54.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 +284 -27
- package/README.md +84 -14
- package/components/AgentToolAnnouncementDialog.tsx +1 -1
- package/components/AgentsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +177 -34
- package/components/AnnotationSidebar.tsx +1 -1
- package/components/AnnotationToolbar.tsx +64 -9
- package/components/AnnotationToolstrip.tsx +45 -40
- package/components/ApproveDropdown.tsx +7 -9
- package/components/AskSessionAnnouncementDialog.tsx +1 -1
- package/components/AttachmentsButton.tsx +1 -1
- package/components/BlockRenderer.tsx +19 -5
- package/components/CodeFilePopout.tsx +140 -5
- package/components/CommentPopover.tsx +46 -8
- package/components/DecisionControl.tsx +31 -5
- package/components/DiagramBlock.tsx +17 -12
- package/components/EditorAnnotationCard.tsx +1 -1
- package/components/ExportModal.tsx +77 -7
- package/components/HostTitle.tsx +0 -4
- package/components/HtmlSurfaceControls.tsx +2 -1
- package/components/ImageAnnotator/Toolbar.tsx +25 -1
- package/components/ImageAnnotator/index.tsx +24 -4
- package/components/ImageLightbox.tsx +658 -120
- 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 +152 -24
- 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/MentionPicker.tsx +51 -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/SkillReferenceMenu.tsx +39 -12
- 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 +316 -47
- 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 +522 -62
- package/components/blocks/QuestionContext.tsx +3 -0
- 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 +69 -6
- package/components/html-viewer/bridge-script.asset.js +504 -12
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +545 -12
- package/components/html-viewer/useHtmlAnnotation.ts +125 -0
- package/components/imageZoom.ts +265 -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/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/useLinkedDoc.ts +226 -13
- package/hooks/useMentionAutocomplete.ts +1 -1
- package/hooks/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useSkillReferenceAutocomplete.ts +7 -3
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- package/hooks/useVimSelection.ts +9 -0
- package/package.json +23 -22
- 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 +14 -6
- 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/annotationOwnsHighlight.ts +4 -2
- package/utils/asciidocParser.ts +570 -0
- package/utils/autoUpdateNotice.ts +24 -0
- package/utils/belowTitleSlot.ts +174 -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/inboxAnnouncement.ts +78 -0
- package/utils/lookAndFeelAnnouncement.ts +82 -5
- package/utils/markdownExtensions.ts +2 -2
- package/utils/mentions.ts +34 -5
- 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
|
@@ -144,6 +144,19 @@ export interface DraftSavedFileChange {
|
|
|
144
144
|
sourceSave: DraftSourceSaveCapability;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/**
|
|
148
|
+
* A linked document's saved comments, offered with the session draft. The
|
|
149
|
+
* server writes this list (`documents` on GET /api/draft, plan review and
|
|
150
|
+
* annotate sessions on a URL or the last message: packages/shared/annotate-draft.ts);
|
|
151
|
+
* the hook never sends it. Each entry belongs to the document at `path`, not
|
|
152
|
+
* to the session's root.
|
|
153
|
+
*/
|
|
154
|
+
export interface DraftDocumentCopy {
|
|
155
|
+
path: string;
|
|
156
|
+
annotations: Annotation[];
|
|
157
|
+
globalAttachments: ImageAttachment[];
|
|
158
|
+
}
|
|
159
|
+
|
|
147
160
|
/** New format: full objects. */
|
|
148
161
|
interface DraftData {
|
|
149
162
|
annotations: Annotation[];
|
|
@@ -158,6 +171,8 @@ interface DraftData {
|
|
|
158
171
|
savedFileChanges?: DraftSavedFileChange[];
|
|
159
172
|
/** Client-side generation used to ignore stale saves after a draft delete. */
|
|
160
173
|
draftGeneration?: number;
|
|
174
|
+
/** Server-written on load only: other documents' saved comments. */
|
|
175
|
+
documents?: unknown;
|
|
161
176
|
ts: number;
|
|
162
177
|
}
|
|
163
178
|
|
|
@@ -253,6 +268,28 @@ function isDraftSourceSaveCapability(value: unknown): value is DraftSourceSaveCa
|
|
|
253
268
|
);
|
|
254
269
|
}
|
|
255
270
|
|
|
271
|
+
/** The server's `documents` list: entries with a path and at least one comment or attachment. */
|
|
272
|
+
function parseDraftDocuments(value: unknown): DraftDocumentCopy[] {
|
|
273
|
+
if (!Array.isArray(value)) return [];
|
|
274
|
+
const documents: DraftDocumentCopy[] = [];
|
|
275
|
+
for (const entry of value) {
|
|
276
|
+
const doc = entry as Partial<DraftDocumentCopy> | null;
|
|
277
|
+
if (!doc || typeof doc.path !== 'string' || doc.path.length === 0) continue;
|
|
278
|
+
const annotations = Array.isArray(doc.annotations)
|
|
279
|
+
? doc.annotations.filter((a): a is Annotation => !!a && typeof a === 'object' && typeof (a as Annotation).id === 'string')
|
|
280
|
+
: [];
|
|
281
|
+
const globalAttachments = Array.isArray(doc.globalAttachments)
|
|
282
|
+
? doc.globalAttachments.filter((g): g is ImageAttachment => !!g && typeof g === 'object' && typeof (g as ImageAttachment).path === 'string')
|
|
283
|
+
: [];
|
|
284
|
+
if (annotations.length === 0 && globalAttachments.length === 0) continue;
|
|
285
|
+
documents.push({ path: doc.path, annotations, globalAttachments });
|
|
286
|
+
}
|
|
287
|
+
return documents;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const countDraftDocuments = (documents: DraftDocumentCopy[]): number =>
|
|
291
|
+
documents.reduce((total, doc) => total + doc.annotations.length + doc.globalAttachments.length, 0);
|
|
292
|
+
|
|
256
293
|
function readDraftGeneration(value: unknown): number | null {
|
|
257
294
|
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
|
|
258
295
|
}
|
|
@@ -291,17 +328,37 @@ interface RestoredDraft {
|
|
|
291
328
|
editedMarkdown: string | null;
|
|
292
329
|
editedDocuments: DraftEditedDocument[];
|
|
293
330
|
savedFileChanges: DraftSavedFileChange[];
|
|
331
|
+
/** Other documents' saved comments, each for the document at its path.
|
|
332
|
+
`annotations` / `globalAttachments` above are the root document's. */
|
|
333
|
+
documents: DraftDocumentCopy[];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** What the Draft Recovered banner says it found. */
|
|
337
|
+
export interface DraftBanner {
|
|
338
|
+
count: number;
|
|
339
|
+
timeAgo: string;
|
|
340
|
+
hasEdits: boolean;
|
|
341
|
+
/** How many other documents hold comments (absent: the root's alone). */
|
|
342
|
+
documents?: number;
|
|
343
|
+
/** How many of `count` are on those other documents. */
|
|
344
|
+
documentComments?: number;
|
|
294
345
|
}
|
|
295
346
|
|
|
296
347
|
interface UseAnnotationDraftResult {
|
|
297
|
-
|
|
348
|
+
/** `count` includes the other documents' comments; `documents` is how many
|
|
349
|
+
other documents hold some and `documentComments` how many of `count`
|
|
350
|
+
they hold (both absent when the draft is the root's alone). */
|
|
351
|
+
draftBanner: DraftBanner | null;
|
|
298
352
|
restoreDraft: () => RestoredDraft;
|
|
299
353
|
/** Debounced save trigger for changes the reactive deps can't see
|
|
300
354
|
(editor keystrokes, edit commit/discard). Stable identity. */
|
|
301
355
|
scheduleDraftSave: () => void;
|
|
302
356
|
scheduleDraftSaveAfterSubmitFailure: () => void;
|
|
303
357
|
getDraftGeneration: () => number;
|
|
304
|
-
|
|
358
|
+
/** Discard the offered draft. Returns the other documents' comments that
|
|
359
|
+
were offered with it (empty for a root-only draft), so the host can
|
|
360
|
+
discard their copies too. */
|
|
361
|
+
dismissDraft: () => DraftDocumentCopy[];
|
|
305
362
|
/** Send a pending (debounced) save now, if there is one. For a session
|
|
306
363
|
that ends WITHOUT a decision body (the agent closed it): the comment
|
|
307
364
|
typed just before must still reach the kept draft. */
|
|
@@ -319,7 +376,7 @@ export function useAnnotationDraft({
|
|
|
319
376
|
isSharedSession,
|
|
320
377
|
submitted,
|
|
321
378
|
}: UseAnnotationDraftOptions): UseAnnotationDraftResult {
|
|
322
|
-
const [draftBanner, setDraftBanner] = useState<
|
|
379
|
+
const [draftBanner, setDraftBanner] = useState<DraftBanner | null>(null);
|
|
323
380
|
const draftDataRef = useRef<RestoredDraft | null>(null);
|
|
324
381
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
325
382
|
const hasMountedRef = useRef(false);
|
|
@@ -394,8 +451,10 @@ export function useAnnotationDraft({
|
|
|
394
451
|
!isLegacyDraft(data) && Array.isArray((data as DraftData).savedFileChanges)
|
|
395
452
|
? (data as DraftData).savedFileChanges!.filter(isDraftSavedFileChange)
|
|
396
453
|
: [];
|
|
454
|
+
const restoredDocuments = !isLegacyDraft(data) ? parseDraftDocuments((data as DraftData).documents) : [];
|
|
397
455
|
|
|
398
|
-
const totalCount =
|
|
456
|
+
const totalCount =
|
|
457
|
+
restoredAnnotations.length + restoredCodeAnnotations.length + restoredGlobal.length + countDraftDocuments(restoredDocuments);
|
|
399
458
|
if (totalCount > 0 || restoredEdited !== null || restoredEditedDocuments.length > 0 || restoredSavedFileChanges.length > 0) {
|
|
400
459
|
draftDataRef.current = {
|
|
401
460
|
annotations: restoredAnnotations,
|
|
@@ -404,11 +463,15 @@ export function useAnnotationDraft({
|
|
|
404
463
|
editedMarkdown: restoredEdited,
|
|
405
464
|
editedDocuments: restoredEditedDocuments,
|
|
406
465
|
savedFileChanges: restoredSavedFileChanges,
|
|
466
|
+
documents: restoredDocuments,
|
|
407
467
|
};
|
|
408
468
|
setDraftBanner({
|
|
409
469
|
count: totalCount,
|
|
410
470
|
timeAgo: formatTimeAgo(data.ts || 0),
|
|
411
471
|
hasEdits: restoredEdited !== null || restoredEditedDocuments.length > 0 || restoredSavedFileChanges.length > 0,
|
|
472
|
+
...(restoredDocuments.length > 0
|
|
473
|
+
? { documents: restoredDocuments.length, documentComments: countDraftDocuments(restoredDocuments) }
|
|
474
|
+
: {}),
|
|
412
475
|
});
|
|
413
476
|
}
|
|
414
477
|
hasMountedRef.current = true;
|
|
@@ -547,12 +610,13 @@ export function useAnnotationDraft({
|
|
|
547
610
|
setDraftBanner(null);
|
|
548
611
|
draftDataRef.current = null;
|
|
549
612
|
|
|
550
|
-
if (!data) return { annotations: [], codeAnnotations: [], globalAttachments: [], editedMarkdown: null, editedDocuments: [], savedFileChanges: [] };
|
|
613
|
+
if (!data) return { annotations: [], codeAnnotations: [], globalAttachments: [], editedMarkdown: null, editedDocuments: [], savedFileChanges: [], documents: [] };
|
|
551
614
|
|
|
552
615
|
return data;
|
|
553
616
|
}, []);
|
|
554
617
|
|
|
555
|
-
const dismissDraft = useCallback(() => {
|
|
618
|
+
const dismissDraft = useCallback((): DraftDocumentCopy[] => {
|
|
619
|
+
const offeredDocuments = draftDataRef.current?.documents ?? [];
|
|
556
620
|
const deletedGeneration = draftGenerationRef.current + 1;
|
|
557
621
|
draftGenerationRef.current = deletedGeneration;
|
|
558
622
|
if (timerRef.current) {
|
|
@@ -563,6 +627,7 @@ export function useAnnotationDraft({
|
|
|
563
627
|
draftDataRef.current = null;
|
|
564
628
|
|
|
565
629
|
getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
|
|
630
|
+
return offeredDocuments;
|
|
566
631
|
}, []);
|
|
567
632
|
|
|
568
633
|
return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft, flushPendingSave };
|
|
@@ -8,11 +8,17 @@
|
|
|
8
8
|
import { useEffect, useRef, useState, useCallback, type RefObject } from 'react';
|
|
9
9
|
import Highlighter from '@plannotator/web-highlighter';
|
|
10
10
|
import type { Annotation, EditorMode, HostDraft, ImageAttachment } from '../types';
|
|
11
|
+
import { metaWithBelowTitle, metaWithoutBelowTitle, snapRangeOutOfBelowTitle } from '../utils/belowTitleSlot';
|
|
11
12
|
import { AnnotationType } from '../types';
|
|
12
13
|
import type { QuickLabel } from '../utils/quickLabels';
|
|
14
|
+
import {
|
|
15
|
+
trimWhitespaceOnlyBoundaryNodes,
|
|
16
|
+
type SelectedNodeLike,
|
|
17
|
+
} from '../utils/selectionBoundary';
|
|
13
18
|
import { getIdentity } from '../utils/identity';
|
|
14
19
|
import { annotationOwnsHighlight } from '../utils/annotationOwnsHighlight';
|
|
15
20
|
import { transformPlainText } from '../utils/inlineTransforms';
|
|
21
|
+
import { richCopySourceFromRange, type RichCopySource } from '../utils/richClipboard';
|
|
16
22
|
|
|
17
23
|
// --- Exported state types ---
|
|
18
24
|
|
|
@@ -20,6 +26,9 @@ export interface ToolbarState {
|
|
|
20
26
|
element: HTMLElement;
|
|
21
27
|
source: any;
|
|
22
28
|
selectionText: string;
|
|
29
|
+
/** Builds the selection's rich-text copy when the toolbar button is pressed
|
|
30
|
+
* (synchronous in the markdown Viewer, a bridge round trip on HTML surfaces). */
|
|
31
|
+
getRichCopy?: () => RichCopySource | Promise<RichCopySource>;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
export interface CommentPopoverState {
|
|
@@ -1248,6 +1257,10 @@ export function useAnnotationHighlighter({
|
|
|
1248
1257
|
// verdict, so it is neither attempted nor unanchored here (the same
|
|
1249
1258
|
// rule the raw-HTML pinpoints follow on their surface).
|
|
1250
1259
|
if (ann.diagramAnchor) return;
|
|
1260
|
+
// A comment on part of an image has no text either: the image viewer
|
|
1261
|
+
// draws its box, and the Viewer reports it unanchored when its image is
|
|
1262
|
+
// gone from the document.
|
|
1263
|
+
if (ann.imageRegion != null) return;
|
|
1251
1264
|
// An answer to a `:::question` block quotes the prompt but is not a
|
|
1252
1265
|
// selection: the question block draws it from the annotation, so it is
|
|
1253
1266
|
// never painted as a highlight (and never reported as unanchored here).
|
|
@@ -1286,7 +1299,12 @@ export function useAnnotationHighlighter({
|
|
|
1286
1299
|
|
|
1287
1300
|
if (ann.startMeta && ann.endMeta) {
|
|
1288
1301
|
try {
|
|
1289
|
-
highlighter.fromStore(
|
|
1302
|
+
highlighter.fromStore(
|
|
1303
|
+
metaWithBelowTitle(containerRef.current, ann.startMeta),
|
|
1304
|
+
metaWithBelowTitle(containerRef.current, ann.endMeta),
|
|
1305
|
+
ann.originalText,
|
|
1306
|
+
ann.id,
|
|
1307
|
+
);
|
|
1290
1308
|
const restoredDoms = highlighter.getDoms(ann.id);
|
|
1291
1309
|
if (restoredDoms && restoredDoms.length > 0) {
|
|
1292
1310
|
const restoredText = paintedTextOf(restoredDoms as HTMLElement[]);
|
|
@@ -1480,6 +1498,15 @@ export function useAnnotationHighlighter({
|
|
|
1480
1498
|
style: { className: 'annotation-highlight' },
|
|
1481
1499
|
});
|
|
1482
1500
|
|
|
1501
|
+
// Chromium can extend a triple-clicked line into empty or indentation nodes of the next block.
|
|
1502
|
+
// Trim only boundary whitespace so spacing inside genuine multi-node selections stays highlighted.
|
|
1503
|
+
// The hook's callback type is `(...args: unknown[]) => SelectedNode[]`; a SelectedNode
|
|
1504
|
+
// structurally satisfies SelectedNodeLike, so the trimmed subset goes back through the
|
|
1505
|
+
// hook's own callback type rather than importing SelectedNode from the package's dist/.
|
|
1506
|
+
type SelectedNodesTap = Parameters<typeof highlighter.hooks.Render.SelectedNodes.tap>[0];
|
|
1507
|
+
highlighter.hooks.Render.SelectedNodes.tap(((...args: unknown[]) =>
|
|
1508
|
+
trimWhitespaceOnlyBoundaryNodes(args[1] as SelectedNodeLike[])) as SelectedNodesTap);
|
|
1509
|
+
|
|
1483
1510
|
highlighterRef.current = highlighter;
|
|
1484
1511
|
|
|
1485
1512
|
// Stored positions can resolve into chrome the reviewer could never have
|
|
@@ -1499,6 +1526,15 @@ export function useAnnotationHighlighter({
|
|
|
1499
1526
|
|
|
1500
1527
|
highlighter.on(Highlighter.event.CREATE, ({ sources, type }: { sources: any[]; type?: string }) => {
|
|
1501
1528
|
if (type === 'from-store') return;
|
|
1529
|
+
// Store positions as if a host's `belowTitle` slot were not there, so
|
|
1530
|
+
// its contents never renumber a comment's parent element (identity
|
|
1531
|
+
// when the document has no slot). See utils/belowTitleSlot.
|
|
1532
|
+
for (const created of sources) {
|
|
1533
|
+
const startMeta = metaWithoutBelowTitle(containerRef.current, created.startMeta, 'start');
|
|
1534
|
+
const endMeta = metaWithoutBelowTitle(containerRef.current, created.endMeta, 'end');
|
|
1535
|
+
if (startMeta !== created.startMeta) created.startMeta = startMeta;
|
|
1536
|
+
if (endMeta !== created.endMeta) created.endMeta = endMeta;
|
|
1537
|
+
}
|
|
1502
1538
|
if (sources.length > 0) {
|
|
1503
1539
|
const source = sources[0];
|
|
1504
1540
|
const doms = highlighter.getDoms(source.id);
|
|
@@ -1561,10 +1597,24 @@ export function useAnnotationHighlighter({
|
|
|
1561
1597
|
} else {
|
|
1562
1598
|
// Selection mode — show toolbar
|
|
1563
1599
|
pendingSourceRef.current = source;
|
|
1600
|
+
const marks = [...doms] as HTMLElement[];
|
|
1601
|
+
const selectionText = source.text;
|
|
1564
1602
|
setToolbarState({
|
|
1565
1603
|
element: doms[0] as HTMLElement,
|
|
1566
1604
|
source,
|
|
1567
|
-
selectionText
|
|
1605
|
+
selectionText,
|
|
1606
|
+
// Built only when Copy (rich text) is pressed: the pending
|
|
1607
|
+
// highlight's marks still bound the selected words, and the
|
|
1608
|
+
// sanitizer drops the marks themselves.
|
|
1609
|
+
getRichCopy: () => {
|
|
1610
|
+
const first = marks[0];
|
|
1611
|
+
const last = marks[marks.length - 1];
|
|
1612
|
+
if (!first?.isConnected || !last?.isConnected) return null;
|
|
1613
|
+
const range = document.createRange();
|
|
1614
|
+
range.setStartBefore(first);
|
|
1615
|
+
range.setEndAfter(last);
|
|
1616
|
+
return richCopySourceFromRange(range, selectionText);
|
|
1617
|
+
},
|
|
1568
1618
|
});
|
|
1569
1619
|
}
|
|
1570
1620
|
}
|
|
@@ -1609,7 +1659,9 @@ export function useAnnotationHighlighter({
|
|
|
1609
1659
|
pendingRangeRunsRef.current = null;
|
|
1610
1660
|
return;
|
|
1611
1661
|
}
|
|
1612
|
-
|
|
1662
|
+
const snappedOutOfSlot = snapRangeOutOfBelowTitle(container, range);
|
|
1663
|
+
const snappedPastExcluded = snapRangeStartPastExcluded(range);
|
|
1664
|
+
if (snappedOutOfSlot || snappedPastExcluded) {
|
|
1613
1665
|
selection.removeAllRanges();
|
|
1614
1666
|
selection.addRange(range);
|
|
1615
1667
|
}
|
|
@@ -1724,6 +1776,7 @@ export function useAnnotationHighlighter({
|
|
|
1724
1776
|
// Same fail-closed rule as the pointer-end guard (#881): touch
|
|
1725
1777
|
// selections settle on block boundaries just as often.
|
|
1726
1778
|
if (isBlankQuote(sel.toString()) || !isSerializableRange(range)) return false;
|
|
1779
|
+
snapRangeOutOfBelowTitle(containerRef.current, range);
|
|
1727
1780
|
snapRangeStartPastExcluded(range);
|
|
1728
1781
|
pendingRangeRunsRef.current = rangeTextPieces(range);
|
|
1729
1782
|
try {
|
|
@@ -1776,6 +1829,7 @@ export function useAnnotationHighlighter({
|
|
|
1776
1829
|
// visually hidden type word; snap off it before painting (the caller's own
|
|
1777
1830
|
// range is left untouched).
|
|
1778
1831
|
const painted = range.cloneRange();
|
|
1832
|
+
snapRangeOutOfBelowTitle(container, painted);
|
|
1779
1833
|
snapRangeStartPastExcluded(painted);
|
|
1780
1834
|
pendingRangeRunsRef.current = rangeTextPieces(painted);
|
|
1781
1835
|
|
|
@@ -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
|
+
}
|