@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.
Files changed (145) hide show
  1. package/HANDOFF.md +284 -27
  2. package/README.md +84 -14
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +177 -34
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/AnnotationToolstrip.tsx +45 -40
  9. package/components/ApproveDropdown.tsx +7 -9
  10. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  11. package/components/AttachmentsButton.tsx +1 -1
  12. package/components/BlockRenderer.tsx +19 -5
  13. package/components/CodeFilePopout.tsx +140 -5
  14. package/components/CommentPopover.tsx +46 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DiagramBlock.tsx +17 -12
  17. package/components/EditorAnnotationCard.tsx +1 -1
  18. package/components/ExportModal.tsx +77 -7
  19. package/components/HostTitle.tsx +0 -4
  20. package/components/HtmlSurfaceControls.tsx +2 -1
  21. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  22. package/components/ImageAnnotator/index.tsx +24 -4
  23. package/components/ImageLightbox.tsx +658 -120
  24. package/components/ImagePeek.tsx +38 -4
  25. package/components/ImageRegions.tsx +369 -0
  26. package/components/ImageThumbnail.tsx +1 -1
  27. package/components/ImageZoomStage.tsx +796 -0
  28. package/components/InboxAnnouncementDialog.tsx +202 -0
  29. package/components/InlineMarkdown.tsx +152 -24
  30. package/components/KeyboardShortcuts.tsx +23 -0
  31. package/components/LazyBlockBoundary.tsx +100 -0
  32. package/components/ListMarker.tsx +3 -3
  33. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  34. package/components/MentionPicker.tsx +51 -0
  35. package/components/MenuVersionSection.tsx +31 -9
  36. package/components/PlanDecisionMenuExtras.tsx +129 -0
  37. package/components/PlanHeaderMenu.tsx +15 -1
  38. package/components/ProviderIcons.tsx +8 -0
  39. package/components/RefHoverCard.tsx +913 -0
  40. package/components/SendFailureToast.tsx +126 -0
  41. package/components/Settings.tsx +617 -126
  42. package/components/SkillReferenceMenu.tsx +39 -12
  43. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  44. package/components/ThemeProvider.tsx +14 -1
  45. package/components/ThemeTab.tsx +16 -24
  46. package/components/Tooltip.tsx +49 -18
  47. package/components/Viewer.tsx +316 -47
  48. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  49. package/components/ai/NewChatButton.tsx +74 -0
  50. package/components/blocks/CodeBlock.tsx +1 -1
  51. package/components/blocks/HtmlBlock.tsx +7 -1
  52. package/components/blocks/QuestionBlock.tsx +522 -62
  53. package/components/blocks/QuestionContext.tsx +3 -0
  54. package/components/diagram/DiagramComposer.tsx +5 -1
  55. package/components/diagram/DiagramPending.tsx +12 -2
  56. package/components/diagram/DiagramViewer.tsx +19 -3
  57. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  58. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  59. package/components/html-embed/index.ts +3 -0
  60. package/components/html-embed/languages.ts +15 -0
  61. package/components/html-embed/loadGate.ts +107 -0
  62. package/components/html-viewer/HtmlViewer.tsx +69 -6
  63. package/components/html-viewer/bridge-script.asset.js +504 -12
  64. package/components/html-viewer/bridge-script.lite.ts +1 -1
  65. package/components/html-viewer/bridge-script.ts +545 -12
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +265 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  78. package/hooks/useAIChat.ts +72 -25
  79. package/hooks/useAnnotationDraft.ts +71 -6
  80. package/hooks/useAnnotationHighlighter.ts +57 -3
  81. package/hooks/useCodeAnnotationDraft.ts +113 -17
  82. package/hooks/useCookieHostSetting.ts +96 -0
  83. package/hooks/useDocumentFont.ts +17 -0
  84. package/hooks/useDocumentTextSize.ts +20 -0
  85. package/hooks/useFileBrowser.ts +10 -1
  86. package/hooks/useLinkedDoc.ts +226 -13
  87. package/hooks/useMentionAutocomplete.ts +1 -1
  88. package/hooks/usePackageChangesSetting.ts +90 -0
  89. package/hooks/usePlanLookDialog.ts +67 -0
  90. package/hooks/useSharing.ts +11 -7
  91. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  92. package/hooks/useThemePaletteHalves.ts +37 -0
  93. package/hooks/useUpdateCheck.ts +22 -4
  94. package/hooks/useUpdateToast.ts +1 -1
  95. package/hooks/useVimSelection.ts +9 -0
  96. package/package.json +23 -22
  97. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  98. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  99. package/shortcuts/core.ts +63 -4
  100. package/shortcuts/index.ts +1 -0
  101. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  102. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  103. package/shortcuts/runtime.ts +14 -6
  104. package/styles.css +1 -1
  105. package/theme.css +179 -1
  106. package/themes/plannotator-graphite.css +66 -0
  107. package/types.ts +26 -9
  108. package/utils/agentSwitch.ts +41 -20
  109. package/utils/agentToolSetting.ts +1 -1
  110. package/utils/aiChatFormat.ts +23 -7
  111. package/utils/annotationOwnsHighlight.ts +4 -2
  112. package/utils/asciidocParser.ts +570 -0
  113. package/utils/autoUpdateNotice.ts +24 -0
  114. package/utils/belowTitleSlot.ts +174 -0
  115. package/utils/codeLineScroll.ts +100 -0
  116. package/utils/commentSubmitKey.ts +48 -0
  117. package/utils/cssFontFamily.ts +65 -0
  118. package/utils/decisionSpec.ts +190 -2
  119. package/utils/defaultNotesApp.ts +1 -1
  120. package/utils/diffFonts.ts +4 -1
  121. package/utils/documentFont.ts +68 -0
  122. package/utils/documentTextSize.ts +54 -0
  123. package/utils/fontDetect.ts +63 -0
  124. package/utils/inboxAnnouncement.ts +78 -0
  125. package/utils/lookAndFeelAnnouncement.ts +82 -5
  126. package/utils/markdownExtensions.ts +2 -2
  127. package/utils/mentions.ts +34 -5
  128. package/utils/mermaid.ts +20 -3
  129. package/utils/mermaidTheme.ts +3 -51
  130. package/utils/notion.ts +58 -0
  131. package/utils/parser.ts +28 -1
  132. package/utils/platform.ts +46 -0
  133. package/utils/refCard.ts +82 -0
  134. package/utils/richClipboard.ts +380 -0
  135. package/utils/richClipboardLists.ts +57 -0
  136. package/utils/richCopyPolicy.ts +22 -0
  137. package/utils/scrollAnchor.ts +82 -0
  138. package/utils/sectionRefs.ts +123 -0
  139. package/utils/selectionBoundary.ts +40 -0
  140. package/utils/sendFailure.ts +85 -0
  141. package/utils/sharing.ts +5 -2
  142. package/utils/themeRegistry.ts +10 -0
  143. package/utils/themeTokens.ts +74 -0
  144. package/utils/updateNotice.ts +30 -8
  145. 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
+ }
@@ -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
  )
@@ -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 { isDiagramRenderKind } from "@plannotator/core/annotatable";
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: () => void;
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 back = useCallback(() => {
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 — cache its annotations before moving on
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' : isDiagramRenderKind(data.renderAs) ? data.renderAs : 'markdown';
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,
@@ -115,7 +115,7 @@ export function useMentionAutocomplete(options: {
115
115
  }, [mentionsKey, onMentionsChange]);
116
116
 
117
117
  const items = useMemo(
118
- () => (source && trigger ? mentionMatches(source.people, trigger, taggedIds) : []),
118
+ () => (source && trigger ? mentionMatches(source.people, trigger, taggedIds, source.kinds, source.matchDetail ?? true) : []),
119
119
  [source, trigger, taggedIds],
120
120
  );
121
121
 
@@ -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
+ }