@plannotator/ui 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -466,6 +466,13 @@
466
466
  scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
467
467
  }
468
468
 
469
+ else if (type === PREFIX + 'scroll-to-fragment') {
470
+ // A linked document opened from an in-page link carried a #fragment.
471
+ // The srcdoc document has no URL of its own, so the parent cannot set
472
+ // one: it replays the fragment here once the new document is ready.
473
+ scrollToLocalFragment(typeof e.data.fragment === 'string' ? e.data.fragment : '');
474
+ }
475
+
469
476
  else if (type === PREFIX + 'focus-mark') {
470
477
  focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
471
478
  }
@@ -2517,8 +2524,9 @@
2517
2524
  // Constraints (the "smart" part is that they adapt to the element):
2518
2525
  // - attributes are an ALLOWLIST (a page cannot add a key); no form values,
2519
2526
  // no on* handlers, no style, no script/style/template contents ever;
2520
- // - absolute http(s) URLs lose their query and fragment (tokens live
2521
- // there), data: URIs keep only their media-type prefix;
2527
+ // - href/src URLs lose their query and fragment (tokens live there),
2528
+ // relative ones included, and data: URIs keep only their media-type
2529
+ // prefix;
2522
2530
  // - the outline tries two levels of children, falls back to one, then to a
2523
2531
  // per-tag count, whichever first fits CTX_MAX_OUTLINE, so a click on a
2524
2532
  // whole <main> costs the same bytes as a click on a chip;
@@ -2568,7 +2576,10 @@
2568
2576
  }
2569
2577
 
2570
2578
  // URL attribute values: keep what locates the element in source, drop
2571
- // what identifies the user. Relative URLs are route state and stay whole.
2579
+ // what identifies the user. The path survives in every form; the query and
2580
+ // the fragment never do — a relative URL carries the same per-visit state an
2581
+ // absolute one does (session ids, and the implicit-flow tokens that live in
2582
+ // the fragment specifically), so it is scrubbed the same way.
2572
2583
  function ctxScrubUrl(value) {
2573
2584
  var v = String(value).trim();
2574
2585
  if (/^javascript:/i.test(v)) return null;
@@ -2582,6 +2593,8 @@
2582
2593
  return u.origin + u.pathname + (u.search || u.hash ? '?…' : '');
2583
2594
  } catch (ex) { return ctxTruncate(v, CTX_MAX_ATTR_VALUE); }
2584
2595
  }
2596
+ var mark = v.search(/[?#]/);
2597
+ if (mark >= 0) return v.slice(0, mark) + '?…';
2585
2598
  return v;
2586
2599
  }
2587
2600
 
@@ -3280,6 +3293,74 @@
3280
3293
  return true;
3281
3294
  }
3282
3295
 
