@plannotator/ui 0.39.0 → 0.40.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.
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { useState, useCallback, useRef } from "react";
10
+ import { normalizeBrowserPath } from "@plannotator/core/browser-paths";
10
11
  import type { Annotation, ImageAttachment } from "../types";
11
12
  import type { ViewerHandle } from "../components/Viewer";
12
13
  import type { SidebarTab } from "./useSidebar";
@@ -136,13 +137,19 @@ export interface UseLinkedDocReturn {
136
137
  error: string | null;
137
138
  /** Whether a fetch is in progress */
138
139
  isLoading: boolean;
139
- /** Open a linked document by path (saves plan state, fetches doc, swaps) */
140
- open: (docPath: string, buildUrl?: (path: string) => string, targetTab?: SidebarTab) => Promise<void>;
140
+ /** Open a linked document by path (saves plan state, fetches doc, swaps).
141
+ * `revealSidebar: false` leaves the sidebar exactly as it was. */
142
+ open: (
143
+ docPath: string,
144
+ buildUrl?: (path: string) => string,
145
+ targetTab?: SidebarTab,
146
+ options?: { revealSidebar?: boolean },
147
+ ) => Promise<void>;
141
148
  /** Open an already-loaded linked document without refetching from disk */
142
149
  openLoaded: (
143
150
  doc: LinkedDocLoadData & { filepath: string },
144
151
  targetTab?: SidebarTab,
145
- options?: { notifyDocumentLoaded?: boolean },
152
+ options?: { notifyDocumentLoaded?: boolean; revealSidebar?: boolean },
146
153
  ) => void;
147
154
  /** Return to the plan (caches doc annotations, restores plan state) */
148
155
  back: () => void;
@@ -150,6 +157,20 @@ export interface UseLinkedDocReturn {
150
157
  dismissError: () => void;
151
158
  /** All linked doc annotations including the active doc's live state (keyed by filepath) */
152
159
  getDocAnnotations: () => Map<string, CachedDocState>;
160
+ /**
161
+ * Replace the stored annotations of a document that is NOT the active one —
162
+ * a cached linked doc, or the stashed source document. This is what lets the
163
+ * panel's cross-file view edit and delete another file's comments without
164
+ * navigating to it; the active document's annotations are host state
165
+ * (`setAnnotations`) and are deliberately never touched here.
166
+ *
167
+ * Returns false when no stored document matches (including the active one),
168
+ * so a host can fall back to its own live-state path.
169
+ */
170
+ updateStoredAnnotations: (
171
+ filepath: string,
172
+ update: (annotations: Annotation[]) => Annotation[],
173
+ ) => boolean;
153
174
  /** Snapshot the root document plus linked-doc cache for cross-document session swaps */
154
175
  snapshotSession: () => LinkedDocSessionState;
155
176
  /** Restore a root document plus linked-doc cache, closing any active linked document */
@@ -203,6 +224,11 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
203
224
  const [error, setError] = useState<string | null>(null);
204
225
  const [isLoading, setIsLoading] = useState(false);
205
226
  const [docAnnotationCount, setDocAnnotationCount] = useState(0);
227
+ // Bumped whenever a stored (non-active) document's annotations change in
228
+ // place. docCache is a ref, so without this the memos keyed on
229
+ // getDocAnnotations' identity — panel groups, counts, the export — would
230
+ // keep serving the pre-mutation cache.
231
+ const [storeRevision, setStoreRevision] = useState(0);
206
232
 
207
233
  // Stash plan state when navigating to a linked doc
208
234
  const savedPlanState = useRef<SavedPlanState | null>(null);
@@ -281,10 +307,19 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
281
307
  const activateDocument = useCallback((
282
308
  data: LinkedDocLoadData & { filepath: string },
283
309
  targetTab?: SidebarTab,
284
- options: { snapshotCurrent?: boolean; notifyDocumentLoaded?: boolean } = {},
310
+ options: {
311
+ snapshotCurrent?: boolean;
312
+ notifyDocumentLoaded?: boolean;
313
+ revealSidebar?: boolean;
314
+ } = {},
285
315
  ) => {
286
316
  const snapshotCurrent = options.snapshotCurrent ?? true;
287
317
  const notifyDocumentLoaded = options.notifyDocumentLoaded ?? true;
318
+ // Opening a document reveals the sidebar so its "Viewing / Back to …"
319
+ // header is in reach. A host whose surface has its own way back (the
320
+ // raw-HTML header's Back control) passes false, and the sidebar is left
321
+ // exactly as the user had it — closed stays closed, open stays put.
322
+ const revealSidebar = options.revealSidebar ?? true;
288
323
  if (snapshotCurrent) onBeforeNavigate?.();
289
324
 
290
325
  // Backlink detection: if a linked doc links back to the source file (e.g.,
@@ -368,7 +403,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
368
403
  versionInfo: diffBaseline.versionInfo,
369
404
  });
370
405
  setError(null);
371
- sidebar.open(targetTab ?? "toc");
406
+ if (revealSidebar) sidebar.open(targetTab ?? "toc");
372
407
  onDocumentActivated?.(data);
373
408
 
374
409
  // Re-apply cached annotations after DOM settles
@@ -407,16 +442,22 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
407
442
  const openLoaded = useCallback((
408
443
  doc: LinkedDocLoadData & { filepath: string },
409
444
  targetTab?: SidebarTab,
410
- options?: { notifyDocumentLoaded?: boolean },
445
+ options?: { notifyDocumentLoaded?: boolean; revealSidebar?: boolean },
411
446
  ) => {
412
447
  activateDocument(doc, targetTab, {
413
448
  snapshotCurrent: true,
414
449
  notifyDocumentLoaded: options?.notifyDocumentLoaded,
450
+ revealSidebar: options?.revealSidebar,
415
451
  });
416
452
  }, [activateDocument]);
417
453
 
418
454
  const open = useCallback(
419
- async (docPath: string, buildUrl?: (path: string) => string, targetTab?: SidebarTab) => {
455
+ async (
456
+ docPath: string,
457
+ buildUrl?: (path: string) => string,
458
+ targetTab?: SidebarTab,
459
+ options?: { revealSidebar?: boolean },
460
+ ) => {
420
461
  onBeforeNavigate?.();
421
462
  setIsLoading(true);
422
463
  setError(null);
@@ -438,7 +479,10 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
438
479
  setError("Failed to load document");
439
480
  return;
440
481
  }
441
- activateDocument({ ...data, filepath: data.filepath }, targetTab, { snapshotCurrent: false });
482
+ activateDocument({ ...data, filepath: data.filepath }, targetTab, {
483
+ snapshotCurrent: false,
484
+ revealSidebar: options?.revealSidebar,
485
+ });
442
486
  } catch {
443
487
  setError("Failed to connect to server");
444
488
  } finally {
@@ -527,6 +571,50 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
527
571
  viewerRef,
528
572
  ]);
529
573
 
574
+ const updateStoredAnnotations = useCallback((
575
+ filepath: string,
576
+ update: (annotations: Annotation[]) => Annotation[],
577
+ ): boolean => {
578
+ // Callers address documents by whatever spelling their list carries — the
579
+ // panel's groups are normalized (forward slashes, collapsed separators)
580
+ // while the cache is keyed by the raw server path. On Windows those differ
581
+ // (`C:/repo/a.md` vs `C:\repo\a.md`) and an unnormalized lookup missed
582
+ // every time, so a cross-file edit or delete silently did nothing.
583
+ const wanted = normalizeBrowserPath(filepath);
584
+
585
+ // The active document's annotations live in host state, not the cache — a
586
+ // write here would be silently overwritten the next time it is cached.
587
+ if (linkedDoc && wanted === normalizeBrowserPath(linkedDoc.filepath)) return false;
588
+
589
+ let cacheKey: string | undefined;
590
+ for (const key of docCache.current.keys()) {
591
+ if (normalizeBrowserPath(key) === wanted) { cacheKey = key; break; }
592
+ }
593
+ const cached = cacheKey === undefined ? undefined : docCache.current.get(cacheKey);
594
+ if (cached && cacheKey !== undefined) {
595
+ docCache.current.set(cacheKey, { ...cached, annotations: update([...cached.annotations]) });
596
+ } else if (savedPlanState.current && sourceFilePath && wanted === normalizeBrowserPath(sourceFilePath)) {
597
+ const saved = savedPlanState.current;
598
+ savedPlanState.current = { ...saved, annotations: update([...saved.annotations]) };
599
+ } else {
600
+ return false;
601
+ }
602
+
603
+ // Same accounting as activateDocument/back: everything except the document
604
+ // that is active right now.
605
+ let total = 0;
606
+ for (const [fp, entry] of docCache.current.entries()) {
607
+ if (linkedDoc && fp === linkedDoc.filepath) continue;
608
+ total += entry.annotations.length + entry.globalAttachments.length;
609
+ }
610
+ if (linkedDoc && savedPlanState.current) {
611
+ total += savedPlanState.current.annotations.length + savedPlanState.current.globalAttachments.length;
612
+ }
613
+ setDocAnnotationCount(total);
614
+ setStoreRevision((r) => r + 1);
615
+ return true;
616
+ }, [linkedDoc, sourceFilePath]);
617
+
530
618
  const getDocAnnotations = useCallback((): Map<string, CachedDocState> => {
531
619
  const result = new Map(docCache.current);
532
620
  // Include stashed original-file annotations when viewing a linked doc
@@ -547,7 +635,9 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
547
635
  });
548
636
  }
549
637
  return result;
550
- }, [linkedDoc, annotations, globalAttachments, sourceFilePath, sourceConverted, getDocumentMarkdown]);
638
+ // storeRevision: cross-file edits mutate docCache in place, so the
639
+ // identity of this callback is what tells memoized readers to recompute.
640
+ }, [linkedDoc, annotations, globalAttachments, sourceFilePath, sourceConverted, getDocumentMarkdown, storeRevision]);
551
641
 
