@plannotator/ui 0.39.0 → 0.41.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 (52) hide show
  1. package/HANDOFF.md +140 -10
  2. package/README.md +9 -5
  3. package/components/AnnotationPanel.tsx +244 -13
  4. package/components/CommentPopover.tsx +91 -2
  5. package/components/DiagramBlock.tsx +376 -0
  6. package/components/GraphvizBlock.tsx +22 -597
  7. package/components/HtmlSurfaceControls.tsx +188 -23
  8. package/components/ListMarker.tsx +10 -1
  9. package/components/MermaidBlock.tsx +17 -630
  10. package/components/TableOfContents.tsx +5 -1
  11. package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
  12. package/components/Viewer.tsx +67 -3
  13. package/components/blocks/AlertBlock.tsx +7 -2
  14. package/components/diagram/DiagramCanvas.tsx +431 -0
  15. package/components/diagram/DiagramComposer.tsx +135 -0
  16. package/components/diagram/DiagramOverlay.tsx +215 -0
  17. package/components/diagram/DiagramPopout.tsx +70 -0
  18. package/components/diagram/DiagramSourcePane.tsx +244 -0
  19. package/components/diagram/DiagramViewer.tsx +276 -0
  20. package/components/diagram/anchorClaims.ts +71 -0
  21. package/components/diagram/index.ts +36 -0
  22. package/components/diagram/useDiagramComments.ts +341 -0
  23. package/components/diagram/useDiagramRender.ts +91 -0
  24. package/components/diagram/useDiagramSourceDraft.ts +143 -0
  25. package/components/diagram/useDiagramViewport.ts +156 -0
  26. package/components/html-viewer/HtmlViewer.tsx +32 -0
  27. package/components/html-viewer/bridge-script.asset.js +121 -9
  28. package/components/html-viewer/bridge-script.lite.ts +1 -1
  29. package/components/html-viewer/bridge-script.ts +133 -9
  30. package/components/html-viewer/useHtmlAnnotation.ts +44 -151
  31. package/hooks/useAnnotationHighlighter.ts +469 -14
  32. package/hooks/useLinkedDoc.ts +100 -9
  33. package/package.json +6 -4
  34. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
  35. package/styles.css +1 -1
  36. package/theme.css +62 -0
  37. package/types.ts +5 -0
  38. package/utils/annotationScope.ts +159 -0
  39. package/utils/cssColor.ts +463 -0
  40. package/utils/diagram-anchor-graphviz.ts +143 -0
  41. package/utils/diagram-anchor.ts +401 -0
  42. package/utils/diagram-projection.ts +66 -0
  43. package/utils/diagram-render.ts +668 -0
  44. package/utils/graphviz.ts +93 -0
  45. package/utils/htmlChrome.ts +70 -5
  46. package/utils/htmlLinkNavigation.ts +196 -0
  47. package/utils/mermaid-eager.ts +13 -11
  48. package/utils/mermaid.ts +19 -10
  49. package/utils/mermaidTheme.ts +732 -0
  50. package/utils/parser.ts +36 -7
  51. package/utils/terminalToolsAnnouncement.ts +76 -0
  52. package/components/mermaidSvg.ts +0 -33
@@ -60,6 +60,18 @@ body[data-plannotator-pinpoint-cursor],
60
60
  body[data-plannotator-pinpoint-cursor] * {
61
61
  cursor: crosshair !important;
62
62
  }
