@plannotator/ui 0.38.2 → 0.39.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 {
@@ -83,6 +83,8 @@ interface BridgeSelectionMessage {
83
83
  targetKey?: string;
84
84
  /** Semantic label from the pinpoint hover cascade (chips + export). */
85
85
  targetLabel?: string;
86
+ /** Agent-facing element description (pinpoint clicks) — validated, size-capped. */
87
+ context?: HtmlElementContext;
86
88
  }
87
89
 
88
90
  /** One draft target in an in-flight multi-select comment. Index 0 is primary. */
@@ -91,6 +93,7 @@ export interface HtmlDraftTarget {
91
93
  label?: string;
92
94
  text: string;
93
95
  anchor: HtmlElementAnchor | null;
96
+ context?: HtmlElementContext;
94
97
  }
95
98
 
96
99
  interface BridgeMultiTargetAddedMessage {
@@ -99,6 +102,7 @@ interface BridgeMultiTargetAddedMessage {
99
102
  label?: string;
100
103
  text: string;
101
104
  anchor?: HtmlElementAnchor;
105
+ context?: HtmlElementContext;
102
106
  }
103
107
 
104
108
  interface BridgeRect {
@@ -310,6 +314,155 @@ function parseTargetLabel(value: unknown): string | undefined {
310
314
  : collapsed;
311
315
  }
312
316
 
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
+ }
465
+
313
466
  function parseBridgeRect(value: unknown): BridgeRect | null {
314
467
  if (!isRecord(value)) return null;
315
468
  const { top, left, width, height } = value;
@@ -338,6 +491,7 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
338
491
  pinpoint: value.pinpoint === true,
339
492
  targetKey: parseTargetKey(value.targetKey) ?? undefined,
340
493
  targetLabel: parseTargetLabel(value.targetLabel),
494
+ context: parseHtmlElementContext(value.context),
341
495
  };
342
496
  }
343
497
  case `${PREFIX}multi-target-added`: {
@@ -349,6 +503,7 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
349
503
  label: parseTargetLabel(value.label),
350
504
  text: capSelectionText(value.text),
351
505
  anchor: parseHtmlElementAnchor(value.anchor) ?? undefined,
506
+ context: parseHtmlElementContext(value.context),
352
507
  };
353
508
  }
