@plannotator/ui 0.31.0 → 0.33.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 (46) hide show
  1. package/HANDOFF.md +664 -0
  2. package/README.md +57 -1
  3. package/components/AnnotationPanel.tsx +96 -4
  4. package/components/AnnotationToolbar.tsx +25 -25
  5. package/components/CommentPopover.tsx +26 -0
  6. package/components/GraphvizBlock.tsx +86 -7
  7. package/components/HtmlSurfaceControls.tsx +170 -0
  8. package/components/InlineMarkdown.tsx +22 -2
  9. package/components/MermaidBlock.tsx +60 -26
  10. package/components/Settings.tsx +40 -1
  11. package/components/blocks/MathBlock.tsx +26 -14
  12. package/components/html-viewer/HtmlViewer.tsx +289 -6
  13. package/components/html-viewer/bridge-script.asset.js +4392 -0
  14. package/components/html-viewer/bridge-script.lite.ts +9 -0
  15. package/components/html-viewer/bridge-script.ts +54 -8
  16. package/components/html-viewer/hostThreads.ts +37 -0
  17. package/components/html-viewer/index.ts +22 -1
  18. package/components/html-viewer/srcdoc.ts +70 -1
  19. package/components/html-viewer/unanchored.ts +47 -0
  20. package/components/html-viewer/useHtmlAnnotation.ts +132 -5
  21. package/configure.ts +32 -0
  22. package/hooks/useHtmlRefresh.ts +149 -0
  23. package/hooks/useMathRenderer.ts +30 -0
  24. package/hooks/useSharing.ts +31 -5
  25. package/package.json +9 -3
  26. package/styles.css +1 -1
  27. package/types.ts +1 -0
  28. package/utils/generateIdentity.ts +64 -14
  29. package/utils/identity-tater.ts +36 -0
  30. package/utils/math-default-loader.ts +24 -0
  31. package/utils/math-eager.ts +25 -0
  32. package/utils/math.ts +149 -0
  33. package/utils/mermaid-eager.ts +28 -0
  34. package/utils/mermaid.ts +132 -0
  35. package/utils/parser.ts +38 -0
  36. package/utils/quickLabels.ts +13 -0
  37. package/webmcp/activity.ts +46 -0
  38. package/webmcp/changes.ts +227 -0
  39. package/webmcp/index.ts +72 -0
  40. package/webmcp/modelContext.ts +103 -0
  41. package/webmcp/nudges.ts +174 -0
  42. package/webmcp/policy.ts +50 -0
  43. package/webmcp/preference.ts +50 -0
  44. package/webmcp/schema.ts +81 -0
  45. package/webmcp/toolset.ts +337 -0
  46. package/webmcp/useToolset.ts +74 -0