3296
+ // --- Local-site link navigation (srcdoc sessions only) ---
3297
+ // A srcdoc document has no URL of its own: its base URL is the PARENT page's,
3298
+ // which is the Plannotator server. So a plain link to 02-detail.html resolves
3299
+ // to http://localhost:<port>/02-detail.html, the server's catch-all answers
3300
+ // with the app itself, and the whole editor renders inside the annotated
3301
+ // frame. An in-page #section link is a cross-document navigation for the
3302
+ // same reason.
3303
+ //
3304
+ // The frame therefore never navigates itself. In-page fragments scroll here;
3305
+ // everything else is handed to the parent, which owns resolution against the
3306
+ // current document's directory and is the trust boundary for the href.
3307
+ // Registered BEFORE the pinpoint handler and never stopping propagation, so
3308
+ // an armed click still pins the link element exactly as it always did.
3309
+ //
3310
+ // Live app sessions are excluded outright: they navigate a real origin
3311
+ // through the proxy, which is the whole point of that surface.
3312
+ function scrollToLocalFragment(rawId) {
3313
+ var id = typeof rawId === 'string' ? rawId : '';
3314
+ try { id = decodeURIComponent(id); } catch (ex) {}
3315
+ if (!id) {
3316
+ try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (ex) { window.scrollTo(0, 0); }
3317
+ return true;
3318
+ }
3319
+ var target = null;
3320
+ try { target = document.getElementById(id); } catch (ex) {}
3321
+ if (!target) {
3322
+ var named = document.getElementsByName(id);
3323
+ if (named && named.length) target = named[0];
3324
+ }
3325
+ if (!target) return false;
3326
+ try { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
3327
+ catch (ex) { target.scrollIntoView(); }
3328
+ return true;
3329
+ }
3330
+
3331
+ function navigableLinkHref(node) {
3332
+ var el = node && node.nodeType === 1 ? node : node && node.parentElement;
3333
+ if (!el || !el.closest) return '';
3334
+ var link = el.closest('a,area');
3335
+ if (!link) return '';
3336
+ var raw = link.getAttribute('href');
3337
+ // SVG anchors may only carry xlink:href.
3338
+ if (typeof raw !== 'string') raw = link.getAttribute('xlink:href');
3339
+ return typeof raw === 'string' ? raw.trim() : '';
3340
+ }
3341
+
3342
+ if (!LIVE) {
3343
+ document.addEventListener('click', function(e) {
3344
+ if (e.defaultPrevented || e.button !== 0) return;
3345
+ if (isViewerOverlayNode(e.target)) return; // markers own their clicks
3346
+ var raw = navigableLinkHref(e.target);
3347
+ if (!raw) return;
3348
+ // The page's own scripting, not a navigation: leave it alone.
3349
+ if (/^javascript:/i.test(raw)) return;
3350
+ if (raw.charAt(0) === '#') {
3351
+ e.preventDefault();
3352
+ scrollToLocalFragment(raw.slice(1));
3353
+ return;
3354
+ }
3355
+ e.preventDefault();
3356
+ // Armed pinpoint: the click belongs to annotation, and the capture-phase
3357
+ // pinpoint handler below is about to pin this element. Navigation is
3358
+ // already suppressed above, which is all this surface owes the click.
3359
+ if (annotateModeActive && currentInputMethod === 'pinpoint') return;
3360
+ postToParent({ type: PREFIX + 'link-click', href: raw.slice(0, 2048) });
3361
+ }, true);
3362
+ }
3363
+
3283
3364
  document.addEventListener('click', function(e) {
3284
3365
  if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
3285
3366
  // Real placed markers (and any other viewer overlay) own their clicks —
@@ -3355,15 +3436,21 @@
3355
3436
  }
3356
3437
  });
3357
3438
 
3358
- // Mod+Shift+A toggles Interact/Annotate from inside the iframe (the parent
3359
- // registers the same chord, but focus usually lives in here on live apps).
3360
- // Capture phase so the page cannot swallow the reserved chord; the parent
3361
- // answers with set-annotate-mode.
3439
+ // The two reserved header chords, mirrored from inside the iframe (the
3440
+ // parent registers both, but focus usually lives in here on live apps):
3441
+ // Mod+Shift+A toggles Interact/Annotate, Mod+Shift+X shows/hides the
3442
+ // floating tools over the page. Capture phase so the page cannot swallow
3443
+ // them; the parent owns both states and answers annotate with
3444
+ // set-annotate-mode. Disarming tears down any pending draft through that
3445
+ // same set-annotate-mode(false) handler, exactly as Esc does.
3362
3446
  document.addEventListener('keydown', function(e) {
3363
3447
  if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return;
3364
- if (e.key !== 'a' && e.key !== 'A') return;
3448
+ var message = null;
3449
+ if (e.key === 'a' || e.key === 'A') message = 'annotate-toggle';
3450
+ else if (e.key === 'x' || e.key === 'X') message = 'tools-toggle';
3451
+ if (!message) return;
3365
3452
  e.preventDefault();
3366
- postToParent({ type: PREFIX + 'annotate-toggle' });
3453
+ postToParent({ type: PREFIX + message });
3367
3454
  }, true);