354
509
  case `${PREFIX}multi-target-removed`: {
@@ -455,6 +610,7 @@ export function useHtmlAnnotation({
455
610
  // Element anchor for the pending pinpoint selection — committed onto the
456
611
  // annotation so restoration can resolve the exact element again.
457
612
  const pendingAnchorRef = useRef<HtmlElementAnchor | null>(null);
613
+ const pendingContextRef = useRef<HtmlElementContext | null>(null);
458
614
  const draftTargetsRef = useRef<HtmlDraftTarget[]>(draftTargets);
459
615
  draftTargetsRef.current = draftTargets;
460
616
  const onBridgePointerRef = useRef(onBridgePointer);
@@ -534,6 +690,7 @@ export function useHtmlAnnotation({
534
690
  setCommentPopover(null);
535
691
  pendingTextRef.current = "";
536
692
  pendingAnchorRef.current = null;
693
+ pendingContextRef.current = null;
537
694
  return;
538
695
  }
539
696
  if (index === 0) {
@@ -542,6 +699,7 @@ export function useHtmlAnnotation({
542
699
  const next = remaining[0]!;
543
700
  pendingTextRef.current = next.text;
544
701
  pendingAnchorRef.current = next.anchor;
702
+ pendingContextRef.current = next.context ?? null;
545
703
  setCommentPopover((prev) =>
546
704
  prev ? { ...prev, contextText: next.text, selectedText: next.text } : prev,
547
705
  );
@@ -610,6 +768,7 @@ export function useHtmlAnnotation({
610
768
  if (type === `${PREFIX}selection`) {
611
769
  pendingTextRef.current = message.text;
612
770
  pendingAnchorRef.current = message.anchor ?? null;
771
+ pendingContextRef.current = message.context ?? null;
613
772
  setDraftTargets([]); // a new selection always starts a fresh draft
614
773
  const anchor = positionAnchor(message.rect);
615
774
  if (!anchor) return;
@@ -654,6 +813,7 @@ export function useHtmlAnnotation({
654
813
  label: message.targetLabel,
655
814
  text: message.text,
656
815
  anchor: message.anchor ?? null,
816
+ context: message.context,
657
817
  },
658
818
  ]);
659
819
  post({
@@ -689,6 +849,7 @@ export function useHtmlAnnotation({
689
849
  label: message.label,
690
850
  text: message.text,
691
851
  anchor: message.anchor ?? null,
852
+ context: message.context,
692
853
  },
693
854
  ]);
694
855
  setComposerFocusToken((t) => t + 1);
@@ -711,6 +872,7 @@ export function useHtmlAnnotation({
711
872
  if (!commentPopoverRef.current && !quickLabelPickerRef.current) {
712
873
  pendingTextRef.current = "";
713
874
  pendingAnchorRef.current = null;
875
+ pendingContextRef.current = null;
714
876
  }
715
877
  }
716
878
 
@@ -826,11 +988,13 @@ export function useHtmlAnnotation({
826
988
  author: getIdentity(),
827
989
  createdA: Date.now(),
828
990
  htmlAnchor: pendingAnchorRef.current ?? undefined,
991
+ elementContext: pendingContextRef.current ?? undefined,
829
992
  });
830
993
 
831
994
  setToolbarState(null);
832
995
  pendingTextRef.current = "";
833
996
  pendingAnchorRef.current = null;
997
+ pendingContextRef.current = null;
834
998
  },
835
999
  [post],
836
1000
  );
@@ -869,6 +1033,7 @@ export function useHtmlAnnotation({
869
1033
  label: t.label,
870
1034
  text: t.text,
871
1035
  anchor: t.anchor ?? undefined,
1036
+ ...(t.context ? { context: t.context } : {}),
872
1037
  }))
873
1038
  : undefined;
874
1039
 
@@ -887,6 +1052,7 @@ export function useHtmlAnnotation({
887
1052
  createdA: Date.now(),
888
1053
  images,
889
1054
  htmlAnchor: pendingAnchorRef.current ?? undefined,
1055
+ elementContext: pendingContextRef.current ?? undefined,
890
1056
  htmlAdditionalTargets: additionalTargets,
891
1057
  });
892
1058
 
@@ -894,6 +1060,7 @@ export function useHtmlAnnotation({
894
1060
  setDraftTargets([]);
895
1061
  pendingTextRef.current = "";
896
1062
  pendingAnchorRef.current = null;
1063
+ pendingContextRef.current = null;
897
1064
  },
898
1065
  [post],
899
1066
  );
@@ -915,6 +1082,7 @@ export function useHtmlAnnotation({
915
1082
  label: t.label,
916
1083
  text: t.text,
917
1084
  anchor: t.anchor ?? undefined,
1085
+ ...(t.context ? { context: t.context } : {}),
918
1086
  }))
919
1087
  : undefined;
920
1088
 
@@ -934,6 +1102,7 @@ export function useHtmlAnnotation({
934
1102
  author: getIdentity(),
935
1103
  createdA: Date.now(),
936
1104
  htmlAnchor: pendingAnchorRef.current ?? undefined,
1105
+ elementContext: pendingContextRef.current ?? undefined,
937
1106
  htmlAdditionalTargets: additionalTargets,
938
1107
  });
939
1108
 
@@ -941,6 +1110,7 @@ export function useHtmlAnnotation({
941
1110
  setDraftTargets([]);
942
1111
  pendingTextRef.current = "";
943
1112
  pendingAnchorRef.current = null;
1113
+ pendingContextRef.current = null;
944
1114
  }, [post]);
945
1115
 
946
1116
  const handleCommentClose = useCallback(() => {
@@ -949,6 +1119,7 @@ export function useHtmlAnnotation({
949
1119
  setDraftTargets([]);
950
1120
  pendingTextRef.current = "";
951
1121
  pendingAnchorRef.current = null;
1122
+ pendingContextRef.current = null;
952
1123
  }, [post]);
953
1124
 
954
1125
  const removeDraftTarget = useCallback(
@@ -971,6 +1142,7 @@ export function useHtmlAnnotation({
971
1142
  setToolbarState(null);
972
1143
  pendingTextRef.current = "";
973
1144
  pendingAnchorRef.current = null;
1145
+ pendingContextRef.current = null;
974
1146
  }, [post]);
975
1147
 
976
1148
  const applyQuickLabel = useCallback(
@@ -994,10 +1166,12 @@ export function useHtmlAnnotation({
994
1166
  author: getIdentity(),
995
1167
  createdA: Date.now(),
996
1168
  htmlAnchor: pendingAnchorRef.current ?? undefined,
1169
+ elementContext: pendingContextRef.current ?? undefined,
997
1170
  });
998
1171
  clearState();
999
1172
  pendingTextRef.current = "";
1000
1173
  pendingAnchorRef.current = null;
1174
+ pendingContextRef.current = null;
1001
1175
  },
1002
1176
  [post],
1003
1177
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.38.2",
3
+ "version": "0.39.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",