@@ -0,0 +1,9 @@
1
+ // @generated by scripts/build-bridge-assets.ts from ./bridge-script.ts. DO NOT EDIT.
2
+ // Alias target for hosts on the HtmlViewer `bridgeScriptUrl` path: the same
3
+ // annotation CSS, protocol version and live bootstrap as ./bridge-script, with
4
+ // the inline bridge literal stubbed out so a bundler drops it from the viewer
5
+ // chunk. Rendering an HtmlViewer WITHOUT bridgeScriptUrl under this alias throws.
6
+ export const ANNOTATION_HIGHLIGHT_CSS = "\n/* Committed annotation visuals (highlight rectangles + numbered placed\n * markers) render inside a shadow-rooted fixed overlay host — see OVERLAY_CSS\n * in the bridge script. Nothing annotation-related is ever wrapped into or\n * styled onto the author's own elements. */\n/* Vim pinpoint target tint. The MOUSE pinpoint path no longer mutates author\n * elements — it draws the dedicated overlay box below — but keyboard (vim)\n * navigation keeps this class-based visual. */\n.plannotator-pinpoint-hover {\n background-color: oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.12) !important;\n border-radius: 3px;\n cursor: pointer !important;\n}\n/* SVG groups can't render a CSS background, so use a soft glow instead. */\n.plannotator-pinpoint-hover:is(g, svg) {\n filter: drop-shadow(0 0 4px oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.55));\n}\n/* Mouse pinpoint hover: a fixed-position outline box sized to the hovered\n * element's rect. Never a class/style write on the page's own elements. */\n[data-plannotator-pinpoint-box] {\n position: fixed;\n z-index: 2147483643;\n pointer-events: none;\n display: none;\n box-sizing: border-box;\n border: 2px solid oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.85);\n border-radius: 5px;\n background: oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.06);\n}\n[data-plannotator-pinpoint-box].pn-pin-enter {\n animation: pn-pinpoint-in 0.12s ease-out;\n}\n[data-plannotator-pinpoint-box][data-pinned] {\n border-color: var(--pn-accent, #d97757);\n background: oklch(from var(--pn-accent, #d97757) l c h / 0.08);\n}\n@keyframes pn-pinpoint-in {\n from { opacity: 0; transform: scale(0.985); }\n to { opacity: 1; transform: scale(1); }\n}\n/* Pinpoint mode affordance: crosshair everywhere. Placed markers live in the\n * shadow overlay and keep their own pointer cursor there. */\nbody[data-plannotator-pinpoint-cursor],\nbody[data-plannotator-pinpoint-cursor] * {\n cursor: crosshair !important;\n}\n@media (prefers-reduced-motion: reduce) {\n [data-plannotator-pinpoint-box].pn-pin-enter {\n animation: none;\n }\n}\n@media print {\n /* Viewer overlays are review chrome, not page content: never bake pinpoint\n boxes/labels or vim UI into a printed page. The outer app chrome is\n print-hidden by print.css, but this CSS lives inside the iframe's own\n document and must carry its own rule. The annotation overlay host carries\n its own print rule inside its shadow root. */\n [data-plannotator-pinpoint-box],\n [data-plannotator-pinpoint-label],\n [data-plannotator-vim-ui],\n [data-plannotator-vim-cursor] {\n display: none !important;\n }\n}\n/* Print-parity layer: committed highlight rects re-projected into an\n * absolute-positioned light-DOM layer built on beforeprint and torn down on\n * afterprint (the fixed overlay cannot paginate). Guarded here so it can\n * never flash on screen even if an afterprint teardown is missed. */\n@media screen {\n [data-plannotator-print-layer] {\n display: none !important;\n }\n}\nbody[data-plannotator-vim-focus-owner]:focus {\n outline: none !important;\n}\n[data-plannotator-vim-cursor] {\n position: fixed;\n z-index: 2147483646;\n width: 2px;\n min-height: 1em;\n border-radius: 2px;\n background: var(--pn-focus-highlight, #4493f8);\n pointer-events: none;\n}\n[data-plannotator-vim-reticle] {\n position: fixed;\n z-index: 2147483645;\n inset: 0;\n overflow: visible;\n pointer-events: none;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-fill],\n[data-plannotator-vim-reticle] [data-vim-reticle-corner],\n[data-plannotator-vim-reticle] [data-vim-reticle-label] {\n position: absolute;\n top: 0;\n left: 0;\n will-change: transform;\n transition: transform 90ms cubic-bezier(.22,1,.36,1);\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-fill] {\n width: 100px;\n height: 100px;\n transform-origin: 0 0;\n border-radius: 8px;\n background: rgba(167,139,250,.045);\n box-shadow:\n inset 0 0 0 1px rgba(196,181,253,.16),\n 0 0 42px rgba(139,92,246,.12);\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner] {\n width: 28px;\n height: 28px;\n border-color: #c4b5fd;\n filter:\n drop-shadow(0 0 6px rgba(167,139,250,.92))\n drop-shadow(0 0 18px rgba(124,58,237,.42));\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner=\"top-left\"] {\n border-top: 3px solid;\n border-left: 3px solid;\n border-top-left-radius: 8px;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner=\"top-right\"] {\n border-top: 3px solid;\n border-right: 3px solid;\n border-top-right-radius: 8px;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner=\"bottom-left\"] {\n border-bottom: 3px solid;\n border-left: 3px solid;\n border-bottom-left-radius: 8px;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner=\"bottom-right\"] {\n border-right: 3px solid;\n border-bottom: 3px solid;\n border-bottom-right-radius: 8px;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-label] {\n z-index: 1;\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 118px;\n height: 30px;\n max-width: min(280px, calc(100vw - 24px));\n padding: 0 11px;\n overflow: hidden;\n border: 1px solid rgba(216,206,255,.42);\n border-radius: 9px;\n color: #f6f2ff;\n background: rgba(18,14,28,.84);\n box-shadow:\n 0 10px 28px rgba(0,0,0,.42),\n 0 0 20px rgba(139,92,246,.18);\n backdrop-filter: blur(10px);\n font: 700 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;\n letter-spacing: .13em;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-label]::before {\n width: 7px;\n height: 7px;\n flex: 0 0 auto;\n border-radius: 999px;\n background: #c4b5fd;\n box-shadow: 0 0 12px rgba(167,139,250,.94);\n content: \"\";\n}\n@media (prefers-reduced-motion: reduce) {\n [data-plannotator-vim-reticle] [data-vim-reticle-fill],\n [data-plannotator-vim-reticle] [data-vim-reticle-corner],\n [data-plannotator-vim-reticle] [data-vim-reticle-label] {\n transition: none;\n }\n}\n[data-plannotator-vim-badge] {\n position: fixed;\n z-index: 2147483647;\n left: 50%;\n bottom: 12px;\n transform: translateX(-50%);\n padding: 4px 9px;\n border: 1px solid color-mix(in srgb, var(--pn-focus-highlight, #4493f8) 35%, transparent);\n border-radius: 6px;\n background: color-mix(in srgb, var(--pn-background, #111) 94%, transparent);\n color: var(--pn-focus-highlight, #4493f8);\n box-shadow: 0 4px 18px rgba(0,0,0,.25);\n font: 700 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;\n letter-spacing: .04em;\n pointer-events: none;\n}\n";
7
+ export const BRIDGE_PROTOCOL_VERSION = 1;
8
+ export const BRIDGE_SCRIPT = "";
9
+ export const LIVE_BRIDGE_BOOTSTRAP = "(function() {\n var config = window.__plannotatorLiveConfig;\n if (!config || typeof config.css !== 'string') return;\n try {\n var style = document.createElement('style');\n style.setAttribute('data-plannotator-live-css', '');\n style.appendChild(document.createTextNode(config.css));\n (document.head || document.documentElement).appendChild(style);\n } catch (ex) {}\n})();";
@@ -210,6 +210,18 @@ body[data-plannotator-vim-focus-owner]:focus {
210
210
  }
