@plannotator/ui 0.53.1 → 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 +217 -11
- package/README.md +65 -10
- 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 +19 -5
- package/components/CodeFilePopout.tsx +139 -4
- package/components/CommentPopover.tsx +44 -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/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/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/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 +280 -41
- 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/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 +47 -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 +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/usePackageChangesSetting.ts +90 -0
- package/hooks/usePlanLookDialog.ts +67 -0
- package/hooks/useSharing.ts +11 -7
- package/hooks/useThemePaletteHalves.ts +37 -0
- package/hooks/useUpdateCheck.ts +22 -4
- package/hooks/useUpdateToast.ts +1 -1
- 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 +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/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/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
|
@@ -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
|
)
|
package/hooks/useLinkedDoc.ts
CHANGED
|
@@ -6,15 +6,22 @@
|
|
|
6
6
|
* annotation caching per filepath, and highlight re-application.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { useState, useCallback, useRef } from "react";
|
|
9
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
10
10
|
import { normalizeBrowserPath } from "@plannotator/core/browser-paths";
|
|
11
|
-
import {
|
|
11
|
+
import { isDocumentRenderKind } from "@plannotator/core/annotatable";
|
|
12
12
|
import type { Annotation, DocumentRenderAs, ImageAttachment } from "../types";
|
|
13
13
|
import type { ViewerHandle } from "../components/Viewer";
|
|
14
14
|
import type { SidebarTab } from "./useSidebar";
|
|
15
15
|
import type { SourceSaveCapability } from "@plannotator/core/source-save";
|
|
16
16
|
import type { VersionInfo } from "./usePlanDiff";
|
|
17
17
|
import { annotationOwnsHighlight } from "../utils/annotationOwnsHighlight";
|
|
18
|
+
import {
|
|
19
|
+
captureScrollAnchor,
|
|
20
|
+
restoreScrollAnchor,
|
|
21
|
+
scrollAnchorReady,
|
|
22
|
+
type ScrollAnchor,
|
|
23
|
+
} from "../utils/scrollAnchor";
|
|
24
|
+
import { getScrollViewportTop } from "./useScrollViewport";
|
|
18
25
|
|
|
19
26
|
export interface LinkedDocLoadData {
|
|
20
27
|
markdown?: string;
|
|
@@ -100,6 +107,13 @@ export interface UseLinkedDocOptions {
|
|
|
100
107
|
getDocumentMarkdown?: (filepath: string, fallback?: string) => string | undefined;
|
|
101
108
|
/** Let the host restore any state that was suspended while a linked doc was active. */
|
|
102
109
|
onAfterBack?: () => void;
|
|
110
|
+
/**
|
|
111
|
+
* The element the documents scroll in (the host's ScrollViewportContext
|
|
112
|
+
* value). When given, opening a linked document remembers where the reader
|
|
113
|
+
* was in the root document, and back() returns them there. Omitted, nothing
|
|
114
|
+
* about scrolling changes.
|
|
115
|
+
*/
|
|
116
|
+
scrollViewport?: HTMLElement | null;
|
|
103
117
|
}
|
|
104
118
|
|
|
105
119
|
interface SavedPlanState {
|
|
@@ -110,6 +124,8 @@ interface SavedPlanState {
|
|
|
110
124
|
renderAs: DocumentRenderAs;
|
|
111
125
|
rawHtml: string;
|
|
112
126
|
shareHtml: string;
|
|
127
|
+
/** Where the reader was in the root document when it was left. */
|
|
128
|
+
scrollAnchor?: ScrollAnchor | null;
|
|
113
129
|
}
|
|
114
130
|
|
|
115
131
|
export interface CachedDocState {
|
|
@@ -163,21 +179,31 @@ export interface UseLinkedDocReturn {
|
|
|
163
179
|
/** Whether a fetch is in progress */
|
|
164
180
|
isLoading: boolean;
|
|
165
181
|
/** Open a linked document by path (saves plan state, fetches doc, swaps).
|
|
166
|
-
* `revealSidebar: false` leaves the sidebar exactly as it was.
|
|
182
|
+
* `revealSidebar: false` leaves the sidebar exactly as it was.
|
|
183
|
+
* `restoreScroll: true` (with a `scrollViewport`) returns the reader to
|
|
184
|
+
* where they were when they last left this linked document for another
|
|
185
|
+
* one: a host's Back to a document that is not the root (a review of
|
|
186
|
+
* several files goes back to the file a link was followed from). */
|
|
167
187
|
open: (
|
|
168
188
|
docPath: string,
|
|
169
189
|
buildUrl?: (path: string) => string,
|
|
170
190
|
targetTab?: SidebarTab,
|
|
171
|
-
options?: { revealSidebar?: boolean },
|
|
191
|
+
options?: { revealSidebar?: boolean; restoreScroll?: boolean },
|
|
172
192
|
) => Promise<void>;
|
|
173
193
|
/** Open an already-loaded linked document without refetching from disk */
|
|
174
194
|
openLoaded: (
|
|
175
195
|
doc: LinkedDocLoadData & { filepath: string },
|
|
176
196
|
targetTab?: SidebarTab,
|
|
177
|
-
options?: { notifyDocumentLoaded?: boolean; revealSidebar?: boolean },
|
|
197
|
+
options?: { notifyDocumentLoaded?: boolean; revealSidebar?: boolean; restoreScroll?: boolean },
|
|
178
198
|
) => void;
|
|
179
|
-
/** Return to the plan (caches doc annotations, restores plan state)
|
|
180
|
-
back
|
|
199
|
+
/** Return to the plan (caches doc annotations, restores plan state). With a
|
|
200
|
+
* `scrollViewport`, the reader's place in the root comes back too, unless
|
|
201
|
+
* `restoreScroll: false` (a caller that scrolls to a target itself). */
|
|
202
|
+
back: (options?: { restoreScroll?: boolean }) => void;
|
|
203
|
+
/** Stop a scroll restore that back() started and is still settling. A host
|
|
204
|
+
* that scrolls programmatically (a jump to a comment or a section) calls it
|
|
205
|
+
* first, so the restore cannot cancel or undo that scroll. */
|
|
206
|
+
cancelScrollRestore: () => void;
|
|
181
207
|
/** Dismiss the current error */
|
|
182
208
|
dismissError: () => void;
|
|
183
209
|
/** All linked doc annotations including the active doc's live state (keyed by filepath).
|
|
@@ -200,6 +226,17 @@ export interface UseLinkedDocReturn {
|
|
|
200
226
|
filepath: string,
|
|
201
227
|
update: (annotations: Annotation[]) => Annotation[],
|
|
202
228
|
) => boolean;
|
|
229
|
+
/**
|
|
230
|
+
* Put recovered comments back on documents other than the active one, by
|
|
231
|
+
* path (a restored draft that carried several documents). A cached document
|
|
232
|
+
* gains the entries whose id (attachments: path) it does not hold yet; a
|
|
233
|
+
* document not opened in this session gets a cache entry, so its comments
|
|
234
|
+
* count, list and export at once, and show when it is opened. Returns the
|
|
235
|
+
* paths not taken: the active document's comments are host state.
|
|
236
|
+
*/
|
|
237
|
+
restoreStoredDocuments: (
|
|
238
|
+
documents: ReadonlyArray<{ path: string; annotations: Annotation[]; globalAttachments: ImageAttachment[] }>,
|
|
239
|
+
) => string[];
|
|
203
240
|
/** Snapshot the root document plus linked-doc cache for cross-document session swaps */
|
|
204
241
|
snapshotSession: () => LinkedDocSessionState;
|
|
205
242
|
/** Restore a root document plus linked-doc cache, closing any active linked document */
|
|
@@ -216,6 +253,17 @@ export interface UseLinkedDocReturn {
|
|
|
216
253
|
|
|
217
254
|
const HIGHLIGHT_REAPPLY_DELAY = 100;
|
|
218
255
|
|
|
256
|
+
/**
|
|
257
|
+
* After back(), the root document is re-anchored on the next frames until its
|
|
258
|
+
* blocks are in the DOM, then again at these delays (ms) to absorb late layout:
|
|
259
|
+
* re-applied highlights, images that finish loading, a sidebar that resizes the
|
|
260
|
+
* reading column. A scroll the reader makes in between ends it.
|
|
261
|
+
*/
|
|
262
|
+
const SCROLL_RESTORE_SETTLE_DELAYS = [HIGHLIGHT_REAPPLY_DELAY + 50, 400, 1000];
|
|
263
|
+
const SCROLL_RESTORE_MAX_WAIT_FRAMES = 60;
|
|
264
|
+
/** The reader's own input, which ends a restore still settling. */
|
|
265
|
+
const READER_INPUT_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown'] as const;
|
|
266
|
+
|
|
219
267
|
export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
220
268
|
const {
|
|
221
269
|
markdown,
|
|
@@ -241,8 +289,22 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
241
289
|
onDocumentActivated,
|
|
242
290
|
getDocumentMarkdown,
|
|
243
291
|
onAfterBack,
|
|
292
|
+
scrollViewport,
|
|
244
293
|
} = options;
|
|
245
294
|
|
|
295
|
+
// Read through a ref so back() keeps its identity when the viewport mounts.
|
|
296
|
+
const scrollViewportRef = useRef<HTMLElement | null>(scrollViewport ?? null);
|
|
297
|
+
scrollViewportRef.current = scrollViewport ?? null;
|
|
298
|
+
// The restore of the last back(), while it settles: stops it and releases
|
|
299
|
+
// its frames, timers and listeners. Null when nothing is settling.
|
|
300
|
+
const stopScrollRestoreRef = useRef<(() => void) | null>(null);
|
|
301
|
+
const cancelScrollRestore = useCallback(() => {
|
|
302
|
+
const stop = stopScrollRestoreRef.current;
|
|
303
|
+
stopScrollRestoreRef.current = null;
|
|
304
|
+
stop?.();
|
|
305
|
+
}, []);
|
|
306
|
+
useEffect(() => cancelScrollRestore, [cancelScrollRestore]);
|
|
307
|
+
|
|
246
308
|
const [linkedDoc, setLinkedDoc] = useState<{
|
|
247
309
|
filepath: string;
|
|
248
310
|
isConverted?: boolean;
|
|
@@ -264,14 +326,90 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
264
326
|
|
|
265
327
|
// Cache linked doc annotations keyed by filepath (persists across back/forth within session)
|
|
266
328
|
const docCache = useRef<Map<string, CachedDocState>>(new Map());
|
|
329
|
+
// Where the reader was in each linked document when they left it for
|
|
330
|
+
// another one, keyed by normalized path. Read only by an open() that asks
|
|
331
|
+
// for `restoreScroll` (the root's place lives in savedPlanState instead).
|
|
332
|
+
const docScrollAnchors = useRef<Map<string, ScrollAnchor>>(new Map());
|
|
267
333
|
|
|
268
334
|
const defaultBuildUrl = useCallback(
|
|
269
335
|
(path: string) => `/api/doc?path=${encodeURIComponent(path)}`,
|
|
270
336
|
[]
|
|
271
337
|
);
|
|
272
338
|
|
|
273
|
-
const
|
|
339
|
+
const restoreScrollAnchorSettled = useCallback((anchor: ScrollAnchor) => {
|
|
340
|
+
cancelScrollRestore();
|
|
341
|
+
let stopped = false;
|
|
342
|
+
const frames = new Set<number>();
|
|
343
|
+
const timers = new Set<ReturnType<typeof setTimeout>>();
|
|
344
|
+
const win = typeof window === 'undefined' ? null : window;
|
|
345
|
+
const stop = () => {
|
|
346
|
+
if (stopped) return;
|
|
347
|
+
stopped = true;
|
|
348
|
+
for (const id of frames) cancelAnimationFrame(id);
|
|
349
|
+
for (const id of timers) clearTimeout(id);
|
|
350
|
+
frames.clear();
|
|
351
|
+
timers.clear();
|
|
352
|
+
for (const type of READER_INPUT_EVENTS) win?.removeEventListener(type, onReaderInput, true);
|
|
353
|
+
if (stopScrollRestoreRef.current === stop) stopScrollRestoreRef.current = null;
|
|
354
|
+
};
|
|
355
|
+
// Any input of the reader's own ends the restore: from then on, where the
|
|
356
|
+
// page is is theirs.
|
|
357
|
+
function onReaderInput() { stop(); }
|
|
358
|
+
for (const type of READER_INPUT_EVENTS) {
|
|
359
|
+
win?.addEventListener(type, onReaderInput, { capture: true, passive: true });
|
|
360
|
+
}
|
|
361
|
+
stopScrollRestoreRef.current = stop;
|
|
362
|
+
|
|
363
|
+
const frame = (fn: () => void) => {
|
|
364
|
+
const id = requestAnimationFrame(() => { frames.delete(id); fn(); });
|
|
365
|
+
frames.add(id);
|
|
366
|
+
};
|
|
367
|
+
const later = (fn: () => void, ms: number) => {
|
|
368
|
+
const id = setTimeout(() => { timers.delete(id); fn(); }, ms);
|
|
369
|
+
timers.add(id);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
let lastTop: number | null = null;
|
|
373
|
+
const apply = () => {
|
|
374
|
+
const viewport = scrollViewportRef.current;
|
|
375
|
+
if (stopped || !viewport) return;
|
|
376
|
+
// Something else scrolled since the last re-anchor (the reader, or the
|
|
377
|
+
// host scrolling to a target): that position wins.
|
|
378
|
+
if (lastTop !== null && Math.abs(getScrollViewportTop(viewport) - lastTop) > 2) {
|
|
379
|
+
stop();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
lastTop = restoreScrollAnchor(viewport, anchor);
|
|
383
|
+
};
|
|
384
|
+
let waited = 0;
|
|
385
|
+
const firstApply = () => {
|
|
386
|
+
if (stopped) return;
|
|
387
|
+
const viewport = scrollViewportRef.current;
|
|
388
|
+
if (viewport && scrollAnchorReady(viewport, anchor)) {
|
|
389
|
+
apply();
|
|
390
|
+
if (stopped) return;
|
|
391
|
+
const last = SCROLL_RESTORE_SETTLE_DELAYS[SCROLL_RESTORE_SETTLE_DELAYS.length - 1];
|
|
392
|
+
for (const delay of SCROLL_RESTORE_SETTLE_DELAYS) {
|
|
393
|
+
later(() => { apply(); if (delay === last) stop(); }, delay);
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (++waited > SCROLL_RESTORE_MAX_WAIT_FRAMES) {
|
|
398
|
+
if (viewport) apply();
|
|
399
|
+
stop();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
frame(firstApply);
|
|
403
|
+
};
|
|
404
|
+
// Two frames: the swap commits, then layout settles.
|
|
405
|
+
frame(() => frame(firstApply));
|
|
406
|
+
}, [cancelScrollRestore]);
|
|
407
|
+
|
|
408
|
+
const back = useCallback((backOptions?: { restoreScroll?: boolean }) => {
|
|
274
409
|
if (!savedPlanState.current) return;
|
|
410
|
+
// `back` may be handed straight to an onClick, so only an explicit false
|
|
411
|
+
// turns the restore off.
|
|
412
|
+
const restoreScroll = backOptions?.restoreScroll !== false;
|
|
275
413
|
onBeforeNavigate?.();
|
|
276
414
|
|
|
277
415
|
// Clear web-highlighter marks before swapping content to prevent React DOM mismatch
|
|
@@ -308,6 +446,8 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
308
446
|
setError(null);
|
|
309
447
|
savedPlanState.current = null;
|
|
310
448
|
onAfterBack?.();
|
|
449
|
+
if (restoreScroll && saved.scrollAnchor) restoreScrollAnchorSettled(saved.scrollAnchor);
|
|
450
|
+
else cancelScrollRestore();
|
|
311
451
|
|
|
312
452
|
// Re-apply plan annotation highlights after DOM settles
|
|
313
453
|
if (saved.annotations.length) {
|
|
@@ -331,6 +471,8 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
331
471
|
onBeforeNavigate,
|
|
332
472
|
getDocumentMarkdown,
|
|
333
473
|
onAfterBack,
|
|
474
|
+
restoreScrollAnchorSettled,
|
|
475
|
+
cancelScrollRestore,
|
|
334
476
|
]);
|
|
335
477
|
|
|
336
478
|
const activateDocument = useCallback((
|
|
@@ -340,6 +482,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
340
482
|
snapshotCurrent?: boolean;
|
|
341
483
|
notifyDocumentLoaded?: boolean;
|
|
342
484
|
revealSidebar?: boolean;
|
|
485
|
+
restoreScroll?: boolean;
|
|
343
486
|
} = {},
|
|
344
487
|
) => {
|
|
345
488
|
const snapshotCurrent = options.snapshotCurrent ?? true;
|
|
@@ -367,9 +510,14 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
367
510
|
// Clear web-highlighter marks before swapping content to prevent React DOM mismatch
|
|
368
511
|
viewerRef.current?.clearAllHighlights();
|
|
369
512
|
|
|
513
|
+
// A restore of an earlier back() must not pull the new document around.
|
|
514
|
+
cancelScrollRestore();
|
|
515
|
+
|
|
370
516
|
// Save current state (plan or another linked doc)
|
|
371
517
|
if (!savedPlanState.current) {
|
|
518
|
+
const viewport = scrollViewportRef.current;
|
|
372
519
|
savedPlanState.current = {
|
|
520
|
+
scrollAnchor: viewport ? captureScrollAnchor(viewport) : null,
|
|
373
521
|
markdown,
|
|
374
522
|
annotations: [...annotations],
|
|
375
523
|
selectedAnnotationId,
|
|
@@ -385,7 +533,12 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
385
533
|
}
|
|
386
534
|
setDocAnnotationCount(total);
|
|
387
535
|
} else if (linkedDoc) {
|
|
388
|
-
// Already viewing a linked doc —
|
|
536
|
+
// Already viewing a linked doc — remember the reader's place in it and
|
|
537
|
+
// cache its annotations before moving on
|
|
538
|
+
const viewport = scrollViewportRef.current;
|
|
539
|
+
if (viewport) {
|
|
540
|
+
docScrollAnchors.current.set(normalizeBrowserPath(linkedDoc.filepath), captureScrollAnchor(viewport));
|
|
541
|
+
}
|
|
389
542
|
docCache.current.set(linkedDoc.filepath, {
|
|
390
543
|
annotations: [...annotations],
|
|
391
544
|
globalAttachments: [...globalAttachments],
|
|
@@ -413,9 +566,10 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
413
566
|
// App's renderAs === 'html' ? HtmlViewer : Viewer switch flips automatically.
|
|
414
567
|
// A diagram source (.mmd/.dot) keeps the markdown state path — the raw
|
|
415
568
|
// text is the document body — but names its engine so the App renders it
|
|
416
|
-
// as one diagram instead of parsing it as markdown.
|
|
569
|
+
// as one diagram instead of parsing it as markdown. AsciiDoc does the same
|
|
570
|
+
// for its own block parser.
|
|
417
571
|
const docRenderAs: DocumentRenderAs =
|
|
418
|
-
data.renderAs === 'html' ? 'html' :
|
|
572
|
+
data.renderAs === 'html' ? 'html' : isDocumentRenderKind(data.renderAs) ? data.renderAs : 'markdown';
|
|
419
573
|
const hostMarkdown = docRenderAs === 'html' || !notifyDocumentLoaded ? undefined : onDocumentLoaded?.(data);
|
|
420
574
|
const nextMarkdown = notifyDocumentLoaded
|
|
421
575
|
? hostMarkdown ?? cached?.markdown ?? data.markdown ?? ''
|
|
@@ -439,6 +593,13 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
439
593
|
if (revealSidebar) sidebar.open(targetTab ?? "toc");
|
|
440
594
|
onDocumentActivated?.(data);
|
|
441
595
|
|
|
596
|
+
// Back to a linked document the reader left for another one: put them
|
|
597
|
+
// back where they were, exactly as back() does for the root.
|
|
598
|
+
const scrollAnchor = options.restoreScroll
|
|
599
|
+
? docScrollAnchors.current.get(normalizeBrowserPath(data.filepath))
|
|
600
|
+
: undefined;
|
|
601
|
+
if (scrollAnchor) restoreScrollAnchorSettled(scrollAnchor);
|
|
602
|
+
|
|
442
603
|
// Re-apply cached annotations after DOM settles
|
|
443
604
|
if (cached?.annotations.length) {
|
|
444
605
|
setTimeout(() => {
|
|
@@ -470,17 +631,20 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
470
631
|
onDocumentActivated,
|
|
471
632
|
getDocumentMarkdown,
|
|
472
633
|
back,
|
|
634
|
+
cancelScrollRestore,
|
|
635
|
+
restoreScrollAnchorSettled,
|
|
473
636
|
]);
|
|
474
637
|
|
|
475
638
|
const openLoaded = useCallback((
|
|
476
639
|
doc: LinkedDocLoadData & { filepath: string },
|
|
477
640
|
targetTab?: SidebarTab,
|
|
478
|
-
options?: { notifyDocumentLoaded?: boolean; revealSidebar?: boolean },
|
|
641
|
+
options?: { notifyDocumentLoaded?: boolean; revealSidebar?: boolean; restoreScroll?: boolean },
|
|
479
642
|
) => {
|
|
480
643
|
activateDocument(doc, targetTab, {
|
|
481
644
|
snapshotCurrent: true,
|
|
482
645
|
notifyDocumentLoaded: options?.notifyDocumentLoaded,
|
|
483
646
|
revealSidebar: options?.revealSidebar,
|
|
647
|
+
restoreScroll: options?.restoreScroll,
|
|
484
648
|
});
|
|
485
649
|
}, [activateDocument]);
|
|
486
650
|
|
|
@@ -489,7 +653,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
489
653
|
docPath: string,
|
|
490
654
|
buildUrl?: (path: string) => string,
|
|
491
655
|
targetTab?: SidebarTab,
|
|
492
|
-
options?: { revealSidebar?: boolean },
|
|
656
|
+
options?: { revealSidebar?: boolean; restoreScroll?: boolean },
|
|
493
657
|
) => {
|
|
494
658
|
onBeforeNavigate?.();
|
|
495
659
|
setIsLoading(true);
|
|
@@ -515,6 +679,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
515
679
|
activateDocument({ ...data, filepath: data.filepath }, targetTab, {
|
|
516
680
|
snapshotCurrent: false,
|
|
517
681
|
revealSidebar: options?.revealSidebar,
|
|
682
|
+
restoreScroll: options?.restoreScroll,
|
|
518
683
|
});
|
|
519
684
|
} catch {
|
|
520
685
|
setError("Failed to connect to server");
|
|
@@ -568,9 +733,11 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
568
733
|
|
|
569
734
|
const restoreSession = useCallback((state: LinkedDocSessionState) => {
|
|
570
735
|
viewerRef.current?.clearAllHighlights();
|
|
736
|
+
cancelScrollRestore();
|
|
571
737
|
|
|
572
738
|
savedPlanState.current = null;
|
|
573
739
|
docCache.current = new Map(state.docs);
|
|
740
|
+
docScrollAnchors.current = new Map();
|
|
574
741
|
let total = 0;
|
|
575
742
|
for (const cached of docCache.current.values()) {
|
|
576
743
|
total += cached.annotations.length + cached.globalAttachments.length;
|
|
@@ -648,6 +815,50 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
648
815
|
return true;
|
|
649
816
|
}, [linkedDoc, sourceFilePath]);
|
|
650
817
|
|
|
818
|
+
const restoreStoredDocuments = useCallback((
|
|
819
|
+
documents: ReadonlyArray<{ path: string; annotations: Annotation[]; globalAttachments: ImageAttachment[] }>,
|
|
820
|
+
): string[] => {
|
|
821
|
+
const notTaken: string[] = [];
|
|
822
|
+
let changed = false;
|
|
823
|
+
for (const doc of documents) {
|
|
824
|
+
const wanted = normalizeBrowserPath(doc.path);
|
|
825
|
+
if (linkedDoc && wanted === normalizeBrowserPath(linkedDoc.filepath)) {
|
|
826
|
+
notTaken.push(doc.path);
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
let cacheKey: string | undefined;
|
|
830
|
+
for (const key of docCache.current.keys()) {
|
|
831
|
+
if (normalizeBrowserPath(key) === wanted) { cacheKey = key; break; }
|
|
832
|
+
}
|
|
833
|
+
const cached = cacheKey === undefined ? undefined : docCache.current.get(cacheKey);
|
|
834
|
+
const heldIds = new Set((cached?.annotations ?? []).map((a) => a.id));
|
|
835
|
+
const heldPaths = new Set((cached?.globalAttachments ?? []).map((g) => g.path));
|
|
836
|
+
const annotations = doc.annotations.filter((a) => !heldIds.has(a.id));
|
|
837
|
+
const globalAttachments = doc.globalAttachments.filter((g) => !heldPaths.has(g.path));
|
|
838
|
+
if (annotations.length === 0 && globalAttachments.length === 0) continue;
|
|
839
|
+
docCache.current.set(cacheKey ?? doc.path, {
|
|
840
|
+
...(cached ?? {}),
|
|
841
|
+
annotations: [...(cached?.annotations ?? []), ...annotations],
|
|
842
|
+
globalAttachments: [...(cached?.globalAttachments ?? []), ...globalAttachments],
|
|
843
|
+
});
|
|
844
|
+
changed = true;
|
|
845
|
+
}
|
|
846
|
+
if (changed) {
|
|
847
|
+
// Same accounting as updateStoredAnnotations.
|
|
848
|
+
let total = 0;
|
|
849
|
+
for (const [fp, entry] of docCache.current.entries()) {
|
|
850
|
+
if (linkedDoc && fp === linkedDoc.filepath) continue;
|
|
851
|
+
total += entry.annotations.length + entry.globalAttachments.length;
|
|
852
|
+
}
|
|
853
|
+
if (linkedDoc && savedPlanState.current) {
|
|
854
|
+
total += savedPlanState.current.annotations.length + savedPlanState.current.globalAttachments.length;
|
|
855
|
+
}
|
|
856
|
+
setDocAnnotationCount(total);
|
|
857
|
+
setStoreRevision((r) => r + 1);
|
|
858
|
+
}
|
|
859
|
+
return notTaken;
|
|
860
|
+
}, [linkedDoc]);
|
|
861
|
+
|
|
651
862
|
const getDocAnnotations = useCallback((): Map<string, CachedDocState> => {
|
|
652
863
|
const result = new Map(docCache.current);
|
|
653
864
|
// Include stashed original-file annotations when viewing a linked doc
|
|
@@ -711,10 +922,12 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
|
|
|
711
922
|
open,
|
|
712
923
|
openLoaded,
|
|
713
924
|
back,
|
|
925
|
+
cancelScrollRestore,
|
|
714
926
|
dismissError,
|
|
715
927
|
getDocAnnotations,
|
|
716
928
|
getFeedbackDocuments,
|
|
717
929
|
updateStoredAnnotations,
|
|
930
|
+
restoreStoredDocuments,
|
|
718
931
|
snapshotSession,
|
|
719
932
|
restoreSession,
|
|
720
933
|
docAnnotationCount,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Package changes in code review: the card switch (`card`, default on) and
|
|
5
|
+
* the npm lookup consent (`lookup`, default off), from serverConfig and
|
|
6
|
+
* written back with POST /api/config. The server enforces `lookup`; this
|
|
7
|
+
* store only mirrors it so the card and Settings agree. Deliberately not a
|
|
8
|
+
* settings-registry entry, like useUpdateCheckSetting: the registry is bundled
|
|
9
|
+
* into the portable guides.show viewer.
|
|
10
|
+
*/
|
|
11
|
+
export interface PackageChangesSettingState {
|
|
12
|
+
card: boolean;
|
|
13
|
+
lookup: boolean;
|
|
14
|
+
/** PLANNOTATOR_PACKAGE_LOOKUP=0: the lookup is off and Settings cannot turn it on. */
|
|
15
|
+
lookupEnv?: false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PackageChangesSetting extends PackageChangesSettingState {
|
|
19
|
+
setCard: (next: boolean) => void;
|
|
20
|
+
/** Resolves true once config.json holds the new value. */
|
|
21
|
+
setLookup: (next: boolean) => Promise<boolean>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_STATE: PackageChangesSettingState = { card: true, lookup: false };
|
|
25
|
+
let state: PackageChangesSettingState | undefined;
|
|
26
|
+
const listeners = new Set<() => void>();
|
|
27
|
+
|
|
28
|
+
function publish(next: PackageChangesSettingState | undefined): void {
|
|
29
|
+
state = next;
|
|
30
|
+
for (const l of listeners) l();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Adopt serverConfig.packageChanges (called where the app reads /api/diff). */
|
|
34
|
+
export function adoptPackageChangesConfig(serverConfig: Record<string, unknown> | undefined): void {
|
|
35
|
+
const raw = serverConfig?.packageChanges as Record<string, unknown> | undefined;
|
|
36
|
+
if (!raw || typeof raw.card !== 'boolean' || typeof raw.lookup !== 'boolean') {
|
|
37
|
+
publish(undefined);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
publish({ card: raw.card, lookup: raw.lookup, ...(raw.lookupEnv === false && { lookupEnv: false as const }) });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Test hook. */
|
|
44
|
+
export function resetPackageChangesSetting(): void {
|
|
45
|
+
publish(undefined);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function save(patch: Partial<Pick<PackageChangesSettingState, 'card' | 'lookup'>>): Promise<boolean> {
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch('/api/config', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify({ packageChanges: patch }),
|
|
54
|
+
});
|
|
55
|
+
return res.ok;
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const setCard = (next: boolean): void => {
|
|
62
|
+
const before = state ?? DEFAULT_STATE;
|
|
63
|
+
publish({ ...before, card: next });
|
|
64
|
+
void save({ card: next }).then((ok) => {
|
|
65
|
+
if (!ok) publish({ ...(state ?? DEFAULT_STATE), card: before.card });
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const setLookup = async (next: boolean): Promise<boolean> => {
|
|
70
|
+
const before = state ?? DEFAULT_STATE;
|
|
71
|
+
if (next && before.lookupEnv === false) return false;
|
|
72
|
+
// Off applies at once; on only once saved, because the server refuses a
|
|
73
|
+
// lookup until config.json says yes.
|
|
74
|
+
if (!next) publish({ ...before, lookup: false });
|
|
75
|
+
const ok = await save({ lookup: next });
|
|
76
|
+
if (ok && next) publish({ ...(state ?? DEFAULT_STATE), lookup: true });
|
|
77
|
+
if (!ok && !next) publish({ ...(state ?? DEFAULT_STATE), lookup: before.lookup });
|
|
78
|
+
return ok;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const subscribe = (l: () => void) => {
|
|
82
|
+
listeners.add(l);
|
|
83
|
+
return () => listeners.delete(l);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** The setting, or undefined until the server advertised it (an older server never does). */
|
|
87
|
+
export function usePackageChangesSetting(): PackageChangesSetting | undefined {
|
|
88
|
+
const current = useSyncExternalStore(subscribe, () => state, () => undefined);
|
|
89
|
+
return current ? { ...current, setCard, setLookup } : undefined;
|
|
90
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { configStore } from '../config';
|
|
3
|
+
import {
|
|
4
|
+
markLookAndFeelChoiceResolved,
|
|
5
|
+
planLookAnsweredElsewhere,
|
|
6
|
+
planLookPrompt,
|
|
7
|
+
type PlanLookPrompt,
|
|
8
|
+
} from '../utils/lookAndFeelAnnouncement';
|
|
9
|
+
|
|
10
|
+
export interface PlanLookDialog {
|
|
11
|
+
/** Which dialog this page load shows: the first-run chooser, the one-time "moved to Clean" notice, or none. Read once per page load. */
|
|
12
|
+
prompt: PlanLookPrompt;
|
|
13
|
+
open: boolean;
|
|
14
|
+
/** Continue or Escape: record `gridEnabled` as the choice, unless another tab answered first. */
|
|
15
|
+
dismiss: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The plan-look chooser and notice, shared by the plan editor and the code
|
|
20
|
+
* review app. The cookie marker is shared, so answering in either app
|
|
21
|
+
* resolves it everywhere.
|
|
22
|
+
*
|
|
23
|
+
* The first answer wins: two open tabs both show the dialog, and when one is
|
|
24
|
+
* answered the other adopts that look and writes nothing, so its preselected
|
|
25
|
+
* Clean can never overwrite a Grid picked in the first tab. That happens on
|
|
26
|
+
* dismiss, and also when the person comes back to the tab (visibilitychange
|
|
27
|
+
* or focus) while the dialog is still open.
|
|
28
|
+
*/
|
|
29
|
+
export function usePlanLookDialog(gridEnabled: boolean): PlanLookDialog {
|
|
30
|
+
const [prompt] = useState(planLookPrompt);
|
|
31
|
+
const [open, setOpen] = useState(prompt !== null);
|
|
32
|
+
|
|
33
|
+
const adopt = useCallback((grid: boolean) => {
|
|
34
|
+
configStore.seed('gridEnabled', grid);
|
|
35
|
+
setOpen(false);
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!open) return;
|
|
40
|
+
const check = () => {
|
|
41
|
+
if (document.visibilityState !== 'visible') return;
|
|
42
|
+
const grid = planLookAnsweredElsewhere();
|
|
43
|
+
if (grid !== null) adopt(grid);
|
|
44
|
+
};
|
|
45
|
+
document.addEventListener('visibilitychange', check);
|
|
46
|
+
window.addEventListener('focus', check);
|
|
47
|
+
return () => {
|
|
48
|
+
document.removeEventListener('visibilitychange', check);
|
|
49
|
+
window.removeEventListener('focus', check);
|
|
50
|
+
};
|
|
51
|
+
}, [open, adopt]);
|
|
52
|
+
|
|
53
|
+
const dismiss = useCallback(() => {
|
|
54
|
+
const answered = planLookAnsweredElsewhere();
|
|
55
|
+
if (answered !== null) {
|
|
56
|
+
adopt(answered);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Persist even when the user accepts the displayed default without first
|
|
60
|
+
// clicking its already-selected card, then record the explicit decision.
|
|
61
|
+
configStore.set('gridEnabled', gridEnabled);
|
|
62
|
+
markLookAndFeelChoiceResolved();
|
|
63
|
+
setOpen(false);
|
|
64
|
+
}, [gridEnabled, adopt]);
|
|
65
|
+
|
|
66
|
+
return { prompt, open, dismiss };
|
|
67
|
+
}
|