@plannotator/ui 0.38.2 → 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.
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useCallback, useRef, type RefObject } from "react";
2
- import { AnnotationType, type Annotation, type EditorMode, type HtmlAnnotationTarget, type HtmlElementAnchor, type ImageAttachment } from "../../types";
2
+ import { AnnotationType, type Annotation, type EditorMode, type HtmlAnnotationTarget, type HtmlElementAnchor, type HtmlElementContext, type ImageAttachment } from "../../types";
3
3
  import { THUMBS_UP_LABEL, type QuickLabel } from "../../utils/quickLabels";
4
4
  import { getIdentity } from "../../utils/identity";
5
5
  import type {
@@ -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
 
@@ -83,6 +88,8 @@ interface BridgeSelectionMessage {
83
88
  targetKey?: string;
84
89
  /** Semantic label from the pinpoint hover cascade (chips + export). */
85
90
  targetLabel?: string;
91
+ /** Agent-facing element description (pinpoint clicks) — validated, size-capped. */
92
+ context?: HtmlElementContext;
86
93
  }
87
94
 
88
95
  /** One draft target in an in-flight multi-select comment. Index 0 is primary. */
@@ -91,6 +98,7 @@ export interface HtmlDraftTarget {
91
98
  label?: string;
92
99
  text: string;
93
100
  anchor: HtmlElementAnchor | null;
101
+ context?: HtmlElementContext;
94
102
  }
95
103
 
96
104
  interface BridgeMultiTargetAddedMessage {
@@ -99,6 +107,7 @@ interface BridgeMultiTargetAddedMessage {
99
107
  label?: string;
100
108
  text: string;
101
109
  anchor?: HtmlElementAnchor;
110
+ context?: HtmlElementContext;
102
111
  }
103
112
 
104
113
  interface BridgeRect {
@@ -119,7 +128,8 @@ type BridgeMessage =
119
128
  | { type: `${typeof PREFIX}mark-click`; id: string }
120
129
  | { type: `${typeof PREFIX}unanchored`; ids: string[] }
121
130
  | { type: `${typeof PREFIX}resize`; height: number }
122
- | { type: `${typeof PREFIX}page-change`; pageUrl: string };
131
+ | { type: `${typeof PREFIX}page-change`; pageUrl: string }
132
+ | { type: `${typeof PREFIX}link-click`; href: string };
123
133
 
124
134
  /** Live proxied-app session credentials: the proxy origin messages must come
125
135
  * from, and the per-session token every message must echo. */
@@ -128,8 +138,10 @@ export interface HtmlLiveSession {
128
138
  token: string;
129
139
  }
130
140
 
131
- /** Cap for live-mode page identity strings (mirrors the bridge's slice). */
132
- 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]/;
133
145
 
134
146
  /** True when a live-session message event fails the origin or token check.
135
147
  * Exported for protocol tests. */
@@ -178,6 +190,12 @@ export interface UseHtmlAnnotationOptions {
178
190
  * the bridge on arm-multi-select so the in-page toggle stops at the
179
191
  * same number. Absent: the package's 16, and the arm message is unchanged. */
180
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;
181
199
  /** scrollIntoView behavior for scroll-to (selecting an annotation).
182
200
  * Absent: smooth, as before; pass 'auto' to honor reduced motion. */
183
201
  scrollBehavior?: 'smooth' | 'auto';
@@ -310,6 +328,15 @@ function parseTargetLabel(value: unknown): string | undefined {
310
328
  : collapsed;
311
329
  }
312
330
 
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
+ };
339
+
313
340
  function parseBridgeRect(value: unknown): BridgeRect | null {
314
341
  if (!isRecord(value)) return null;
315
342
  const { top, left, width, height } = value;
@@ -338,6 +365,7 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
338
365
  pinpoint: value.pinpoint === true,
339
366
  targetKey: parseTargetKey(value.targetKey) ?? undefined,
340
367
  targetLabel: parseTargetLabel(value.targetLabel),
368
+ context: parseHtmlElementContext(value.context),
341
369
  };
342
370
  }
343
371
  case `${PREFIX}multi-target-added`: {
@@ -349,6 +377,7 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
349
377
  label: parseTargetLabel(value.label),
350
378
  text: capSelectionText(value.text),
351
379
  anchor: parseHtmlElementAnchor(value.anchor) ?? undefined,
380
+ context: parseHtmlElementContext(value.context),
352
381
  };
353
382
  }