211
211
  `;
212
212
 
213
+ /**
214
+ * Bridge protocol version. Stamped on the bridge's `ready` message
215
+ * (`protocolVersion`) and compared by the parent (HtmlViewer) against this
216
+ * same constant. Bump it whenever a message shape changes in a way an older
217
+ * bridge or an older parent would misread. The inline srcdoc path and the
218
+ * live proxy always ship the bridge from the same bundle as the parent, so
219
+ * they match by construction; the check exists for hosts that serve the
220
+ * generated `bridge-script.asset.js` separately (`bridgeScriptUrl`), where a
221
+ * cached asset from a previous package version can outlive the parent code.
222
+ */
223
+ export const BRIDGE_PROTOCOL_VERSION = 1;
224
+
213
225
  export const BRIDGE_SCRIPT = `(function() {
214
226
  var PREFIX = 'plannotator-bridge-';
215
227
 
@@ -339,6 +351,17 @@ export const BRIDGE_SCRIPT = `(function() {
339
351
  var pendingMultiTargets = []; // { key, el, anchor, label, text, box }
340
352
  var multiTargetSeq = 0;
341
353
  var MAX_MULTI_TARGETS = 16;
354
+ // Per-draft cap on additional targets: the parent may lower it on
355
+ // arm-multi-select ({ max }) to its product cap so the toggle stops where
356
+ // the saved annotation would. Never above MAX_MULTI_TARGETS; reset with
357
+ // the arm on every draft.
358
+ var multiSelectMax = MAX_MULTI_TARGETS;
359
+ function clampMultiSelectMax(value) {
360
+ if (typeof value !== 'number' || !isFinite(value)) return MAX_MULTI_TARGETS;
361
+ var whole = Math.floor(value);
362
+ if (whole < 0) return 0;
363
+ return whole > MAX_MULTI_TARGETS ? MAX_MULTI_TARGETS : whole;
364
+ }
342
365
  // Live mode clamps the INPUT METHOD to pinpoint (click = element). Text
343
366
  // drag-selection is a separate, always-on channel — see the mouseup handler
344
367
  // — so the clamp only decides what a plain click does, never whether text
@@ -637,6 +660,10 @@ export const BRIDGE_SCRIPT = `(function() {
637
660
  && e.data.key === pendingPinKey
638
661
  ) {
639
662
  multiSelectArmed = true;
663
+ // Optional product cap for THIS draft; absent keeps the bridge's own.
664
+ multiSelectMax = e.data.max === undefined
665
+ ? MAX_MULTI_TARGETS
666
+ : clampMultiSelectMax(e.data.max);
640
667
  }
641
668
  }
642
669
 
@@ -655,14 +682,25 @@ export const BRIDGE_SCRIPT = `(function() {
655
682
  // Selecting an annotation scrolls its first resolved target into view
656
683
  // and flashes the overlay focus highlight over EVERY rect of EVERY
657
684
  // 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);
685
+ // first fragment of a multi-paragraph selection. The optional
686
+ // behavior lets the parent pass its reduced-motion preference across
687
+ // the boundary; absent means smooth, as before.
688
+ scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
660
689
  }
661
690
 
662
691
  else if (type === PREFIX + 'focus-mark') {
663
692
  focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
664
693
  }
665
694
 
695
+ else if (type === PREFIX + 'report-unanchored') {
696
+ // The parent posted its restore batch and wants the complete set once
697
+ // the next complete overlay pass has run, even if the set is unchanged
698
+ // (empty included). Messages are processed in order, so the pass this
699
+ // schedules sees every find-and-mark posted before this request.
700
+ unanchoredReportRequested = true;
701
+ schedulePinpointReconcile();
702
+ }
703
+
666
704
  else if (type === PREFIX + 'set-input-method') {
667
705
  // Live mode clamps the input method to pinpoint (what a plain click
668
706
  // does); text drag-selection commenting stays live regardless.
@@ -1488,6 +1526,10 @@ export const BRIDGE_SCRIPT = `(function() {
1488
1526
  // whose records are removed and therefore invisible to the per-pass scan.
1489
1527
  var lastUnanchoredKey = '[]';
1490
1528
  var restoreFailedIds = new Set();
1529
+ // Set by report-unanchored: the parent asks for the complete set after
1530
+ // its restore batch, so the next COMPLETE pass emits even when the set
1531
+ // did not change (an all-restored document reports its empty set once).
1532
+ var unanchoredReportRequested = false;
1491
1533
  function emitUnanchored(deadRecordIds) {
1492
1534
  var seen = new Set();
1493
1535
  var combined = [];
@@ -1506,7 +1548,8 @@ export const BRIDGE_SCRIPT = `(function() {
1506
1548
  combined.sort();
1507
1549
  if (combined.length > 512) combined = combined.slice(0, 512);
1508
1550
  var key = JSON.stringify(combined);
1509
- if (key === lastUnanchoredKey) return;
1551
+ if (key === lastUnanchoredKey && !unanchoredReportRequested) return;
1552
+ unanchoredReportRequested = false;
1510
1553
  lastUnanchoredKey = key;
1511
1554
  // postToParent, not a raw '*' post: live sessions stamp the session
1512
1555
  // token and post only to the listed editor origins, and the parent
@@ -2495,7 +2538,7 @@ export const BRIDGE_SCRIPT = `(function() {
2495
2538
  }
2496
2539
  }
2497
2540
 
2498
- function scrollToAnnotation(id) {
2541
+ function scrollToAnnotation(id, behavior) {
2499
2542
  var record = findAnnRecord(id);
2500
2543
  if (!record) return;
2501
2544
  beginDeadSearchPass(Infinity); // user-initiated one-shot: never budget-starved
@@ -2511,7 +2554,7 @@ export const BRIDGE_SCRIPT = `(function() {
2511
2554
  }
2512
2555
  }
2513
2556
  if (scrollEl) {
2514
- try { scrollEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (ex) {}
2557
+ try { scrollEl.scrollIntoView({ behavior: behavior || 'smooth', block: 'center' }); } catch (ex) {}
2515
2558
  }
2516
2559
  focusAnnotationRecord(id, true);
2517
2560
  }
@@ -2693,6 +2736,7 @@ export const BRIDGE_SCRIPT = `(function() {
2693
2736
  pendingPinPoint = null;
2694
2737
  pendingPinViaPinpoint = false;
2695
2738
  multiSelectArmed = false;
2739
+ multiSelectMax = MAX_MULTI_TARGETS;
2696
2740
  hidePinpointBox();
2697
2741
  }
2698
2742
 
@@ -2857,8 +2901,9 @@ export const BRIDGE_SCRIPT = `(function() {
2857
2901
  }
2858
2902
  }
2859
2903
  }
2860
- // Cap at the source: never grow the draft past the parent-side DTO cap.
2861
- if (pendingMultiTargets.length >= MAX_MULTI_TARGETS) return;
2904
+ // Cap at the source: never grow the draft past the parent-side DTO cap
2905
+ // (or the lower product cap the parent armed this draft with).
2906
+ if (pendingMultiTargets.length >= multiSelectMax) return;
2862
2907
  var point = normalizePointInElement(el, clickPoint);
2863
2908
  if (anchor && point) anchor.point = point;
2864
2909
  var label = pinpointHoverLabel(el);
@@ -2961,6 +3006,7 @@ export const BRIDGE_SCRIPT = `(function() {
2961
3006
  // drafts (comment -> quick label) leaves a stale arm and the bridge
2962
3007
  // accumulates pins the saved annotation will not carry.
2963
3008
  multiSelectArmed = false;
3009
+ multiSelectMax = MAX_MULTI_TARGETS;
2964
3010
  pendingPinEl = el;
2965
3011
  pendingPinAnchor = buildElementAnchor(el);
2966
3012
  pendingPinKey = makeTargetKey();
@@ -4538,7 +4584,7 @@ export const BRIDGE_SCRIPT = `(function() {
4538
4584
  // pinpoint: show the cursor affordance immediately instead of waiting for
4539
4585
  // the parent's first set-input-method/set-annotate-mode round trip.
4540
4586
  updatePinpointCursor();
4541
- var readyMsg = { type: PREFIX + 'ready' };
4587
+ var readyMsg = { type: PREFIX + 'ready', protocolVersion: ${BRIDGE_PROTOCOL_VERSION} };
4542
4588
  if (LIVE) readyMsg.pageUrl = currentPageUrl();
4543
4589
  postToParent(readyMsg);
4544
4590
  }
@@ -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,22 @@
1
- export { HtmlViewer, type HtmlViewerProps } from "./HtmlViewer";
1
+ export {
2
+ DEFAULT_BRIDGE_READY_TIMEOUT_MS,
3
+ HtmlViewer,
4
+ formatBridgeUnavailableMessage,
5
+ type BridgeUnavailableInfo,
6
+ type HtmlViewerProps,
7
+ } from "./HtmlViewer";
8
+ export { BRIDGE_PROTOCOL_VERSION } from "./bridge-script";
9
+ export {
10
+ checkBridgeProtocolVersion,
11
+ formatBridgeProtocolWarning,
12
+ type BridgeProtocolVerdict,
13
+ } from "./useHtmlAnnotation";
14
+ export {
15
+ buildPersistedHtmlAnchor,
16
+ projectHostThreads,
17
+ type BuildPersistedHtmlAnchorOptions,
18
+ type HostThread,
19
+ type PersistedHtmlAnchor,
20
+ type PersistedHtmlAnchorResult,
21
+ type ProjectHostThreadsOptions,
22
+ } from "./hostThreads";
@@ -96,6 +96,68 @@ export interface SrcdocInjectionOptions {
96
96
  hostTheme: boolean;
97
97
  /** The version-diff view is showing (rawHtml is htmlDiff output). */
98
98
  diffActive: boolean;
99
+ /**
100
+ * Load the bridge through a classic `<script src>` from this URL instead of
101
+ * inlining `BRIDGE_SCRIPT`. Absent or empty: inline, exactly as before. The
102
+ * tag takes the inline script's place, so placement is identical on both
103
+ * paths: at the end of `<head>`, before the body (head scripts of the page
104
+ * run first, body scripts after the bridge). No `crossorigin` attribute is
105
+ * set: the srcdoc frame is an opaque origin, and a classic script needs no
106
+ * CORS to execute. Callers must pass an ABSOLUTE URL (see
107
+ * {@link resolveBridgeScriptUrl}): the framed page may carry its own
108
+ * `<base href>`, which precedes the injected tag and would re-anchor a
109
+ * relative URL onto an attacker-chosen origin.
110
+ */
111
+ bridgeScriptUrl?: string;
112
+ }
113
+
114
+ /**
115
+ * Resolve a host-supplied bridge URL against the PARENT document (its
116
+ * `document.baseURI`), never against the framed page. The injection lands at
117
+ * the end of the page's `<head>`, after any `<base href>` the page declares,
118
+ * so a relative URL written into the srcdoc would resolve against that base:
119
+ * a hostile document could point the viewer at an attacker-served bridge and
120
+ * defeat the version check. Resolving here pins the URL before it is written.
121
+ * An unparsable input is returned unchanged (nothing loads, the ready timeout
122
+ * reports it) rather than throwing during render.
123
+ */
124
+ export function resolveBridgeScriptUrl(bridgeScriptUrl: string, parentBaseUrl: string): string {
125
+ try {
126
+ return new URL(bridgeScriptUrl, parentBaseUrl).href;
127
+ } catch {
128
+ return bridgeScriptUrl;
129
+ }
130
+ }
131
+
132
+ /** Escape a string for a double-quoted HTML attribute value. */
133
+ function escapeAttribute(value: string): string {
134
+ return value
135
+ .replace(/&/g, "&amp;")
136
+ .replace(/"/g, "&quot;")
137
+ .replace(/</g, "&lt;")
138
+ .replace(/>/g, "&gt;");
139
+ }
140
+
141
+ /**
142
+ * The bridge `<script>` element, the ONE injection point for both delivery
143
+ * paths. Inline is the default and Plannotator's only path; the URL form is
144
+ * the opt-in for hosts that serve the generated `bridge-script.asset.js`.
145
+ */
146
+ export function buildBridgeScriptTag(bridgeScriptUrl?: string): string {
147
+ if (bridgeScriptUrl) {
148
+ return `<script src="${escapeAttribute(bridgeScriptUrl)}"></script>`;
149
+ }
150
+ if (!BRIDGE_SCRIPT) {
151
+ // Only reachable when a host aliased `./bridge-script` to the generated
152
+ // `bridge-script.lite` module (which stubs the inline literal) and then
153
+ // rendered an HtmlViewer without `bridgeScriptUrl`: an empty inline
154
+ // script would be a silently dead surface, so fail loudly instead.
155
+ throw new Error(
156
+ "@plannotator/ui HtmlViewer: the inline bridge script is stubbed out "
157
+ + "(bridge-script.lite alias) but no bridgeScriptUrl was passed.",
158
+ );
159
+ }
160
+ return `<script>${BRIDGE_SCRIPT}</script>`;
99
161
  }
100
162
 
101
163
  /** The `<style>` + `<script>` block spliced into the document's head. */
@@ -104,6 +166,7 @@ export function buildSrcdocInjection({
104
166
  isLight,
105
167
  hostTheme,
106
168
  diffActive,
169
+ bridgeScriptUrl,
107
170
  }: SrcdocInjectionOptions): string {
108
171
  const payload = buildThemeTokenPayload(tokens, hostTheme);
109
172
  let themeCSS = ":root {\n";
@@ -117,7 +180,7 @@ export function buildSrcdocInjection({
117
180
  themeCSS += `:root { color-scheme: ${isLight ? "light" : "dark"}; }\n`;
118
181
  }
119
182
  const diffCSS = diffActive ? DIFF_HIGHLIGHT_CSS : "";
120
- return `<style>${themeCSS}${ANNOTATION_HIGHLIGHT_CSS}${diffCSS}</style><script>${BRIDGE_SCRIPT}</script>`;
183
+ return `<style>${themeCSS}${ANNOTATION_HIGHLIGHT_CSS}${diffCSS}</style>${buildBridgeScriptTag(bridgeScriptUrl)}`;
121
184
  }
122
185
 
123
186
  /**
@@ -126,6 +189,12 @@ export function buildSrcdocInjection({
126
189
  * script and disables annotation entirely. The iframe `sandbox` attribute is
127
190
  * the security boundary for the annotate surface; the page's CSP was written
128
191
  * for its standalone context, so it is removed before injection.
192
+ *
193
+ * The package itself never adds a CSP `<meta>` to the srcdoc document (the
194
+ * injection is one `<style>` and one `<script>`), so nothing here blocks a
195
+ * `<script src>` on the URL path. A CSP delivered as an HTTP header on the
196
+ * HOST page is inherited by the srcdoc document, and the host must allow
197
+ * `script-src` for the origin the asset is served from.
129
198
  */
130
199
  const META_CSP_RE =
131
200
  /<meta\s[^>]*http-equiv\s*=\s*["']?\s*content-security-policy\s*["']?[^>]*\/?>/gi;
@@ -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,
@@ -8,9 +8,46 @@ import type {
8
8
  QuickLabelPickerState,
9
9
  UseAnnotationHighlighterReturn,
10
10
  } from "../../hooks/useAnnotationHighlighter";
11
+ import { BRIDGE_PROTOCOL_VERSION } from "./bridge-script";
11
12
 
12
13
  const PREFIX = "plannotator-bridge-";
13
14
 
15
+ /** Outcome of comparing a bridge `ready` message's stamp with this parent. */
16
+ export interface BridgeProtocolVerdict {
17
+ ok: boolean;
18
+ /** The version this parent bundle speaks (`BRIDGE_PROTOCOL_VERSION`). */
19
+ expected: number;
20
+ /** The version the bridge reported; `undefined` when the ready carried none
21
+ * (a bridge asset built before the stamp existed, or a forged message). */
22
+ reported: number | undefined;
23
+ }
24
+
25
+ /**
26
+ * Compare a `ready` message against the parent's protocol version. A missing
27
+ * stamp counts as a mismatch: the only way it can be absent is a bridge asset
28
+ * older than the parent (or a page forging the message), which is exactly
29
+ * the drift this check exists to name.
30
+ */
31
+ export function checkBridgeProtocolVersion(data: unknown): BridgeProtocolVerdict {
32
+ const raw = isRecord(data) ? data.protocolVersion : undefined;
33
+ const reported = typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
34
+ return {
35
+ ok: reported === BRIDGE_PROTOCOL_VERSION,
36
+ expected: BRIDGE_PROTOCOL_VERSION,
37
+ reported,
38
+ };
39
+ }
40
+
41
+ /** The one console warning a mismatch produces; names both versions. */
42
+ export function formatBridgeProtocolWarning(
43
+ verdict: BridgeProtocolVerdict,
44
+ bridgeScriptUrl?: string,
45
+ ): string {
46
+ const reported = verdict.reported === undefined ? "none" : String(verdict.reported);
47
+ const source = bridgeScriptUrl ? `the bridge script at ${bridgeScriptUrl}` : "the bridge script";
48
+ return `[plannotator] HTML bridge protocol version mismatch: this viewer expects ${verdict.expected}, ${source} reported ${reported}. Serve the bridge-script asset from the same @plannotator/ui version as the viewer.`;
49
+ }
50
+
14
51
  // Collision-proof annotation ids. `Date.now()` alone repeats within a millisecond,
15
52
  // so two quick annotations could share a data-bind-id and clobber each other.
16
53
  let htmlAnnSeq = 0;
@@ -18,6 +55,13 @@ function nextHtmlAnnId(): string {
18
55
  return `html-ann-${Date.now().toString(36)}-${(htmlAnnSeq++).toString(36)}`;
19
56
  }
20
57
 
58
+ /** Ids minted by this module for locally created annotations (create-mark).
59
+ * Module-scoped like the sequence above: a host that swaps a local id for
60
+ * its own server id keeps the local mark until it removes it, and the
61
+ * unanchored union needs to recognise such ids whichever viewer instance
62
+ * minted them. Bounded: only ids from this page load, one entry per create. */
63
+ const mintedHtmlAnnIds = new Set<string>();
64
+
21
65
  function htmlCommentDraftKey(
22
66
  text: string,
23
67
  anchor?: HtmlElementAnchor | null,
@@ -129,6 +173,20 @@ export interface UseHtmlAnnotationOptions {
129
173
  * empty on recovery. Delivered in readOnly mode too: view-only surfaces
130
174
  * are exactly where silently missing markers would go unnoticed. */
131
175
  onUnanchoredChange?: (ids: string[]) => void;
176
+ /** Product cap on additional (shift-click) targets per comment, 0..16.
177
+ * Applied at the trust boundary, on submit, on restore, and carried to
178
+ * the bridge on arm-multi-select so the in-page toggle stops at the
179
+ * same number. Absent: the package's 16, and the arm message is unchanged. */
180
+ maxAdditionalTargets?: number;
181
+ /** scrollIntoView behavior for scroll-to (selecting an annotation).
182
+ * Absent: smooth, as before; pass 'auto' to honor reduced motion. */
183
+ scrollBehavior?: 'smooth' | 'auto';
184
+ }
185
+
186
+ /** Clamp a host cap into the package's bound; anything unusable is the default. */
187
+ export function resolveMaxAdditionalTargets(value: number | undefined): number {
188
+ if (value === undefined || !Number.isFinite(value)) return MAX_ADDITIONAL_TARGETS;
189
+ return Math.max(0, Math.min(MAX_ADDITIONAL_TARGETS, Math.floor(value)));
132
190
  }
133
191
 
134
192
  function postToIframe(
@@ -365,6 +423,8 @@ export function useHtmlAnnotation({
365
423
  onPageChange,
366
424
  onBridgePointer,
367
425
  onUnanchoredChange,
426
+ maxAdditionalTargets,
427
+ scrollBehavior,
368
428
  }: UseHtmlAnnotationOptions): Omit<
369
429
  UseAnnotationHighlighterReturn,
370
430
  "highlighterRef" | "highlightRange" | "highlightMathElement"
@@ -377,6 +437,13 @@ export function useHtmlAnnotation({
377
437
  flashDraftTarget: (key: string) => void;
378
438
  /** Bumped after every target add/remove so the composer can refocus its textarea. */
379
439
  composerFocusToken: number;
440
+ /** Composer one-click "Looks good": submits the hardcoded positive label
441
+ * with the same anchor and multi-select targets a typed comment would carry. */
442
+ handleCommentLooksGood: () => void;
443
+ /** Ids this module minted for locally created annotations (create-mark),
444
+ * for the unanchored union: a minted id the host never listed is a
445
+ * swapped-out local mark, not a host row. Read-only, stable identity. */
446
+ createdAnnotationIds: ReadonlySet<string>;
380
447
  } {
381
448
  const [toolbarState, setToolbarState] = useState<ToolbarState | null>(null);
382
449
  const [commentPopover, setCommentPopover] = useState<CommentPopoverState | null>(null);
@@ -417,6 +484,12 @@ export function useHtmlAnnotation({
417
484
  liveRef.current = live ?? null;
418
485
  const onPageChangeRef = useRef(onPageChange);
419
486
  onPageChangeRef.current = onPageChange;
487
+ // The effective cap and whether the host set one: only an explicit cap
488
+ // rides on arm-multi-select, so an unconfigured viewer posts today's message.
489
+ const maxTargetsRef = useRef(resolveMaxAdditionalTargets(maxAdditionalTargets));
490
+ maxTargetsRef.current = resolveMaxAdditionalTargets(maxAdditionalTargets);
491
+ const hostCapRef = useRef(maxAdditionalTargets !== undefined);
492
+ hostCapRef.current = maxAdditionalTargets !== undefined;
420
493
 
421
494
  const anchorRef = useRef<HTMLDivElement | null>(null);
422
495
 
@@ -577,6 +650,7 @@ export function useHtmlAnnotation({
577
650
  post({
578
651
  type: `${PREFIX}arm-multi-select`,
579
652
  key: message.targetKey,
653
+ ...(hostCapRef.current ? { max: maxTargetsRef.current } : {}),
580
654
  });
581
655
  }
582
656
  } else {
@@ -596,7 +670,7 @@ export function useHtmlAnnotation({
596
670
  if (
597
671
  commentPopoverRef.current
598
672
  && targets.length > 0
599
- && targets.length < 1 + MAX_ADDITIONAL_TARGETS
673
+ && targets.length < 1 + maxTargetsRef.current
600
674
  && !targets.some((t) => t.key === message.key)
601
675
  ) {
602
676
  setDraftTargets([
@@ -712,6 +786,9 @@ export function useHtmlAnnotation({
712
786
  post({
713
787
  type: `${PREFIX}scroll-to`,
714
788
  id: selectedAnnotationId,
789
+ // Only an explicit host preference rides along; the default message
790
+ // is unchanged and the bridge scrolls smoothly as before.
791
+ ...(scrollBehavior ? { behavior: scrollBehavior } : {}),
715
792
  });
716
793
  } else {
717
794
  post({
@@ -719,7 +796,7 @@ export function useHtmlAnnotation({
719
796
  id: null,
720
797
  });
721
798
  }
722
- }, [selectedAnnotationId, post]);
799
+ }, [selectedAnnotationId, post, scrollBehavior]);
723
800
 
724
801
  const handleAnnotate = useCallback(
725
802
  (type: AnnotationType) => {
@@ -728,6 +805,7 @@ export function useHtmlAnnotation({
728
805
  if (!text || type !== AnnotationType.DELETION) return;
729
806
 
730
807
  const id = nextHtmlAnnId();
808
+ mintedHtmlAnnIds.add(id);
731
809
  post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
732
810
  onAddRef.current?.({
733
811
  id,
@@ -778,7 +856,7 @@ export function useHtmlAnnotation({
778
856
  const targets = draftTargetsRef.current;
779
857
  const additionalTargets: HtmlAnnotationTarget[] | undefined =
780
858
  targets.length > 1
781
- ? targets.slice(1, 1 + MAX_ADDITIONAL_TARGETS).map((t) => ({
859
+ ? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
782
860
  label: t.label,
783
861
  text: t.text,
784
862
  anchor: t.anchor ?? undefined,
@@ -786,6 +864,7 @@ export function useHtmlAnnotation({
786
864
  : undefined;
787
865
 
788
866
  const id = nextHtmlAnnId();
867
+ mintedHtmlAnnIds.add(id);
789
868
  post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
790
869
  onAddRef.current?.({
791
870
  id,
@@ -810,6 +889,51 @@ export function useHtmlAnnotation({
810
889
  [post],
811
890
  );
812
891
 
892
+ // The composer's one-click "Looks good" (the restored thumbs-up for
893
+ // comment-only surfaces, where pinpoint clicks land straight in the
894
+ // composer and never see the selection toolbar). Mirrors
895
+ // handleCommentSubmit — same anchor, same multi-select targets — but
896
+ // emits the hardcoded positive label instead of typed prose.
897
+ const handleCommentLooksGood = useCallback(() => {
898
+ if (!enabledRef.current) return;
899
+ const text = commentPopoverRef.current?.selectedText || pendingTextRef.current;
900
+ if (!text) return;
901
+
902
+ const targets = draftTargetsRef.current;
903
+ const additionalTargets: HtmlAnnotationTarget[] | undefined =
904
+ targets.length > 1
905
+ ? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
906
+ label: t.label,
907
+ text: t.text,
908
+ anchor: t.anchor ?? undefined,
909
+ }))
910
+ : undefined;
911
+
912
+ const id = nextHtmlAnnId();
913
+ mintedHtmlAnnIds.add(id);
914
+ post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
915
+ onAddRef.current?.({
916
+ id,
917
+ blockId: "",
918
+ startOffset: 0,
919
+ endOffset: 0,
920
+ type: AnnotationType.COMMENT,
921
+ text: THUMBS_UP_LABEL.text,
922
+ originalText: text,
923
+ isQuickLabel: true,
924
+ quickLabelTip: THUMBS_UP_LABEL.tip,
925
+ author: getIdentity(),
926
+ createdA: Date.now(),
927
+ htmlAnchor: pendingAnchorRef.current ?? undefined,
928
+ htmlAdditionalTargets: additionalTargets,
929
+ });
930
+
931
+ setCommentPopover(null);
932
+ setDraftTargets([]);
933
+ pendingTextRef.current = "";
934
+ pendingAnchorRef.current = null;
935
+ }, [post]);
936
+
813
937
  const handleCommentClose = useCallback(() => {
814
938
  post({ type: `${PREFIX}cancel-selection` });
815
939
  setCommentPopover(null);
@@ -846,6 +970,7 @@ export function useHtmlAnnotation({
846
970
  const text = pendingTextRef.current;
847
971
  if (!text) return;
848
972
  const id = nextHtmlAnnId();
973
+ mintedHtmlAnnIds.add(id);
849
974
  post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
850
975
  onAddRef.current?.({
851
976
  id,
@@ -906,7 +1031,7 @@ export function useHtmlAnnotation({
906
1031
  const additionalAnchors = (ann.htmlAdditionalTargets ?? [])
907
1032
  .map((t) => t.anchor)
908
1033
  .filter((a): a is HtmlElementAnchor => !!a)
909
- .slice(0, MAX_ADDITIONAL_TARGETS);
1034
+ .slice(0, maxTargetsRef.current);
910
1035
  post({
911
1036
  type: `${PREFIX}find-and-mark`,
912
1037
  id: ann.id,
@@ -931,6 +1056,7 @@ export function useHtmlAnnotation({
931
1056
  handleToolbarClose,
932
1057
  handleRequestComment,
933
1058
  handleCommentSubmit,
1059
+ handleCommentLooksGood,
934
1060
  handleCommentClose,
935
1061
  handleFloatingQuickLabel,
936
1062
  handleQuickLabelPickerDismiss,
@@ -941,5 +1067,6 @@ export function useHtmlAnnotation({
941
1067
  removeDraftTarget,
942
1068
  flashDraftTarget,
943
1069
  composerFocusToken,
1070
+ createdAnnotationIds: mintedHtmlAnnIds,
944
1071
  };
945
1072
  }