3368
3455
 
3369
3456
  // Author opt-in: a plain click on any element tagged [data-annotate] pops the
@@ -690,6 +690,13 @@ export const BRIDGE_SCRIPT = `(function() {
690
690
  scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
691
691
  }
692
692
 
693
+ else if (type === PREFIX + 'scroll-to-fragment') {
694
+ // A linked document opened from an in-page link carried a #fragment.
695
+ // The srcdoc document has no URL of its own, so the parent cannot set
696
+ // one: it replays the fragment here once the new document is ready.
697
+ scrollToLocalFragment(typeof e.data.fragment === 'string' ? e.data.fragment : '');
698
+ }
699
+
693
700
  else if (type === PREFIX + 'focus-mark') {
694
701
  focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
695
702
  }
@@ -2741,8 +2748,9 @@ export const BRIDGE_SCRIPT = `(function() {
2741
2748
  // Constraints (the "smart" part is that they adapt to the element):
2742
2749
  // - attributes are an ALLOWLIST (a page cannot add a key); no form values,
2743
2750
  // 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;
2751
+ // - href/src URLs lose their query and fragment (tokens live there),
2752
+ // relative ones included, and data: URIs keep only their media-type
2753
+ // prefix;
2746
2754
  // - the outline tries two levels of children, falls back to one, then to a
2747
2755
  // per-tag count, whichever first fits CTX_MAX_OUTLINE, so a click on a
2748
2756
  // whole <main> costs the same bytes as a click on a chip;
@@ -2792,7 +2800,10 @@ export const BRIDGE_SCRIPT = `(function() {
2792
2800
  }
2793
2801
 
2794
2802
  // URL attribute values: keep what locates the element in source, drop
2795
- // what identifies the user. Relative URLs are route state and stay whole.
2803
+ // what identifies the user. The path survives in every form; the query and
2804
+ // the fragment never do — a relative URL carries the same per-visit state an
2805
+ // absolute one does (session ids, and the implicit-flow tokens that live in
2806
+ // the fragment specifically), so it is scrubbed the same way.
2796
2807
  function ctxScrubUrl(value) {
2797
2808
  var v = String(value).trim();
2798
2809
  if (/^javascript:/i.test(v)) return null;
@@ -2806,6 +2817,8 @@ export const BRIDGE_SCRIPT = `(function() {
2806
2817
  return u.origin + u.pathname + (u.search || u.hash ? '?…' : '');
2807
2818
  } catch (ex) { return ctxTruncate(v, CTX_MAX_ATTR_VALUE); }
2808
2819
  }
2820
+ var mark = v.search(/[?#]/);
2821
+ if (mark >= 0) return v.slice(0, mark) + '?…';
2809
2822
  return v;
2810
2823
  }
2811
2824
 
@@ -3504,6 +3517,74 @@ export const BRIDGE_SCRIPT = `(function() {
3504
3517
  return true;
3505
3518
  }
3506
3519
 
3520
+ // --- Local-site link navigation (srcdoc sessions only) ---
3521
+ // A srcdoc document has no URL of its own: its base URL is the PARENT page's,
3522
+ // which is the Plannotator server. So a plain link to 02-detail.html resolves
3523
+ // to http://localhost:<port>/02-detail.html, the server's catch-all answers
3524
+ // with the app itself, and the whole editor renders inside the annotated
3525
+ // frame. An in-page #section link is a cross-document navigation for the
3526
+ // same reason.
3527
+ //
3528
+ // The frame therefore never navigates itself. In-page fragments scroll here;
3529
+ // everything else is handed to the parent, which owns resolution against the
3530
+ // current document's directory and is the trust boundary for the href.
3531
+ // Registered BEFORE the pinpoint handler and never stopping propagation, so
3532
+ // an armed click still pins the link element exactly as it always did.
3533
+ //
3534
+ // Live app sessions are excluded outright: they navigate a real origin
3535
+ // through the proxy, which is the whole point of that surface.
3536
+ function scrollToLocalFragment(rawId) {
3537
+ var id = typeof rawId === 'string' ? rawId : '';
3538
+ try { id = decodeURIComponent(id); } catch (ex) {}
3539
+ if (!id) {
3540
+ try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (ex) { window.scrollTo(0, 0); }
3541
+ return true;
3542
+ }
3543
+ var target = null;
3544
+ try { target = document.getElementById(id); } catch (ex) {}
3545
+ if (!target) {
3546
+ var named = document.getElementsByName(id);
3547
+ if (named && named.length) target = named[0];
3548
+ }
3549
+ if (!target) return false;
3550
+ try { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
3551
+ catch (ex) { target.scrollIntoView(); }
3552
+ return true;
3553
+ }
3554
+
3555
+ function navigableLinkHref(node) {
3556
+ var el = node && node.nodeType === 1 ? node : node && node.parentElement;
3557
+ if (!el || !el.closest) return '';
3558
+ var link = el.closest('a,area');
3559
+ if (!link) return '';
3560
+ var raw = link.getAttribute('href');
3561
+ // SVG anchors may only carry xlink:href.
3562
+ if (typeof raw !== 'string') raw = link.getAttribute('xlink:href');
3563
+ return typeof raw === 'string' ? raw.trim() : '';
3564
+ }
3565
+
3566
+ if (!LIVE) {
3567
+ document.addEventListener('click', function(e) {
3568
+ if (e.defaultPrevented || e.button !== 0) return;
3569
+ if (isViewerOverlayNode(e.target)) return; // markers own their clicks
3570
+ var raw = navigableLinkHref(e.target);
3571
+ if (!raw) return;
3572
+ // The page's own scripting, not a navigation: leave it alone.
3573
+ if (/^javascript:/i.test(raw)) return;
3574
+ if (raw.charAt(0) === '#') {
3575
+ e.preventDefault();
3576
+ scrollToLocalFragment(raw.slice(1));
3577
+ return;
3578
+ }
3579
+ e.preventDefault();
3580
+ // Armed pinpoint: the click belongs to annotation, and the capture-phase
3581
+ // pinpoint handler below is about to pin this element. Navigation is
3582
+ // already suppressed above, which is all this surface owes the click.
3583
+ if (annotateModeActive && currentInputMethod === 'pinpoint') return;
3584
+ postToParent({ type: PREFIX + 'link-click', href: raw.slice(0, 2048) });
3585
+ }, true);
3586
+ }
3587
+
3507
3588
  document.addEventListener('click', function(e) {
3508
3589
  if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
3509
3590
  // Real placed markers (and any other viewer overlay) own their clicks —
@@ -3579,15 +3660,21 @@ export const BRIDGE_SCRIPT = `(function() {
3579
3660
  }
3580
3661
  });
3581
3662
 
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.
3663
+ // The two reserved header chords, mirrored from inside the iframe (the
3664
+ // parent registers both, but focus usually lives in here on live apps):
3665
+ // Mod+Shift+A toggles Interact/Annotate, Mod+Shift+X shows/hides the
3666
+ // floating tools over the page. Capture phase so the page cannot swallow
3667
+ // them; the parent owns both states and answers annotate with
3668
+ // set-annotate-mode. Disarming tears down any pending draft through that
3669
+ // same set-annotate-mode(false) handler, exactly as Esc does.
3586
3670
  document.addEventListener('keydown', function(e) {
3587
3671
  if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return;
3588
- if (e.key !== 'a' && e.key !== 'A') return;
3672
+ var message = null;
3673
+ if (e.key === 'a' || e.key === 'A') message = 'annotate-toggle';
3674
+ else if (e.key === 'x' || e.key === 'X') message = 'tools-toggle';
3675
+ if (!message) return;
3589
3676
  e.preventDefault();
3590
- postToParent({ type: PREFIX + 'annotate-toggle' });
3677
+ postToParent({ type: PREFIX + message });
3591
3678
  }, true);
3592
3679
 
3593
3680
  // 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);