354
383
  case `${PREFIX}multi-target-removed`: {
@@ -393,6 +422,16 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
393
422
  return typeof value.height === "number" && Number.isFinite(value.height)
394
423
  ? { type: value.type, height: value.height }
395
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
+ }
396
435
  case `${PREFIX}page-change`:
397
436
  // Live-mode SPA navigation report. Bounded like every bridge string.
398
437
  return typeof value.pageUrl === "string"
@@ -421,6 +460,7 @@ export function useHtmlAnnotation({
421
460
  onResize,
422
461
  live,
423
462
  onPageChange,
463
+ onLinkClick,
424
464
  onBridgePointer,
425
465
  onUnanchoredChange,
426
466
  maxAdditionalTargets,
@@ -455,6 +495,7 @@ export function useHtmlAnnotation({
455
495
  // Element anchor for the pending pinpoint selection — committed onto the
456
496
  // annotation so restoration can resolve the exact element again.
457
497
  const pendingAnchorRef = useRef<HtmlElementAnchor | null>(null);
498
+ const pendingContextRef = useRef<HtmlElementContext | null>(null);
458
499
  const draftTargetsRef = useRef<HtmlDraftTarget[]>(draftTargets);
459
500
  draftTargetsRef.current = draftTargets;
460
501
  const onBridgePointerRef = useRef(onBridgePointer);
@@ -493,6 +534,8 @@ export function useHtmlAnnotation({
493
534
  liveRef.current = live ?? null;
494
535
  const onPageChangeRef = useRef(onPageChange);
495
536
  onPageChangeRef.current = onPageChange;
537
+ const onLinkClickRef = useRef(onLinkClick);
538
+ onLinkClickRef.current = onLinkClick;
496
539
  // The effective cap and whether the host set one: only an explicit cap
497
540
  // rides on arm-multi-select, so an unconfigured viewer posts today's message.
498
541
  const maxTargetsRef = useRef(resolveMaxAdditionalTargets(maxAdditionalTargets));
@@ -534,6 +577,7 @@ export function useHtmlAnnotation({
534
577
  setCommentPopover(null);
535
578
  pendingTextRef.current = "";
536
579
  pendingAnchorRef.current = null;
580
+ pendingContextRef.current = null;
537
581
  return;
538
582
  }
539
583
  if (index === 0) {
@@ -542,6 +586,7 @@ export function useHtmlAnnotation({
542
586
  const next = remaining[0]!;
543
587
  pendingTextRef.current = next.text;
544
588
  pendingAnchorRef.current = next.anchor;
589
+ pendingContextRef.current = next.context ?? null;
545
590
  setCommentPopover((prev) =>
546
591
  prev ? { ...prev, contextText: next.text, selectedText: next.text } : prev,
547
592
  );
@@ -603,6 +648,8 @@ export function useHtmlAnnotation({
603
648
  && type !== `${PREFIX}resize`
604
649
  // Page identity is navigation state, not an annotation mutation.
605
650
  && type !== `${PREFIX}page-change`
651
+ // Following a link is a read action; a read-only document still navigates.
652
+ && type !== `${PREFIX}link-click`
606
653
  ) {
607
654
  return;
608
655
  }
@@ -610,6 +657,7 @@ export function useHtmlAnnotation({
610
657
  if (type === `${PREFIX}selection`) {
611
658
  pendingTextRef.current = message.text;
612
659
  pendingAnchorRef.current = message.anchor ?? null;
660
+ pendingContextRef.current = message.context ?? null;
613
661
  setDraftTargets([]); // a new selection always starts a fresh draft
614
662
  const anchor = positionAnchor(message.rect);
615
663
  if (!anchor) return;
@@ -654,6 +702,7 @@ export function useHtmlAnnotation({
654
702
  label: message.targetLabel,
655
703
  text: message.text,
656
704
  anchor: message.anchor ?? null,
705
+ context: message.context,
657
706
  },
658
707
  ]);
659
708
  post({
@@ -689,6 +738,7 @@ export function useHtmlAnnotation({
689
738
  label: message.label,
690
739
  text: message.text,
691
740
  anchor: message.anchor ?? null,
741
+ context: message.context,
692
742
  },
693
743
  ]);
694
744
  setComposerFocusToken((t) => t + 1);
@@ -711,6 +761,7 @@ export function useHtmlAnnotation({
711
761
  if (!commentPopoverRef.current && !quickLabelPickerRef.current) {
712
762
  pendingTextRef.current = "";
713
763
  pendingAnchorRef.current = null;
764
+ pendingContextRef.current = null;
714
765
  }
715
766
  }
716
767
 
@@ -765,6 +816,10 @@ export function useHtmlAnnotation({
765
816
  if (type === `${PREFIX}page-change`) {
766
817
  onPageChangeRef.current?.(message.pageUrl);
767
818
  }
819
+
820
+ if (type === `${PREFIX}link-click`) {
821
+ onLinkClickRef.current?.(message.href);
822
+ }
768
823
  }
769
824
 
770
825
  window.addEventListener("message", handler);
@@ -826,11 +881,13 @@ export function useHtmlAnnotation({
826
881
  author: getIdentity(),
827
882
  createdA: Date.now(),
828
883
  htmlAnchor: pendingAnchorRef.current ?? undefined,
884
+ elementContext: pendingContextRef.current ?? undefined,
829
885
  });
830
886
 
831
887
  setToolbarState(null);
832
888
  pendingTextRef.current = "";
833
889
  pendingAnchorRef.current = null;
890
+ pendingContextRef.current = null;
834
891
  },
835
892
  [post],
836
893
  );
@@ -869,6 +926,7 @@ export function useHtmlAnnotation({
869
926
  label: t.label,
870
927
  text: t.text,
871
928
  anchor: t.anchor ?? undefined,
929
+ ...(t.context ? { context: t.context } : {}),
872
930
  }))
873
931
  : undefined;
874
932
 
@@ -887,6 +945,7 @@ export function useHtmlAnnotation({
887
945
  createdA: Date.now(),
888
946
  images,
889
947
  htmlAnchor: pendingAnchorRef.current ?? undefined,
948
+ elementContext: pendingContextRef.current ?? undefined,
890
949
  htmlAdditionalTargets: additionalTargets,
891
950
  });
892
951
 
@@ -894,6 +953,7 @@ export function useHtmlAnnotation({
894
953
  setDraftTargets([]);
895
954
  pendingTextRef.current = "";
896
955
  pendingAnchorRef.current = null;
956
+ pendingContextRef.current = null;
897
957
  },
898
958
  [post],
899
959
  );
@@ -915,6 +975,7 @@ export function useHtmlAnnotation({
915
975
  label: t.label,
916
976
  text: t.text,
917
977
  anchor: t.anchor ?? undefined,
978
+ ...(t.context ? { context: t.context } : {}),
918
979
  }))
919
980
  : undefined;
920
981
 
@@ -934,6 +995,7 @@ export function useHtmlAnnotation({
934
995
  author: getIdentity(),
935
996
  createdA: Date.now(),
936
997
  htmlAnchor: pendingAnchorRef.current ?? undefined,
998
+ elementContext: pendingContextRef.current ?? undefined,
937
999
  htmlAdditionalTargets: additionalTargets,
938
1000
  });
939
1001
 
@@ -941,6 +1003,7 @@ export function useHtmlAnnotation({
941
1003
  setDraftTargets([]);
942
1004
  pendingTextRef.current = "";
943
1005
  pendingAnchorRef.current = null;
1006
+ pendingContextRef.current = null;
944
1007
  }, [post]);
945
1008
 
946
1009
  const handleCommentClose = useCallback(() => {
@@ -949,6 +1012,7 @@ export function useHtmlAnnotation({
949
1012
  setDraftTargets([]);
950
1013
  pendingTextRef.current = "";
951
1014
  pendingAnchorRef.current = null;
1015
+ pendingContextRef.current = null;
952
1016
  }, [post]);
953
1017
 
954
1018
  const removeDraftTarget = useCallback(
@@ -971,6 +1035,7 @@ export function useHtmlAnnotation({
971
1035
  setToolbarState(null);
972
1036
  pendingTextRef.current = "";
973
1037
  pendingAnchorRef.current = null;
1038
+ pendingContextRef.current = null;
974
1039
  }, [post]);
975
1040
 
976
1041
  const applyQuickLabel = useCallback(
@@ -994,10 +1059,12 @@ export function useHtmlAnnotation({
994
1059
  author: getIdentity(),
995
1060
  createdA: Date.now(),
996
1061
  htmlAnchor: pendingAnchorRef.current ?? undefined,
1062
+ elementContext: pendingContextRef.current ?? undefined,
997
1063
  });
998
1064
  clearState();
999
1065
  pendingTextRef.current = "";
1000
1066
  pendingAnchorRef.current = null;
1067
+ pendingContextRef.current = null;
1001
1068
  },
1002
1069
  [post],
1003
1070
  );