@plannotator/ui 0.31.0 → 0.32.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 (41) hide show
  1. package/README.md +45 -1
  2. package/components/AnnotationPanel.tsx +96 -4
  3. package/components/AnnotationToolbar.tsx +25 -25
  4. package/components/CommentPopover.tsx +26 -0
  5. package/components/GraphvizBlock.tsx +86 -7
  6. package/components/HtmlSurfaceControls.tsx +170 -0
  7. package/components/InlineMarkdown.tsx +22 -2
  8. package/components/MermaidBlock.tsx +60 -26
  9. package/components/Settings.tsx +40 -1
  10. package/components/blocks/MathBlock.tsx +26 -14
  11. package/components/html-viewer/HtmlViewer.tsx +115 -5
  12. package/components/html-viewer/bridge-script.ts +41 -7
  13. package/components/html-viewer/hostThreads.ts +37 -0
  14. package/components/html-viewer/index.ts +9 -0
  15. package/components/html-viewer/unanchored.ts +47 -0
  16. package/components/html-viewer/useHtmlAnnotation.ts +95 -5
  17. package/configure.ts +32 -0
  18. package/hooks/useHtmlRefresh.ts +149 -0
  19. package/hooks/useMathRenderer.ts +30 -0
  20. package/hooks/useSharing.ts +31 -5
  21. package/package.json +4 -2
  22. package/styles.css +1 -1
  23. package/types.ts +1 -0
  24. package/utils/generateIdentity.ts +64 -14
  25. package/utils/identity-tater.ts +36 -0
  26. package/utils/math-eager.ts +25 -0
  27. package/utils/math.ts +146 -0
  28. package/utils/mermaid-eager.ts +28 -0
  29. package/utils/mermaid.ts +132 -0
  30. package/utils/parser.ts +38 -0
  31. package/utils/quickLabels.ts +13 -0
  32. package/webmcp/activity.ts +46 -0
  33. package/webmcp/changes.ts +227 -0
  34. package/webmcp/index.ts +72 -0
  35. package/webmcp/modelContext.ts +103 -0
  36. package/webmcp/nudges.ts +174 -0
  37. package/webmcp/policy.ts +50 -0
  38. package/webmcp/preference.ts +50 -0
  39. package/webmcp/schema.ts +81 -0
  40. package/webmcp/toolset.ts +337 -0
  41. package/webmcp/useToolset.ts +74 -0
@@ -339,6 +339,17 @@ export const BRIDGE_SCRIPT = `(function() {
339
339
  var pendingMultiTargets = []; // { key, el, anchor, label, text, box }
340
340
  var multiTargetSeq = 0;
341
341
  var MAX_MULTI_TARGETS = 16;
342
+ // Per-draft cap on additional targets: the parent may lower it on
343
+ // arm-multi-select ({ max }) to its product cap so the toggle stops where
344
+ // the saved annotation would. Never above MAX_MULTI_TARGETS; reset with
345
+ // the arm on every draft.
346
+ var multiSelectMax = MAX_MULTI_TARGETS;
347
+ function clampMultiSelectMax(value) {
348
+ if (typeof value !== 'number' || !isFinite(value)) return MAX_MULTI_TARGETS;
349
+ var whole = Math.floor(value);
350
+ if (whole < 0) return 0;
351
+ return whole > MAX_MULTI_TARGETS ? MAX_MULTI_TARGETS : whole;
352
+ }
342
353
  // Live mode clamps the INPUT METHOD to pinpoint (click = element). Text
343
354
  // drag-selection is a separate, always-on channel — see the mouseup handler
344
355
  // — so the clamp only decides what a plain click does, never whether text
@@ -637,6 +648,10 @@ export const BRIDGE_SCRIPT = `(function() {
637
648
  && e.data.key === pendingPinKey
638
649
  ) {
639
650
  multiSelectArmed = true;
651
+ // Optional product cap for THIS draft; absent keeps the bridge's own.
652
+ multiSelectMax = e.data.max === undefined
653
+ ? MAX_MULTI_TARGETS
654
+ : clampMultiSelectMax(e.data.max);
640
655
  }
641
656
  }
642
657
 
