@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.
- package/HANDOFF.md +3 -2
- package/README.md +1 -1
- package/components/DecisionControl.tsx +0 -10
- package/components/html-viewer/bridge-script.asset.js +440 -3
- package/components/html-viewer/bridge-script.ts +440 -3
- package/components/html-viewer/useHtmlAnnotation.ts +175 -1
- package/package.json +1 -1
- package/styles.css +1 -1
- package/types.ts +45 -0
- package/utils/decisionSpec.ts +1 -4
- package/utils/parser.ts +135 -4
|
@@ -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
|
);
|