552
642
  return {
553
643
  isActive: linkedDoc !== null,
@@ -559,6 +649,7 @@ export function useLinkedDoc(options: UseLinkedDocOptions): UseLinkedDocReturn {
559
649
  back,
560
650
  dismissError,
561
651
  getDocAnnotations,
652
+ updateStoredAnnotations,
562
653
  snapshotSession,
563
654
  restoreSession,
564
655
  docAnnotationCount,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",
@@ -74,7 +74,7 @@
74
74
  "@lezer/highlight": "^1.2.3",
75
75
  "@pierre/diffs": "1.3.6",
76
76
  "@plannotator/atomic-editor": "^0.8.0",
77
- "@plannotator/core": "0.25.2",
77
+ "@plannotator/core": "0.25.3",
78
78
  "@plannotator/markdown-editor": "^0.4.0",
79
79
  "@plannotator/web-highlighter": "^0.8.1",
80
80
  "@tanstack/react-table": "^8.21.3",
@@ -86,7 +86,7 @@
86
86
  "katex": "^0.16.47",
87
87
  "lucide-react": "^1.38.0",
88
88
  "marked": "^17.0.6",
89
- "mermaid": "^11.17.2",
89
+ "mermaid": "12.0.0",
90
90
  "motion": "^12.38.0",
91
91
  "perfect-freehand": "^1.2.2",
92
92
  "tailwind-merge": "^3.6.0",
@@ -1,23 +1,37 @@
1
1
  import { defineShortcutScope } from '../core';
2
2
  import { createShortcutScopeHook } from '../runtime';
3
3
 
4
- // Interact/Annotate toggle for HTML and live-app annotate surfaces. The same
5
- // chord is mirrored inside the sandboxed iframe by the bridge (focus usually
6
- // lives in there on live apps) and forwarded to the parent, so it works
7
- // regardless of which document owns the keyboard. No bare-letter binding:
8
- // bare letters belong to type-to-comment and the page itself.
4
+ // Chords for the HTML and live-app annotate surfaces. Both are mirrored inside
5
+ // the sandboxed iframe by the bridge (focus usually lives in there on live
6
+ // apps) and forwarded to the parent, so they work regardless of which document
7
+ // owns the keyboard. No bare-letter binding: bare letters belong to
8
+ // type-to-comment and the page itself.
9
+ //
10
+ // `Mod+Shift+X` for the tools rather than a mnemonic letter: every mnemonic
11
+ // candidate is claimed by a browser (H = Chrome's Home / Firefox's history
12
+ // library, E/I/J/K/C = devtools, B/O = bookmarks, V = paste-as-plain-text,
13
+ // T = reopen tab), and a chord the browser eats is worse than an arbitrary
14
+ // one. X is unassigned in Chrome, Firefox, Safari and Edge on every platform.
9
15
  export const htmlAnnotateShortcuts = defineShortcutScope({
10
16
  id: 'html-annotate',
11
17
  title: 'HTML Annotate',
12
18
  shortcuts: {
13
19
  toggleAnnotateMode: {
14
- description: 'Toggle Interact / Annotate',
20
+ description: 'Toggle annotate mode',
15
21
  bindings: ['Mod+Shift+A'],
16
22
  section: 'Annotations',
17
- hint: 'On HTML and live app surfaces: arm annotation capture, or hand clicks back to the page. Esc also exits Annotate.',
23
+ hint: 'On HTML and live app surfaces: arm annotation capture, or hand clicks back to the page. Esc also exits Annotate; this chord is the way back in.',
18
24
  preventDefault: true,
19
25
  displayOrder: 0,
20
26
  },
27
+ toggleTools: {
28
+ description: 'Show or hide tools',
29
+ bindings: ['Mod+Shift+X'],
30
+ section: 'Annotations',
31
+ hint: 'On HTML and live app surfaces: the header eye — shows or removes all floating chrome over the page. HTML surfaces open with the tools hidden.',
32
+ preventDefault: true,
33
+ displayOrder: 1,
34
+ },
21
35
  },
22
36
  });
23
37