@@ -655,14 +670,25 @@ export const BRIDGE_SCRIPT = `(function() {
655
670
  // Selecting an annotation scrolls its first resolved target into view
656
671
  // and flashes the overlay focus highlight over EVERY rect of EVERY
657
672
  // target — never a class write on page elements, and never only the
658
- // first fragment of a multi-paragraph selection.
659
- scrollToAnnotation(e.data.id);
673
+ // first fragment of a multi-paragraph selection. The optional
674
+ // behavior lets the parent pass its reduced-motion preference across
675
+ // the boundary; absent means smooth, as before.
676
+ scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
660
677
  }
661
678
 
662
679
  else if (type === PREFIX + 'focus-mark') {
663
680
  focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
664
681
  }
665
682
 
683
+ else if (type === PREFIX + 'report-unanchored') {
684
+ // The parent posted its restore batch and wants the complete set once
685
+ // the next complete overlay pass has run, even if the set is unchanged
686
+ // (empty included). Messages are processed in order, so the pass this
687
+ // schedules sees every find-and-mark posted before this request.
688
+ unanchoredReportRequested = true;
689
+ schedulePinpointReconcile();
690
+ }
691
+
666
692
  else if (type === PREFIX + 'set-input-method') {
667
693
  // Live mode clamps the input method to pinpoint (what a plain click
668
694
  // does); text drag-selection commenting stays live regardless.
@@ -1488,6 +1514,10 @@ export const BRIDGE_SCRIPT = `(function() {
1488
1514
  // whose records are removed and therefore invisible to the per-pass scan.
1489
1515
  var lastUnanchoredKey = '[]';
1490
1516
  var restoreFailedIds = new Set();
1517
+ // Set by report-unanchored: the parent asks for the complete set after
1518
+ // its restore batch, so the next COMPLETE pass emits even when the set
1519
+ // did not change (an all-restored document reports its empty set once).
1520
+ var unanchoredReportRequested = false;
1491
1521
  function emitUnanchored(deadRecordIds) {
1492
1522
  var seen = new Set();
1493
1523
  var combined = [];
@@ -1506,7 +1536,8 @@ export const BRIDGE_SCRIPT = `(function() {
1506
1536
  combined.sort();
1507
1537
  if (combined.length > 512) combined = combined.slice(0, 512);
1508
1538
  var key = JSON.stringify(combined);
1509
- if (key === lastUnanchoredKey) return;
1539
+ if (key === lastUnanchoredKey && !unanchoredReportRequested) return;
1540
+ unanchoredReportRequested = false;
1510
1541
  lastUnanchoredKey = key;
1511
1542
  // postToParent, not a raw '*' post: live sessions stamp the session
1512
1543
  // token and post only to the listed editor origins, and the parent
@@ -2495,7 +2526,7 @@ export const BRIDGE_SCRIPT = `(function() {
2495
2526
  }
2496
2527
  }
2497
2528
 
2498
- function scrollToAnnotation(id) {
2529
+ function scrollToAnnotation(id, behavior) {
2499
2530
  var record = findAnnRecord(id);
2500
2531
  if (!record) return;
2501
2532
  beginDeadSearchPass(Infinity); // user-initiated one-shot: never budget-starved
@@ -2511,7 +2542,7 @@ export const BRIDGE_SCRIPT = `(function() {
2511
2542
  }
2512
2543
  }
2513
2544
  if (scrollEl) {
2514
- try { scrollEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (ex) {}
2545
+ try { scrollEl.scrollIntoView({ behavior: behavior || 'smooth', block: 'center' }); } catch (ex) {}
2515
2546
  }
2516
2547
  focusAnnotationRecord(id, true);
2517
2548
  }
@@ -2693,6 +2724,7 @@ export const BRIDGE_SCRIPT = `(function() {
2693
2724
  pendingPinPoint = null;
2694
2725
  pendingPinViaPinpoint = false;
2695
2726
  multiSelectArmed = false;
2727
+ multiSelectMax = MAX_MULTI_TARGETS;
2696
2728
  hidePinpointBox();
2697
2729
  }
2698
2730
 
@@ -2857,8 +2889,9 @@ export const BRIDGE_SCRIPT = `(function() {
2857
2889
  }
2858
2890
  }
2859
2891
  }
2860
- // Cap at the source: never grow the draft past the parent-side DTO cap.
2861
- if (pendingMultiTargets.length >= MAX_MULTI_TARGETS) return;
2892
+ // Cap at the source: never grow the draft past the parent-side DTO cap
2893
+ // (or the lower product cap the parent armed this draft with).
2894
+ if (pendingMultiTargets.length >= multiSelectMax) return;
2862
2895
  var point = normalizePointInElement(el, clickPoint);
2863
2896
  if (anchor && point) anchor.point = point;
2864
2897
  var label = pinpointHoverLabel(el);
@@ -2961,6 +2994,7 @@ export const BRIDGE_SCRIPT = `(function() {
2961
2994
  // drafts (comment -> quick label) leaves a stale arm and the bridge
2962
2995
  // accumulates pins the saved annotation will not carry.
2963
2996
  multiSelectArmed = false;
2997
+ multiSelectMax = MAX_MULTI_TARGETS;
2964
2998
  pendingPinEl = el;
2965
2999
  pendingPinAnchor = buildElementAnchor(el);
2966
3000
  pendingPinKey = makeTargetKey();
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Host-side helpers for the raw-HTML viewer, re-exported from
3
+ * `@plannotator/core/html-anchor` with the package's `Annotation` type.
4
+ *
5
+ * `projectHostThreads` turns a host's stored rows into the `annotations`
6
+ * prop (output order == marker numbering); `buildPersistedHtmlAnchor` trims a
7
+ * composed comment's anchor to a bounded record the host can persist. Both
8
+ * are pure and dependency-free (they live in `@plannotator/core`).
9
+ */
10
+ import {
11
+ projectHostThreads as projectHostThreadsCore,
12
+ type HostThread,
13
+ type ProjectHostThreadsOptions,
14
+ } from "@plannotator/core/html-anchor";
15
+ import type { Annotation } from "../../types";
16
+
17
+ export {
18
+ buildPersistedHtmlAnchor,
19
+ type BuildPersistedHtmlAnchorOptions,
20
+ type HostThread,
21
+ type PersistedHtmlAnchor,
22
+ type PersistedHtmlAnchorResult,
23
+ type ProjectHostThreadsOptions,
24
+ } from "@plannotator/core/html-anchor";
25
+
26
+ /**
27
+ * Project stored host rows onto the viewer's `annotations` prop, in the
28
+ * host's order (which becomes the marker numbering). See the core function
29
+ * for the projection rules; the `type` literals it emits are the string
30
+ * values of `AnnotationType`, so the cast below is representation-exact.
31
+ */
32
+ export function projectHostThreads(
33
+ threads: readonly HostThread[],
34
+ options?: ProjectHostThreadsOptions,
35
+ ): Annotation[] {
36
+ return projectHostThreadsCore(threads, options) as unknown as Annotation[];
37
+ }
@@ -1 +1,10 @@
1
1
  export { HtmlViewer, type HtmlViewerProps } from "./HtmlViewer";
2
+ export {
3
+ buildPersistedHtmlAnchor,
4
+ projectHostThreads,
5
+ type BuildPersistedHtmlAnchorOptions,
6
+ type HostThread,
7
+ type PersistedHtmlAnchor,
8
+ type PersistedHtmlAnchorResult,
9
+ type ProjectHostThreadsOptions,
10
+ } from "./hostThreads";
@@ -0,0 +1,47 @@
1
+ import { AnnotationType, type Annotation } from "../../types";
2
+
3
+ /**
4
+ * A page-anchored row the viewer can never post to the bridge: nothing to
5
+ * find it by (no quoted text, no element anchor, no additional target
6
+ * anchor). Document-level comments are excluded on purpose: a
7
+ * GLOBAL_COMMENT has no page location by design and is not "unanchored".
8
+ */
9
+ export function isTextlessPageAnnotation(annotation: Annotation): boolean {
10
+ if (annotation.type === AnnotationType.GLOBAL_COMMENT) return false;
11
+ if (annotation.originalText) return false;
12
+ if (annotation.htmlAnchor) return false;
13
+ return !(annotation.htmlAdditionalTargets ?? []).some((target) => !!target.anchor);
14
+ }
15
+
16
+ /**
17
+ * The host-facing unanchored set: the bridge's report (ids with no live
18
+ * representation on the page) completed with what the bridge cannot see.
19
+ *
20
+ * - Textless page rows are added: they were never posted, so the bridge
21
+ * cannot report them, yet they have no marker and no highlight.
22
+ * - An id this viewer minted for a locally created comment (`create-mark`)
23
+ * that the host never carried in `annotations`, or has since swapped out
24
+ * for its own id, is dropped: the host holds no card for it, so naming it
25
+ * would be noise. Every other bridge id passes through untouched, so a
26
+ * host that paints through the imperative handle keeps today's delivery.
27
+ *
28
+ * Sorted and deduplicated like the bridge's own emission. With no textless
29
+ * rows and no swapped-out minted ids the result is exactly the bridge list.
30
+ */
31
+ export function mergeUnanchoredIds(input: {
32
+ bridgeIds: readonly string[];
33
+ annotations: readonly Annotation[];
34
+ createdIds: ReadonlySet<string>;
35
+ }): string[] {
36
+ const known = new Set<string>();
37
+ for (const annotation of input.annotations) known.add(annotation.id);
38
+ const out = new Set<string>();
39
+ for (const id of input.bridgeIds) {
40
+ if (input.createdIds.has(id) && !known.has(id)) continue;
41
+ out.add(id);
42
+ }
43
+ for (const annotation of input.annotations) {
44
+ if (isTextlessPageAnnotation(annotation)) out.add(annotation.id);
45
+ }
46
+ return [...out].sort();
47
+ }
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useCallback, useRef, type RefObject } from "react";
2
2
  import { AnnotationType, type Annotation, type EditorMode, type HtmlAnnotationTarget, type HtmlElementAnchor, type ImageAttachment } from "../../types";
3
- import type { QuickLabel } from "../../utils/quickLabels";
3
+ import { THUMBS_UP_LABEL, type QuickLabel } from "../../utils/quickLabels";
4
4
  import { getIdentity } from "../../utils/identity";
5
5
  import type {
6
6
  ToolbarState,
@@ -18,6 +18,13 @@ function nextHtmlAnnId(): string {
18
18
  return `html-ann-${Date.now().toString(36)}-${(htmlAnnSeq++).toString(36)}`;
19
19
  }
20
20
 
21
+ /** Ids minted by this module for locally created annotations (create-mark).
22
+ * Module-scoped like the sequence above: a host that swaps a local id for
23
+ * its own server id keeps the local mark until it removes it, and the
24
+ * unanchored union needs to recognise such ids whichever viewer instance
25
+ * minted them. Bounded: only ids from this page load, one entry per create. */
26
+ const mintedHtmlAnnIds = new Set<string>();
27
+
21
28
  function htmlCommentDraftKey(
22
29
  text: string,
23
30
  anchor?: HtmlElementAnchor | null,
@@ -129,6 +136,20 @@ export interface UseHtmlAnnotationOptions {
129
136
  * empty on recovery. Delivered in readOnly mode too: view-only surfaces
130
137
  * are exactly where silently missing markers would go unnoticed. */
131
138
  onUnanchoredChange?: (ids: string[]) => void;
139
+ /** Product cap on additional (shift-click) targets per comment, 0..16.
140
+ * Applied at the trust boundary, on submit, on restore, and carried to
141
+ * the bridge on arm-multi-select so the in-page toggle stops at the
142
+ * same number. Absent: the package's 16, and the arm message is unchanged. */
143
+ maxAdditionalTargets?: number;
144
+ /** scrollIntoView behavior for scroll-to (selecting an annotation).
145
+ * Absent: smooth, as before; pass 'auto' to honor reduced motion. */
146
+ scrollBehavior?: 'smooth' | 'auto';
147
+ }
148
+
149
+ /** Clamp a host cap into the package's bound; anything unusable is the default. */
150
+ export function resolveMaxAdditionalTargets(value: number | undefined): number {
151
+ if (value === undefined || !Number.isFinite(value)) return MAX_ADDITIONAL_TARGETS;
152
+ return Math.max(0, Math.min(MAX_ADDITIONAL_TARGETS, Math.floor(value)));
132
153
  }
133
154
 
134
155
  function postToIframe(
@@ -365,6 +386,8 @@ export function useHtmlAnnotation({
365
386
  onPageChange,
366
387
  onBridgePointer,
367
388
  onUnanchoredChange,
389
+ maxAdditionalTargets,
390
+ scrollBehavior,
368
391
  }: UseHtmlAnnotationOptions): Omit<
369
392
  UseAnnotationHighlighterReturn,
370
393
  "highlighterRef" | "highlightRange" | "highlightMathElement"
@@ -377,6 +400,13 @@ export function useHtmlAnnotation({
377
400
  flashDraftTarget: (key: string) => void;
378
401
  /** Bumped after every target add/remove so the composer can refocus its textarea. */
379
402
  composerFocusToken: number;
403
+ /** Composer one-click "Looks good": submits the hardcoded positive label
404
+ * with the same anchor and multi-select targets a typed comment would carry. */
405
+ handleCommentLooksGood: () => void;
406
+ /** Ids this module minted for locally created annotations (create-mark),
407
+ * for the unanchored union: a minted id the host never listed is a
408
+ * swapped-out local mark, not a host row. Read-only, stable identity. */
409
+ createdAnnotationIds: ReadonlySet<string>;
380
410
  } {
381
411
  const [toolbarState, setToolbarState] = useState<ToolbarState | null>(null);
382
412
  const [commentPopover, setCommentPopover] = useState<CommentPopoverState | null>(null);
@@ -417,6 +447,12 @@ export function useHtmlAnnotation({
417
447
  liveRef.current = live ?? null;
418
448
  const onPageChangeRef = useRef(onPageChange);
419
449
  onPageChangeRef.current = onPageChange;
450
+ // The effective cap and whether the host set one: only an explicit cap
451
+ // rides on arm-multi-select, so an unconfigured viewer posts today's message.
452
+ const maxTargetsRef = useRef(resolveMaxAdditionalTargets(maxAdditionalTargets));
453
+ maxTargetsRef.current = resolveMaxAdditionalTargets(maxAdditionalTargets);
454
+ const hostCapRef = useRef(maxAdditionalTargets !== undefined);
455
+ hostCapRef.current = maxAdditionalTargets !== undefined;
420
456
 
421
457
  const anchorRef = useRef<HTMLDivElement | null>(null);
422
458
 
@@ -577,6 +613,7 @@ export function useHtmlAnnotation({
577
613
  post({
578
614
  type: `${PREFIX}arm-multi-select`,
579
615
  key: message.targetKey,
616
+ ...(hostCapRef.current ? { max: maxTargetsRef.current } : {}),
580
617
  });
581
618
  }
582
619
  } else {
@@ -596,7 +633,7 @@ export function useHtmlAnnotation({
596
633
  if (
597
634
  commentPopoverRef.current
598
635
  && targets.length > 0
599
- && targets.length < 1 + MAX_ADDITIONAL_TARGETS
636
+ && targets.length < 1 + maxTargetsRef.current
600
637
  && !targets.some((t) => t.key === message.key)
601
638
  ) {
602
639
  setDraftTargets([
@@ -712,6 +749,9 @@ export function useHtmlAnnotation({
712
749
  post({
713
750
  type: `${PREFIX}scroll-to`,
714
751
  id: selectedAnnotationId,
752
+ // Only an explicit host preference rides along; the default message
753
+ // is unchanged and the bridge scrolls smoothly as before.
754
+ ...(scrollBehavior ? { behavior: scrollBehavior } : {}),
715
755
  });
716
756
  } else {
717
757
  post({
@@ -719,7 +759,7 @@ export function useHtmlAnnotation({
719
759
  id: null,
720
760
  });
721
761
  }
722
- }, [selectedAnnotationId, post]);
762
+ }, [selectedAnnotationId, post, scrollBehavior]);
723
763
 
724
764
  const handleAnnotate = useCallback(
725
765
  (type: AnnotationType) => {
@@ -728,6 +768,7 @@ export function useHtmlAnnotation({
728
768
  if (!text || type !== AnnotationType.DELETION) return;
729
769
 
730
770
  const id = nextHtmlAnnId();
771
+ mintedHtmlAnnIds.add(id);
731
772
  post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
732
773
  onAddRef.current?.({
733
774
  id,
@@ -778,7 +819,7 @@ export function useHtmlAnnotation({
778
819
  const targets = draftTargetsRef.current;
779
820
  const additionalTargets: HtmlAnnotationTarget[] | undefined =
780
821
  targets.length > 1
781
- ? targets.slice(1, 1 + MAX_ADDITIONAL_TARGETS).map((t) => ({
822
+ ? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
782
823
  label: t.label,
783
824
  text: t.text,
784
825
  anchor: t.anchor ?? undefined,
@@ -786,6 +827,7 @@ export function useHtmlAnnotation({
786
827
  : undefined;
787
828
 
788
829
  const id = nextHtmlAnnId();
830
+ mintedHtmlAnnIds.add(id);
789
831
  post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
790
832
  onAddRef.current?.({
791
833
  id,
@@ -810,6 +852,51 @@ export function useHtmlAnnotation({
810
852
  [post],
811
853
  );
812
854
 
855
+ // The composer's one-click "Looks good" (the restored thumbs-up for
856
+ // comment-only surfaces, where pinpoint clicks land straight in the
857
+ // composer and never see the selection toolbar). Mirrors
858
+ // handleCommentSubmit — same anchor, same multi-select targets — but
859
+ // emits the hardcoded positive label instead of typed prose.
860
+ const handleCommentLooksGood = useCallback(() => {
861
+ if (!enabledRef.current) return;
862
+ const text = commentPopoverRef.current?.selectedText || pendingTextRef.current;
863
+ if (!text) return;
864
+
865
+ const targets = draftTargetsRef.current;
866
+ const additionalTargets: HtmlAnnotationTarget[] | undefined =
867
+ targets.length > 1
868
+ ? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
869
+ label: t.label,
870
+ text: t.text,
871
+ anchor: t.anchor ?? undefined,
872
+ }))
873
+ : undefined;
874
+
875
+ const id = nextHtmlAnnId();
876
+ mintedHtmlAnnIds.add(id);
877
+ post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
878
+ onAddRef.current?.({
879
+ id,
880
+ blockId: "",
881
+ startOffset: 0,
882
+ endOffset: 0,
883
+ type: AnnotationType.COMMENT,
884
+ text: THUMBS_UP_LABEL.text,
885
+ originalText: text,
886
+ isQuickLabel: true,
887
+ quickLabelTip: THUMBS_UP_LABEL.tip,
888
+ author: getIdentity(),
889
+ createdA: Date.now(),
890
+ htmlAnchor: pendingAnchorRef.current ?? undefined,
891
+ htmlAdditionalTargets: additionalTargets,
892
+ });
893
+
894
+ setCommentPopover(null);
895
+ setDraftTargets([]);
896
+ pendingTextRef.current = "";
897
+ pendingAnchorRef.current = null;
898
+ }, [post]);
899
+
813
900
  const handleCommentClose = useCallback(() => {
814
901
  post({ type: `${PREFIX}cancel-selection` });
815
902
  setCommentPopover(null);
@@ -846,6 +933,7 @@ export function useHtmlAnnotation({
846
933
  const text = pendingTextRef.current;
847
934
  if (!text) return;
848
935
  const id = nextHtmlAnnId();
936
+ mintedHtmlAnnIds.add(id);
849
937
  post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
850
938
  onAddRef.current?.({
851
939
  id,
@@ -906,7 +994,7 @@ export function useHtmlAnnotation({
906
994
  const additionalAnchors = (ann.htmlAdditionalTargets ?? [])
907
995
  .map((t) => t.anchor)
908
996
  .filter((a): a is HtmlElementAnchor => !!a)
909
- .slice(0, MAX_ADDITIONAL_TARGETS);
997
+ .slice(0, maxTargetsRef.current);
910
998
  post({
911
999
  type: `${PREFIX}find-and-mark`,
912
1000
  id: ann.id,
@@ -931,6 +1019,7 @@ export function useHtmlAnnotation({
931
1019
  handleToolbarClose,
932
1020
  handleRequestComment,
933
1021
  handleCommentSubmit,
1022
+ handleCommentLooksGood,
934
1023
  handleCommentClose,
935
1024
  handleFloatingQuickLabel,
936
1025
  handleQuickLabelPickerDismiss,
@@ -941,5 +1030,6 @@ export function useHtmlAnnotation({
941
1030
  removeDraftTarget,
942
1031
  flashDraftTarget,
943
1032
  composerFocusToken,
1033
+ createdAnnotationIds: mintedHtmlAnnIds,
944
1034
  };
945
1035
  }
package/configure.ts CHANGED
@@ -8,6 +8,9 @@ import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDra
8
8
  import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations';
9
9
  import { setAITransport, type AITransport } from './hooks/useAIChat';
10
10
  import { setSkillCatalogTransport, setSkillContentTransport, type SkillCatalogTransport, type SkillContentTransport } from './utils/skillCatalog';
11
+ import { setWebMcpPolicy, type WebMcpPolicy } from './webmcp/policy';
12
+ import { setMathRendererLoader, type MathRenderer, type MathRendererLoader } from './utils/math';
13
+ import { setIdentityGenerator, type IdentityGenerator } from './utils/generateIdentity';
11
14
  import { configStore } from './config';
12
15
  import type { ServerSyncFn } from './config/configStore';
13
16
  import type { ExternalAnnotationEvent, VaultNode } from './types';
@@ -31,6 +34,10 @@ export type {
31
34
  SkillCatalogTransport,
32
35
  SkillContentTransport,
33
36
  ServerSyncFn,
37
+ WebMcpPolicy,
38
+ MathRenderer,
39
+ MathRendererLoader,
40
+ IdentityGenerator,
34
41
  };
35
42
 
36
43
  type ExternalAnnotationBase = { id: string; source?: string };
@@ -56,6 +63,28 @@ export interface PlannotatorUIConfig {
56
63
  /** Human-only skill contents request for feedback injection. Default: `GET /api/skills/content?name=` on the page origin. */
57
64
  skillContentTransport?: SkillContentTransport;
58
65
  serverSync?: ServerSyncFn;
66
+ /**
67
+ * WebMCP provider policy: `{ enabled, namePrefix }`. Default: enabled
68
+ * whenever the browser exposes `document.modelContext`, with the
69
+ * `plannotator.` prefix. There is no confirmation seam because the catalog
70
+ * exposes nothing consequential: no tool decides, submits or closes.
71
+ */
72
+ webmcp?: WebMcpPolicy;
73
+ /**
74
+ * How the math renderer is loaded when no renderer is registered before the
75
+ * first math node renders. Default: `import('katex')` (JS only; the
76
+ * stylesheet stays the host's job). A host that wants KaTeX and its CSS on
77
+ * one lazy chunk passes a loader that imports both. Hosts that want math
78
+ * typeset on the first commit instead import `@plannotator/ui/utils/math-eager`.
79
+ */
80
+ mathRendererLoader?: MathRendererLoader;
81
+ /**
82
+ * Synchronous generator for the default "tater" display name, used only when
83
+ * no `identityProvider` is installed. Default: a small built-in pool of the
84
+ * same `adjective-noun-tater` shape. Plannotator registers the full
85
+ * dictionary by importing `@plannotator/ui/utils/identity-tater`.
86
+ */
87
+ identityGenerator?: IdentityGenerator;
59
88
  /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
60
89
  loadSettingsFromBackend?: boolean;
61
90
  }
@@ -73,6 +102,9 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
73
102
  if (config.skillCatalogTransport) setSkillCatalogTransport(config.skillCatalogTransport);
74
103
  if (config.skillContentTransport) setSkillContentTransport(config.skillContentTransport);
75
104
  if (config.serverSync) configStore.setServerSync(config.serverSync);
105
+ if (config.webmcp) setWebMcpPolicy(config.webmcp);
106
+ if (config.mathRendererLoader) setMathRendererLoader(config.mathRendererLoader);
107
+ if (config.identityGenerator) setIdentityGenerator(config.identityGenerator);
76
108
  // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
77
109
  if (config.loadSettingsFromBackend) configStore.loadFromBackend();
78
110
  }
@@ -0,0 +1,149 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ /** What a host's `fetchSnapshot` resolves to. */
4
+ export type HtmlRefreshSnapshot =
5
+ | { status: 'ok'; rawHtml: string }
6
+ | { status: 'missing' }
7
+ | { status: 'unavailable' };
8
+
9
+ /** The outcome of one `refresh()` call, for host notifications (toasts). */
10
+ export type HtmlRefreshResult = 'refreshed' | 'missing' | 'unavailable';
11
+
12
+ export interface UseHtmlRefreshOptions {
13
+ /** Whether refresh is offered at all. Default true. */
14
+ enabled?: boolean;
15
+ /**
16
+ * Identity of the document under refresh (a path, an id). A change
17
+ * cancels any in-flight fetch and any pending restore acknowledgement, so
18
+ * a snapshot for the previous document can never land on the next one.
19
+ * `null` means no document: `canRefresh` is false. Omit it when the host
20
+ * has a single document.
21
+ */
22
+ documentKey?: string | null;
23
+ /** Fetch the current bytes of the document. Called with `documentKey`.
24
+ * A rejection is treated as `{ status: 'unavailable' }`. */
25
+ fetchSnapshot: (documentKey: string | null) => Promise<HtmlRefreshSnapshot>;
26
+ /** Apply the refreshed bytes (the host owns the viewer's `rawHtml`). */
27
+ onSnapshot: (rawHtml: string) => void;
28
+ /**
29
+ * Once per refresh: the ids the remounted viewer could not re-anchor,
30
+ * possibly empty. Wire the viewer's `onUnanchoredChange` to the returned
31
+ * `reportAnnotationRestore`; only the first report after a refresh is
32
+ * forwarded, and only while the document and reload generation match.
33
+ */
34
+ onUnanchored?: (ids: string[]) => void;
35
+ /** The outcome of each `refresh()` call that reached a decision. */
36
+ onResult?: (result: HtmlRefreshResult) => void;
37
+ }
38
+
39
+ export interface UseHtmlRefreshReturn {
40
+ canRefresh: boolean;
41
+ isRefreshing: boolean;
42
+ /** Bumps after every applied snapshot. Key the viewer on it to remount. */
43
+ reloadGeneration: number;
44
+ refresh: () => Promise<void>;
45
+ /** Feed the viewer's `onUnanchoredChange` report here. */
46
+ reportAnnotationRestore: (missingIds: string[]) => void;
47
+ }
48
+
49
+ /**
50
+ * Re-fetch a rendered HTML document from the host's source and remount the
51
+ * viewer on it, keeping the annotations the viewer can still anchor.
52
+ *
53
+ * Backend-agnostic: the host supplies `fetchSnapshot` (Plannotator wraps its
54
+ * `/api/doc` read; a host with a document store passes its own read). The
55
+ * hook owns the guards: an in-flight fetch that is superseded by a newer
56
+ * refresh, or by a document change, is dropped before `onSnapshot`; the
57
+ * restore acknowledgement is armed per reload generation and consumed by
58
+ * the first viewer report for that generation.
59
+ */
60
+ export function useHtmlRefresh({
61
+ enabled = true,
62
+ documentKey,
63
+ fetchSnapshot,
64
+ onSnapshot,
65
+ onUnanchored,
66
+ onResult,
67
+ }: UseHtmlRefreshOptions): UseHtmlRefreshReturn {
68
+ const [isRefreshing, setIsRefreshing] = useState(false);
69
+ const [reloadGeneration, setReloadGeneration] = useState(0);
70
+ const keyed = documentKey !== undefined;
71
+ const activeKey = keyed ? documentKey : null;
72
+ const activeKeyRef = useRef(activeKey);
73
+ const requestRef = useRef(0);
74
+ const reloadGenerationRef = useRef(0);
75
+ const restorePendingRef = useRef<{ key: string | null; generation: number } | null>(null);
76
+ const onUnanchoredRef = useRef(onUnanchored);
77
+ onUnanchoredRef.current = onUnanchored;
78
+ const onResultRef = useRef(onResult);
79
+ onResultRef.current = onResult;
80
+ const canRefresh = enabled && (!keyed || !!documentKey);
81
+
82
+ useLayoutEffect(() => {
83
+ if (activeKeyRef.current !== activeKey) {
84
+ requestRef.current += 1;
85
+ restorePendingRef.current = null;
86
+ setIsRefreshing(false);
87
+ }
88
+ activeKeyRef.current = activeKey;
89
+ }, [activeKey]);
90
+
91
+ const refresh = useCallback(async () => {
92
+ if (!canRefresh) return;
93
+
94
+ const requestKey = activeKey;
95
+ const requestId = ++requestRef.current;
96
+ setIsRefreshing(true);
97
+ try {
98
+ // A rejecting fetch is an unavailable snapshot: the host hears it
99
+ // through onResult like any other outcome, never as an unhandled
100
+ // rejection out of refresh().
101
+ let result: HtmlRefreshSnapshot;
102
+ try {
103
+ result = await fetchSnapshot(requestKey);
104
+ } catch {
105
+ result = { status: 'unavailable' };
106
+ }
107
+ if (requestId !== requestRef.current || activeKeyRef.current !== requestKey) return;
108
+
109
+ if (result.status === 'missing' || result.status === 'unavailable') {
110
+ onResultRef.current?.(result.status);
111
+ return;
112
+ }
113
+
114
+ onSnapshot(result.rawHtml);
115
+ const nextGeneration = reloadGenerationRef.current + 1;
116
+ reloadGenerationRef.current = nextGeneration;
117
+ // Armed until the remounted viewer's bridge reports its restore. The
118
+ // bridge emits "unanchored" only when the set CHANGES from its initial
119
+ // empty state, so a pass that restores everything never posts and this
120
+ // stays armed; that is harmless because the next refresh replaces it
121
+ // and a document change clears it.
122
+ restorePendingRef.current = { key: requestKey, generation: nextGeneration };
123
+ setReloadGeneration(nextGeneration);
124
+ onResultRef.current?.('refreshed');
125
+ } finally {
126
+ if (requestId === requestRef.current) setIsRefreshing(false);
127
+ }
128
+ }, [activeKey, canRefresh, fetchSnapshot, onSnapshot]);
129
+
130
+ const reportAnnotationRestore = useCallback((missingIds: string[]) => {
131
+ const pending = restorePendingRef.current;
132
+ if (
133
+ !pending ||
134
+ pending.key !== activeKeyRef.current ||
135
+ pending.generation !== reloadGenerationRef.current
136
+ ) return;
137
+
138
+ restorePendingRef.current = null;
139
+ onUnanchoredRef.current?.(missingIds);
140
+ }, []);
141
+
142
+ return {
143
+ canRefresh,
144
+ isRefreshing,
145
+ reloadGeneration,
146
+ refresh,
147
+ reportAnnotationRestore,
148
+ };
149
+ }
@@ -0,0 +1,30 @@
1
+ import { useEffect, useSyncExternalStore } from 'react';
2
+ import {
3
+ getMathRenderer,
4
+ loadMathRenderer,
5
+ subscribeMathRenderer,
6
+ type MathRenderer,
7
+ } from '../utils/math';
8
+
9
+ /**
10
+ * The registered math renderer, read synchronously during render.
11
+ *
12
+ * With the slot filled before mount (Plannotator: `utils/math-eager`) this
13
+ * returns KaTeX on the first render and the effect below is a no-op, so the
14
+ * typeset HTML is in the first commit. With the slot empty it returns `null`,
15
+ * kicks off `loadMathRenderer()` from an effect, and the subscription
16
+ * re-renders the caller once the renderer lands. A rejected load is left to
17
+ * the loader's retry contract; the caller keeps showing the TeX placeholder.
18
+ */
19
+ export function useMathRenderer(): MathRenderer | null {
20
+ const renderer = useSyncExternalStore(subscribeMathRenderer, getMathRenderer, getMathRenderer);
21
+
22
+ useEffect(() => {
23
+ if (renderer) return;
24
+ loadMathRenderer().catch(() => {
25
+ /* placeholder stays; the next mount retries */
26
+ });
27
+ }, [renderer]);
28
+
29
+ return renderer;
30
+ }