63
+ /* Armed pinpoint over an EMBEDDED local document: the embed is one element
64
+ * from the outer page's point of view, and the bridge is never injected into a
65
+ * nested frame, so a click inside it would simply vanish into another document.
66
+ * Making frames transparent to the pointer while armed is what lets that click
67
+ * pin the <iframe>/<embed>/<object> itself. Interact (Esc, the header pen or
68
+ * Mod+Shift+A) restores native interaction inside the embed — which is also
69
+ * the only state a link inside it can be followed from.
70
+ * Live-app sessions never set this attribute: they annotate a real app whose
71
+ * own nested frames belong to it. */
72
+ body[data-plannotator-frame-inert] :is(iframe, frame, embed, object) {
73
+ pointer-events: none !important;
74
+ }
63
75
  @media (prefers-reduced-motion: reduce) {
64
76
  [data-plannotator-pinpoint-box].pn-pin-enter {
65
77
  animation: none;
@@ -381,8 +393,10 @@ export const BRIDGE_SCRIPT = `(function() {
381
393
  if (!document.body) return;
382
394
  if (annotateModeActive && currentInputMethod === 'pinpoint') {
383
395
  document.body.setAttribute('data-plannotator-pinpoint-cursor', '');
396
+ if (!LIVE) document.body.setAttribute('data-plannotator-frame-inert', '');
384
397
  } else {
385
398
  document.body.removeAttribute('data-plannotator-pinpoint-cursor');
399
+ document.body.removeAttribute('data-plannotator-frame-inert');
386
400
  }
387
401
  }
388
402
  var pinpointHover = null;
@@ -690,6 +704,13 @@ export const BRIDGE_SCRIPT = `(function() {
690
704
  scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
691
705
  }
692
706
 
707
+ else if (type === PREFIX + 'scroll-to-fragment') {
708
+ // A linked document opened from an in-page link carried a #fragment.
709
+ // The srcdoc document has no URL of its own, so the parent cannot set
710
+ // one: it replays the fragment here once the new document is ready.
711
+ scrollToLocalFragment(typeof e.data.fragment === 'string' ? e.data.fragment : '');
712
+ }
713
+
693
714
  else if (type === PREFIX + 'focus-mark') {
694
715
  focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
695
716
  }
@@ -905,11 +926,34 @@ export const BRIDGE_SCRIPT = `(function() {
905
926
  var svgGroup = node.closest('g');
906
927
  if (svgGroup) node = svgGroup;
907
928
  }
929
+ node = preferInertFrameAt(node, x, y);
908
930
  node = promoteTinyTarget(node);
909
931
  if (node === document.body || node === document.documentElement) return null;
910
932
  return node;
911
933
  }
912
934
 
935
+ // While frames are pointer-transparent (armed pinpoint, srcdoc sessions),
936
+ // hit-testing passes THROUGH an embedded document to the container painted
937
+ // behind it — so a click on an embed would pin its wrapper div. The embed is
938
+ // what the reviewer is pointing at and what the anchor must name, so a point
939
+ // inside a frame's own rect resolves to that frame. Bounded to the frames
940
+ // inside the element already resolved, so it costs nothing on ordinary pages.
941
+ var FRAME_SELECTOR = 'iframe,frame,embed,object';
942
+ function framesArePointerInert() {
943
+ return !LIVE && annotateModeActive && currentInputMethod === 'pinpoint';
944
+ }
945
+ function preferInertFrameAt(node, x, y) {
946
+ if (!framesArePointerInert() || !node.querySelectorAll) return node;
947
+ if (node.matches && node.matches(FRAME_SELECTOR)) return node;
948
+ var frames = node.querySelectorAll(FRAME_SELECTOR);
949
+ for (var i = 0; i < frames.length && i < 64; i++) {
950
+ var r = frames[i].getBoundingClientRect();
951
+ if (r.width <= 0 || r.height <= 0) continue;
952
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return frames[i];
953
+ }
954
+ return node;
955
+ }
956
+
913
957
  // Last-position reuse: a pointer that moved under 2px within 16ms resolves
914
958
  // to the cached element instead of re-hit-testing. The scroll reconcile
915
959
  // invalidates this cache — same point, different element after a scroll.
@@ -2741,8 +2785,9 @@ export const BRIDGE_SCRIPT = `(function() {
2741
2785
  // Constraints (the "smart" part is that they adapt to the element):
2742
2786
  // - attributes are an ALLOWLIST (a page cannot add a key); no form values,
2743
2787
  // no on* handlers, no style, no script/style/template contents ever;
2744
- // - absolute http(s) URLs lose their query and fragment (tokens live
2745
- // there), data: URIs keep only their media-type prefix;
2788
+ // - href/src URLs lose their query and fragment (tokens live there),
2789
+ // relative ones included, and data: URIs keep only their media-type
2790
+ // prefix;
2746
2791
  // - the outline tries two levels of children, falls back to one, then to a
2747
2792
  // per-tag count, whichever first fits CTX_MAX_OUTLINE, so a click on a
2748
2793
  // whole <main> costs the same bytes as a click on a chip;
@@ -2792,7 +2837,10 @@ export const BRIDGE_SCRIPT = `(function() {
2792
2837
  }
2793
2838
 
2794
2839
  // URL attribute values: keep what locates the element in source, drop
2795
- // what identifies the user. Relative URLs are route state and stay whole.
2840
+ // what identifies the user. The path survives in every form; the query and
2841
+ // the fragment never do — a relative URL carries the same per-visit state an
2842
+ // absolute one does (session ids, and the implicit-flow tokens that live in
2843
+ // the fragment specifically), so it is scrubbed the same way.
2796
2844
  function ctxScrubUrl(value) {
2797
2845
  var v = String(value).trim();
2798
2846
  if (/^javascript:/i.test(v)) return null;
@@ -2806,6 +2854,8 @@ export const BRIDGE_SCRIPT = `(function() {
2806
2854
  return u.origin + u.pathname + (u.search || u.hash ? '?…' : '');
2807
2855
  } catch (ex) { return ctxTruncate(v, CTX_MAX_ATTR_VALUE); }
2808
2856
  }
2857
+ var mark = v.search(/[?#]/);
2858
+ if (mark >= 0) return v.slice(0, mark) + '?…';
2809
2859
  return v;
2810
2860
  }
2811
2861
 
@@ -3504,6 +3554,74 @@ export const BRIDGE_SCRIPT = `(function() {
3504
3554
  return true;
3505
3555
  }
3506
3556
 
3557
+ // --- Local-site link navigation (srcdoc sessions only) ---
3558
+ // A srcdoc document has no URL of its own: its base URL is the PARENT page's,
3559
+ // which is the Plannotator server. So a plain link to 02-detail.html resolves
3560
+ // to http://localhost:<port>/02-detail.html, the server's catch-all answers
3561
+ // with the app itself, and the whole editor renders inside the annotated
3562
+ // frame. An in-page #section link is a cross-document navigation for the
3563
+ // same reason.
3564
+ //
3565
+ // The frame therefore never navigates itself. In-page fragments scroll here;
3566
+ // everything else is handed to the parent, which owns resolution against the
3567
+ // current document's directory and is the trust boundary for the href.
3568
+ // Registered BEFORE the pinpoint handler and never stopping propagation, so
3569
+ // an armed click still pins the link element exactly as it always did.
3570
+ //
3571
+ // Live app sessions are excluded outright: they navigate a real origin
3572
+ // through the proxy, which is the whole point of that surface.
3573
+ function scrollToLocalFragment(rawId) {
3574
+ var id = typeof rawId === 'string' ? rawId : '';
3575
+ try { id = decodeURIComponent(id); } catch (ex) {}
3576
+ if (!id) {
3577
+ try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (ex) { window.scrollTo(0, 0); }
3578
+ return true;
3579
+ }
3580
+ var target = null;
3581
+ try { target = document.getElementById(id); } catch (ex) {}
3582
+ if (!target) {
3583
+ var named = document.getElementsByName(id);
3584
+ if (named && named.length) target = named[0];
3585
+ }
3586
+ if (!target) return false;
3587
+ try { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
3588
+ catch (ex) { target.scrollIntoView(); }
3589
+ return true;
3590
+ }
3591
+
3592
+ function navigableLinkHref(node) {
3593
+ var el = node && node.nodeType === 1 ? node : node && node.parentElement;
3594
+ if (!el || !el.closest) return '';
3595
+ var link = el.closest('a,area');
3596
+ if (!link) return '';
3597
+ var raw = link.getAttribute('href');
3598
+ // SVG anchors may only carry xlink:href.
3599
+ if (typeof raw !== 'string') raw = link.getAttribute('xlink:href');
3600
+ return typeof raw === 'string' ? raw.trim() : '';
3601
+ }
3602
+
3603
+ if (!LIVE) {
3604
+ document.addEventListener('click', function(e) {
3605
+ if (e.defaultPrevented || e.button !== 0) return;
3606
+ if (isViewerOverlayNode(e.target)) return; // markers own their clicks
3607
+ var raw = navigableLinkHref(e.target);
3608
+ if (!raw) return;
3609
+ // The page's own scripting, not a navigation: leave it alone.
3610
+ if (/^javascript:/i.test(raw)) return;
3611
+ if (raw.charAt(0) === '#') {
3612
+ e.preventDefault();
3613
+ scrollToLocalFragment(raw.slice(1));
3614
+ return;
3615
+ }
3616
+ e.preventDefault();
3617
+ // Armed pinpoint: the click belongs to annotation, and the capture-phase
3618
+ // pinpoint handler below is about to pin this element. Navigation is
3619
+ // already suppressed above, which is all this surface owes the click.
3620
+ if (annotateModeActive && currentInputMethod === 'pinpoint') return;
3621
+ postToParent({ type: PREFIX + 'link-click', href: raw.slice(0, 2048) });
3622
+ }, true);
3623
+ }
3624
+
3507
3625
  document.addEventListener('click', function(e) {
3508
3626
  if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
3509
3627
  // Real placed markers (and any other viewer overlay) own their clicks —
@@ -3579,15 +3697,21 @@ export const BRIDGE_SCRIPT = `(function() {
3579
3697
  }
3580
3698
  });
3581
3699
 
3582
- // Mod+Shift+A toggles Interact/Annotate from inside the iframe (the parent
3583
- // registers the same chord, but focus usually lives in here on live apps).
3584
- // Capture phase so the page cannot swallow the reserved chord; the parent
3585
- // answers with set-annotate-mode.
3700
+ // The two reserved header chords, mirrored from inside the iframe (the
3701
+ // parent registers both, but focus usually lives in here on live apps):
3702
+ // Mod+Shift+A toggles Interact/Annotate, Mod+Shift+X shows/hides the
3703
+ // floating tools over the page. Capture phase so the page cannot swallow
3704
+ // them; the parent owns both states and answers annotate with
3705
+ // set-annotate-mode. Disarming tears down any pending draft through that
3706
+ // same set-annotate-mode(false) handler, exactly as Esc does.
3586
3707
  document.addEventListener('keydown', function(e) {
3587
3708
  if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return;
3588
- if (e.key !== 'a' && e.key !== 'A') return;
3709
+ var message = null;
3710
+ if (e.key === 'a' || e.key === 'A') message = 'annotate-toggle';
3711
+ else if (e.key === 'x' || e.key === 'X') message = 'tools-toggle';
3712
+ if (!message) return;
3589
3713
  e.preventDefault();
3590
- postToParent({ type: PREFIX + 'annotate-toggle' });
3714
+ postToParent({ type: PREFIX + message });
3591
3715
  }, true);
3592
3716
 
3593
3717
  // Author opt-in: a plain click on any element tagged [data-annotate] pops the
@@ -9,6 +9,11 @@ import type {
9
9
  UseAnnotationHighlighterReturn,
10
10
  } from "../../hooks/useAnnotationHighlighter";
11
11
  import { BRIDGE_PROTOCOL_VERSION } from "./bridge-script";
12
+ import {
13
+ parseHtmlElementContext,
14
+ MAX_ELEMENT_CONTEXT_BYTES,
15
+ MAX_PAGE_URL_LENGTH,
16
+ } from "@plannotator/core/html-anchor";
12
17
 
13
18
  const PREFIX = "plannotator-bridge-";
14
19
 
@@ -123,7 +128,8 @@ type BridgeMessage =
123
128
  | { type: `${typeof PREFIX}mark-click`; id: string }
124
129
  | { type: `${typeof PREFIX}unanchored`; ids: string[] }
125
130
  | { type: `${typeof PREFIX}resize`; height: number }
126
- | { type: `${typeof PREFIX}page-change`; pageUrl: string };
131
+ | { type: `${typeof PREFIX}page-change`; pageUrl: string }
132
+ | { type: `${typeof PREFIX}link-click`; href: string };
127
133
 
128
134
  /** Live proxied-app session credentials: the proxy origin messages must come
129
135
  * from, and the per-session token every message must echo. */
@@ -132,8 +138,10 @@ export interface HtmlLiveSession {
132
138
  token: string;
133
139
  }
134
140
 
135
- /** Cap for live-mode page identity strings (mirrors the bridge's slice). */
136
- export const MAX_PAGE_URL_LENGTH = 2048;
141
+ /** Cap for a link href relayed out of the framed document. */
142
+ const MAX_LINK_HREF_LENGTH = 2048;
143
+ /** Control characters never appear in a real href; they are how structure gets smuggled. */
144
+ const LINK_CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
137
145
 
138
146
  /** True when a live-session message event fails the origin or token check.
139
147
  * Exported for protocol tests. */
@@ -182,6 +190,12 @@ export interface UseHtmlAnnotationOptions {
182
190
  * the bridge on arm-multi-select so the in-page toggle stops at the
183
191
  * same number. Absent: the package's 16, and the arm message is unchanged. */
184
192
  maxAdditionalTargets?: number;
193
+ /** A link the framed document swallowed rather than navigating to. The raw
194
+ * href, already bounded and screened; the host resolves it (see
195
+ * `resolveHtmlLinkIntent`). Delivered in readOnly mode too — navigating is
196
+ * a read action — and never fired in live sessions, which navigate the
197
+ * proxied app for real. */
198
+ onLinkClick?: (href: string) => void;
185
199
  /** scrollIntoView behavior for scroll-to (selecting an annotation).
186
200
  * Absent: smooth, as before; pass 'auto' to honor reduced motion. */
187
201
  scrollBehavior?: 'smooth' | 'auto';
@@ -314,154 +328,14 @@ function parseTargetLabel(value: unknown): string | undefined {
314
328
  : collapsed;
315
329
  }
316
330
 
317
- // Element-context caps. The bridge builds the context under the same numbers,
318
- // but this side is the authoritative one: every scalar is re-collapsed (a
319
- // hostile page can embed newlines that would become markdown structure in the
320
- // exported feedback), every list re-capped, unknown keys dropped, and the
321
- // serialized whole bounded. A malformed context is DROPPED, never fatal to
322
- // the annotation it rides on (the same additive rule as the anchor point).
323
- export const MAX_ELEMENT_CONTEXT_BYTES = 2048;
324
- const MAX_CONTEXT_TAG_LENGTH = 32;
325
- const MAX_CONTEXT_ID_LENGTH = 100;
326
- const MAX_CONTEXT_CLASSES = 9; // 8 + the "+N more" marker
327
- const MAX_CONTEXT_CLASS_LENGTH = 48;
328
- const MAX_CONTEXT_PATH_LENGTH = 512;
329
- const MAX_CONTEXT_ROLE_LENGTH = 32;
330
- const MAX_CONTEXT_NAME_LENGTH = 120;
331
- const MAX_CONTEXT_ATTRS = 10;
332
- const MAX_CONTEXT_ATTR_NAME_LENGTH = 40;
333
- const MAX_CONTEXT_ATTR_VALUE_LENGTH = 120;
334
- const MAX_CONTEXT_TEXT_LENGTH = 300;
335
- const MAX_CONTEXT_OUTLINE_LENGTH = 600;
336
- const MAX_CONTEXT_OUTLINE_LINES = 40;
337
- const MAX_CONTEXT_LANDMARK_LENGTH = 80;
338
- const MAX_CONTEXT_HEADING_LENGTH = 130;
339
- const MAX_CONTEXT_COMPONENT_LENGTH = 100;
340
- const MAX_CONTEXT_PAGE_TITLE_LENGTH = 200;
341
- /** Attribute names the context may carry (mirrors CONTEXT_ATTRS in the bridge). */
342
- const CONTEXT_ATTR_ALLOWLIST = new Set([
343
- "href", "src", "alt", "title", "type", "name", "role", "placeholder", "for", "target", "rel",
344
- "aria-label", "aria-labelledby", "aria-describedby", "aria-current", "aria-expanded", "aria-hidden", "aria-controls",
345
- "data-annotate", "data-testid", "data-test", "data-test-id", "data-cy", "data-qa", "data-component", "data-id",
346
- ]);
347
-
348
- function capAt(text: string, max: number): string {
349
- let cut = max;
350
- const last = text.charCodeAt(cut - 1);
351
- if (last >= 0xd800 && last <= 0xdbff) cut -= 1;
352
- return text.slice(0, cut);
353
- }
354
-
355
- /** Collapse control characters and whitespace runs, then cap. */
356
- function collapseContextScalar(value: unknown, max: number): string | undefined {
357
- if (typeof value !== "string") return undefined;
358
- const collapsed = value.replace(/[\x00-\x1f\x7f]+/g, " ").replace(/\s+/g, " ").trim();
359
- if (!collapsed) return undefined;
360
- return collapsed.length > max ? capAt(collapsed, max) : collapsed;
361
- }
362
-
363
- /** The outline keeps its line breaks (it is fenced on export) but nothing
364
- * else: control characters go, each line is whitespace-collapsed, and a
365
- * backtick run that could close the export's fence is defused. */
366
- function collapseContextOutline(value: unknown): string | undefined {
367
- if (typeof value !== "string") return undefined;
368
- const lines = value
369
- .replace(/[\x00-\x09\x0b-\x1f\x7f]+/g, " ")
370
- .replace(/`{3,}/g, "'''")
371
- .split("\n")
372
- .map((line) => {
373
- // Keep the skeleton's indentation (capped), collapse everything else.
374
- const indent = (/^ */.exec(line)?.[0] ?? "").slice(0, 12);
375
- return indent + line.slice(indent.length).replace(/\s+/g, " ").trim();
376
- })
377
- .filter((line) => line.trim().length > 0)
378
- .slice(0, MAX_CONTEXT_OUTLINE_LINES);
379
- const joined = lines.join("\n").trim();
380
- if (!joined) return undefined;
381
- return joined.length > MAX_CONTEXT_OUTLINE_LENGTH ? capAt(joined, MAX_CONTEXT_OUTLINE_LENGTH) : joined;
382
- }
383
-
384
- function contextBytes(value: unknown): number {
385
- return new TextEncoder().encode(JSON.stringify(value)).length;
386
- }
387
-
388
- /** Validate a bridge-posted element context. Exported for protocol tests. */
389
- export function parseHtmlElementContext(value: unknown): HtmlElementContext | undefined {
390
- if (!isRecord(value)) return undefined;
391
- const tag = collapseContextScalar(value.tag, MAX_CONTEXT_TAG_LENGTH);
392
- if (!tag) return undefined;
393
- const context: HtmlElementContext = { tag: tag.toLowerCase() };
394
- const id = collapseContextScalar(value.id, MAX_CONTEXT_ID_LENGTH);
395
- if (id) context.id = id;
396
- if (Array.isArray(value.classes)) {
397
- const classes: string[] = [];
398
- for (const entry of value.classes) {
399
- if (classes.length >= MAX_CONTEXT_CLASSES) break;
400
- const cls = collapseContextScalar(entry, MAX_CONTEXT_CLASS_LENGTH);
401
- if (cls) classes.push(cls);
402
- }
403
- if (classes.length) context.classes = classes;
404
- }
405
- const path = collapseContextScalar(value.path, MAX_CONTEXT_PATH_LENGTH);
406
- if (path) context.path = path;
407
- const role = collapseContextScalar(value.role, MAX_CONTEXT_ROLE_LENGTH);
408
- if (role) context.role = role;
409
- const name = collapseContextScalar(value.name, MAX_CONTEXT_NAME_LENGTH);
410
- if (name) context.name = name;
411
- if (Array.isArray(value.attrs)) {
412
- const attrs: Array<[string, string]> = [];
413
- for (const entry of value.attrs) {
414
- if (attrs.length >= MAX_CONTEXT_ATTRS) break;
415
- if (!Array.isArray(entry) || entry.length !== 2) continue;
416
- const attrName = collapseContextScalar(entry[0], MAX_CONTEXT_ATTR_NAME_LENGTH);
417
- if (!attrName || !CONTEXT_ATTR_ALLOWLIST.has(attrName.toLowerCase())) continue;
418
- if (typeof entry[1] !== "string") continue;
419
- attrs.push([attrName.toLowerCase(), collapseContextScalar(entry[1], MAX_CONTEXT_ATTR_VALUE_LENGTH) ?? ""]);
420
- }
421
- if (attrs.length) context.attrs = attrs;
422
- }
423
- const text = collapseContextScalar(value.text, MAX_CONTEXT_TEXT_LENGTH);
424
- if (text) context.text = text;
425
- const outline = collapseContextOutline(value.outline);
426
- if (outline) context.outline = outline;
427
- if (typeof value.children === "number" && Number.isFinite(value.children) && value.children >= 0) {
428
- context.children = Math.min(100000, Math.floor(value.children));
429
- }
430
- if (isRecord(value.rect)) {
431
- const rect = value.rect;
432
- const nums = ["x", "y", "w", "h", "vw", "vh"].map((key) => {
433
- const n = rect[key];
434
- return typeof n === "number" && Number.isFinite(n) ? Math.round(Math.max(-1e6, Math.min(1e6, n))) : null;
435
- });
436
- if (nums.every((n) => n !== null)) {
437
- const [x, y, w, h, vw, vh] = nums as number[];
438
- context.rect = { x: x!, y: y!, w: w!, h: h!, vw: vw!, vh: vh! };
439
- }
440
- }
441
- const landmark = collapseContextScalar(value.landmark, MAX_CONTEXT_LANDMARK_LENGTH);
442
- if (landmark) context.landmark = landmark;
443
- const heading = collapseContextScalar(value.heading, MAX_CONTEXT_HEADING_LENGTH);
444
- if (heading) context.heading = heading;
445
- const component = collapseContextScalar(value.component, MAX_CONTEXT_COMPONENT_LENGTH);
446
- if (component) context.component = component;
447
- if (isRecord(value.page)) {
448
- const url = collapseContextScalar(value.page.url, MAX_PAGE_URL_LENGTH);
449
- if (url) {
450
- context.page = { url };
451
- const title = collapseContextScalar(value.page.title, MAX_CONTEXT_PAGE_TITLE_LENGTH);
452
- if (title) context.page.title = title;
453
- }
454
- }
455
- // Serialized bound, re-enforced here: shed the expendable fields in the
456
- // bridge's order until the whole fits (validated per-field caps make this
457
- // unreachable for an honest bridge; a forged message cannot exceed it).
458
- const shedOrder: Array<keyof HtmlElementContext> = ["outline", "text", "attrs", "classes", "path", "heading", "landmark", "component"];
459
- for (const field of shedOrder) {
460
- if (contextBytes(context) <= MAX_ELEMENT_CONTEXT_BYTES) break;
461
- delete context[field];
462
- }
463
- return contextBytes(context) <= MAX_ELEMENT_CONTEXT_BYTES ? context : undefined;
464
- }
331
+ // Re-exported so `components/html-viewer` stays the one import site a host
332
+ // needs for the parent trust boundary; the definitions live in
333
+ // `@plannotator/core/html-anchor`, never mirrored here.
334
+ export {
335
+ parseHtmlElementContext,
336
+ MAX_ELEMENT_CONTEXT_BYTES,
337
+ MAX_PAGE_URL_LENGTH,
338
+ };
465
339
 
466
340
  function parseBridgeRect(value: unknown): BridgeRect | null {
467
341
  if (!isRecord(value)) return null;
@@ -548,6 +422,16 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
548
422
  return typeof value.height === "number" && Number.isFinite(value.height)
549
423
  ? { type: value.type, height: value.height }
550
424
  : null;
425
+ case `${PREFIX}link-click`: {
426
+ // The raw href of a link the framed document just swallowed. It is
427
+ // page-controlled text, so it is bounded and screened here — the trust
428
+ // boundary — before the host resolves it into a path or a URL.
429
+ if (typeof value.href !== "string") return null;
430
+ const href = value.href.trim();
431
+ if (!href || href.length > MAX_LINK_HREF_LENGTH) return null;
432
+ if (LINK_CONTROL_CHARS.test(href)) return null;
433
+ return { type: value.type, href };
434
+ }
551
435
  case `${PREFIX}page-change`:
552
436
  // Live-mode SPA navigation report. Bounded like every bridge string.
553
437
  return typeof value.pageUrl === "string"
@@ -576,6 +460,7 @@ export function useHtmlAnnotation({
576
460
  onResize,
577
461
  live,
578
462
  onPageChange,
463
+ onLinkClick,
579
464
  onBridgePointer,
580
465
  onUnanchoredChange,
581
466
  maxAdditionalTargets,
@@ -649,6 +534,8 @@ export function useHtmlAnnotation({
649
534
  liveRef.current = live ?? null;
650
535
  const onPageChangeRef = useRef(onPageChange);
651
536
  onPageChangeRef.current = onPageChange;
537
+ const onLinkClickRef = useRef(onLinkClick);
538
+ onLinkClickRef.current = onLinkClick;
652
539
  // The effective cap and whether the host set one: only an explicit cap
653
540
  // rides on arm-multi-select, so an unconfigured viewer posts today's message.
654
541
  const maxTargetsRef = useRef(resolveMaxAdditionalTargets(maxAdditionalTargets));
@@ -761,6 +648,8 @@ export function useHtmlAnnotation({
761
648
  && type !== `${PREFIX}resize`
762
649
  // Page identity is navigation state, not an annotation mutation.
763
650
  && type !== `${PREFIX}page-change`
651
+ // Following a link is a read action; a read-only document still navigates.
652
+ && type !== `${PREFIX}link-click`
764
653
  ) {
765
654
  return;
766
655
  }
@@ -927,6 +816,10 @@ export function useHtmlAnnotation({
927
816
  if (type === `${PREFIX}page-change`) {
928
817
  onPageChangeRef.current?.(message.pageUrl);
929
818
  }
819
+
820
+ if (type === `${PREFIX}link-click`) {
821
+ onLinkClickRef.current?.(message.href);
822
+ }
930
823
  }
931
824
 
932
825
  window.addEventListener("message", handler);