@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.
- package/HANDOFF.md +99 -9
- package/README.md +4 -4
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DecisionControl.tsx +0 -10
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +39 -5
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +25 -1
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +533 -9
- package/components/html-viewer/bridge-script.ts +533 -9
- package/components/html-viewer/useHtmlAnnotation.ts +71 -4
- package/hooks/useAnnotationHighlighter.ts +464 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +3 -3
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +45 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/decisionSpec.ts +1 -4
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +150 -4
- package/utils/terminalToolsAnnouncement.ts +76 -0
|
@@ -340,6 +340,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
340
340
|
var pendingPinKey = null; // target key for the primary pinpoint target (multi-select)
|
|
341
341
|
var pendingPinLabel = null; // semantic label captured for the primary target
|
|
342
342
|
var pendingPinPoint = null; // normalized {x,y} click point inside the pinned element's rect
|
|
343
|
+
var pendingPinContext = null; // agent-facing element context for the primary target (see buildElementContext)
|
|
343
344
|
var pendingPinViaPinpoint = false; // pinpoint drafts survive scroll-out (see postSelectionRect)
|
|
344
345
|
// Multi-select is ARMED EXPLICITLY by the parent (arm-multi-select), and only
|
|
345
346
|
// when the comment composer owns the draft. The bridge must never accept a
|
|
@@ -466,6 +467,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
466
467
|
pinpoint: (extras && extras.pinpoint) || undefined,
|
|
467
468
|
targetKey: (extras && extras.targetKey) || undefined,
|
|
468
469
|
targetLabel: (extras && extras.targetLabel) || undefined,
|
|
470
|
+
context: (extras && extras.context) || undefined,
|
|
469
471
|
rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
|
|
470
472
|
});
|
|
471
473
|
renderAnnotationOverlay(); // draft selection highlight (overlay-projected)
|
|
@@ -688,6 +690,13 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
688
690
|
scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
|
|
689
691
|
}
|
|
690
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
|
+
|
|
691
700
|
else if (type === PREFIX + 'focus-mark') {
|
|
692
701
|
focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
|
|
693
702
|
}
|
|
@@ -2728,8 +2737,441 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2728
2737
|
return el;
|
|
2729
2738
|
}
|
|
2730
2739
|
|
|
2740
|
+
// --- Element context: a bounded, agent-facing description of a pinpointed
|
|
2741
|
+
// element, captured at click time (only the bridge can see the DOM). It is
|
|
2742
|
+
// purely descriptive and NEVER consulted by restore (that is the anchor's
|
|
2743
|
+
// job): the exported feedback prints it so an agent working in the app's
|
|
2744
|
+
// source can find the element without having the page. Everything here is
|
|
2745
|
+
// page-controlled, so the parent re-validates and re-caps it all at the
|
|
2746
|
+
// trust boundary; this side keeps the wire payload honest.
|
|
2747
|
+
//
|
|
2748
|
+
// Constraints (the "smart" part is that they adapt to the element):
|
|
2749
|
+
// - attributes are an ALLOWLIST (a page cannot add a key); no form values,
|
|
2750
|
+
// no on* handlers, no style, no script/style/template contents ever;
|
|
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;
|
|
2754
|
+
// - the outline tries two levels of children, falls back to one, then to a
|
|
2755
|
+
// per-tag count, whichever first fits CTX_MAX_OUTLINE, so a click on a
|
|
2756
|
+
// whole <main> costs the same bytes as a click on a chip;
|
|
2757
|
+
// - text is innerText (rendered words, not the DOM soup), word-boundary
|
|
2758
|
+
// truncated;
|
|
2759
|
+
// - the serialized context stays under a byte budget by shedding the
|
|
2760
|
+
// expendable fields in a fixed order; tag/id/role/name/page never shed.
|
|
2761
|
+
var CTX_MAX_BYTES = 2048; // primary target
|
|
2762
|
+
var CTX_MAX_BYTES_EXTRA = 1024; // each shift-click additional target
|
|
2763
|
+
var CTX_MAX_TEXT = 300;
|
|
2764
|
+
var CTX_MAX_OUTLINE = 600;
|
|
2765
|
+
var CTX_MAX_PATH_SEGMENTS = 8;
|
|
2766
|
+
var CTX_MAX_CLASSES = 8;
|
|
2767
|
+
var CTX_MAX_ATTRS = 10;
|
|
2768
|
+
var CTX_MAX_ATTR_VALUE = 120;
|
|
2769
|
+
var CTX_MAX_CHILDREN = 6;
|
|
2770
|
+
var CONTEXT_ATTRS = ['href', 'src', 'alt', 'title', 'type', 'name', 'role', 'placeholder', 'for', 'target', 'rel',
|
|
2771
|
+
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-current', 'aria-expanded', 'aria-hidden', 'aria-controls',
|
|
2772
|
+
'data-annotate', 'data-testid', 'data-test', 'data-test-id', 'data-cy', 'data-qa', 'data-component', 'data-id'];
|
|
2773
|
+
var CONTEXT_SKIP_TAGS = { SCRIPT: 1, STYLE: 1, TEMPLATE: 1, NOSCRIPT: 1 };
|
|
2774
|
+
var CONTEXT_IMPLICIT_ROLE = { NAV: 'navigation', MAIN: 'main', HEADER: 'banner', FOOTER: 'contentinfo', ASIDE: 'complementary',
|
|
2775
|
+
ARTICLE: 'article', SECTION: 'region', FORM: 'form', BUTTON: 'button', A: 'link', IMG: 'img', TABLE: 'table', UL: 'list',
|
|
2776
|
+
OL: 'list', LI: 'listitem', H1: 'heading', H2: 'heading', H3: 'heading', H4: 'heading', H5: 'heading', H6: 'heading',
|
|
2777
|
+
DIALOG: 'dialog', INPUT: 'textbox', TEXTAREA: 'textbox', SELECT: 'combobox', SUMMARY: 'button', PROGRESS: 'progressbar' };
|
|
2778
|
+
var CONTEXT_LANDMARK_SELECTOR = 'main,nav,header,footer,aside,article,section,form,dialog,[role]';
|
|
2779
|
+
|
|
2780
|
+
// Collapse control characters and whitespace runs; the parent does it again.
|
|
2781
|
+
function ctxCollapse(value, max) {
|
|
2782
|
+
var s = String(value == null ? '' : value).replace(/[\\x00-\\x1f\\x7f]+/g, ' ').replace(/\\s+/g, ' ').trim();
|
|
2783
|
+
return max ? ctxTruncate(s, max) : s;
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// Word-boundary truncation with an ellipsis, never splitting a surrogate pair.
|
|
2787
|
+
function ctxTruncate(s, max) {
|
|
2788
|
+
if (s.length <= max) return s;
|
|
2789
|
+
var cut = s.lastIndexOf(' ', max - 1);
|
|
2790
|
+
if (cut < max * 0.6) cut = max - 1;
|
|
2791
|
+
var last = s.charCodeAt(cut - 1);
|
|
2792
|
+
if (last >= 0xd800 && last <= 0xdbff) cut -= 1;
|
|
2793
|
+
return s.slice(0, cut) + '…';
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
function ctxBytes(value) {
|
|
2797
|
+
var json = JSON.stringify(value);
|
|
2798
|
+
try { return new TextEncoder().encode(json).length; } catch (ex) {}
|
|
2799
|
+
try { return unescape(encodeURIComponent(json)).length; } catch (ex2) { return json.length * 2; }
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
// URL attribute values: keep what locates the element in source, drop
|
|
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.
|
|
2807
|
+
function ctxScrubUrl(value) {
|
|
2808
|
+
var v = String(value).trim();
|
|
2809
|
+
if (/^javascript:/i.test(v)) return null;
|
|
2810
|
+
if (/^data:/i.test(v)) {
|
|
2811
|
+
var comma = v.indexOf(',');
|
|
2812
|
+
return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
|
|
2813
|
+
}
|
|
2814
|
+
if (/^https?:\\/\\//i.test(v)) {
|
|
2815
|
+
try {
|
|
2816
|
+
var u = new URL(v);
|
|
2817
|
+
return u.origin + u.pathname + (u.search || u.hash ? '?…' : '');
|
|
2818
|
+
} catch (ex) { return ctxTruncate(v, CTX_MAX_ATTR_VALUE); }
|
|
2819
|
+
}
|
|
2820
|
+
var mark = v.search(/[?#]/);
|
|
2821
|
+
if (mark >= 0) return v.slice(0, mark) + '?…';
|
|
2822
|
+
return v;
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
function ctxAttrs(el) {
|
|
2826
|
+
var out = [];
|
|
2827
|
+
if (!el.getAttribute) return out;
|
|
2828
|
+
for (var i = 0; i < CONTEXT_ATTRS.length && out.length < CTX_MAX_ATTRS; i++) {
|
|
2829
|
+
var name = CONTEXT_ATTRS[i];
|
|
2830
|
+
var value = el.getAttribute(name);
|
|
2831
|
+
if (value == null) continue;
|
|
2832
|
+
if (name === 'href' || name === 'src') {
|
|
2833
|
+
value = ctxScrubUrl(value);
|
|
2834
|
+
if (value == null) continue;
|
|
2835
|
+
}
|
|
2836
|
+
out.push([name, ctxCollapse(value, CTX_MAX_ATTR_VALUE)]);
|
|
2837
|
+
}
|
|
2838
|
+
return out;
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
function ctxClasses(el) {
|
|
2842
|
+
var out = [];
|
|
2843
|
+
if (!el.classList) return out;
|
|
2844
|
+
for (var i = 0; i < el.classList.length && out.length < CTX_MAX_CLASSES; i++) {
|
|
2845
|
+
var cls = String(el.classList[i]);
|
|
2846
|
+
if (!cls || isLikelyGeneratedClass(cls)) continue;
|
|
2847
|
+
out.push(ctxTruncate(cls, 48));
|
|
2848
|
+
}
|
|
2849
|
+
if (el.classList.length > out.length && out.length === CTX_MAX_CLASSES) out.push('+' + (el.classList.length - out.length) + ' more');
|
|
2850
|
+
return out;
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
function ctxFirstClass(el) {
|
|
2854
|
+
if (!el.classList) return null;
|
|
2855
|
+
for (var i = 0; i < el.classList.length; i++) {
|
|
2856
|
+
var cls = String(el.classList[i]);
|
|
2857
|
+
if (cls && !isLikelyGeneratedClass(cls)) return ctxTruncate(cls, 48);
|
|
2858
|
+
}
|
|
2859
|
+
return null;
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
function ctxRole(el) {
|
|
2863
|
+
var explicit = el.getAttribute && el.getAttribute('role');
|
|
2864
|
+
if (explicit && explicit.trim()) return ctxCollapse(explicit, 32);
|
|
2865
|
+
if (el.tagName === 'A' && !(el.getAttribute && el.getAttribute('href'))) return null;
|
|
2866
|
+
return CONTEXT_IMPLICIT_ROLE[el.tagName] || null;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
function ctxText(el) {
|
|
2870
|
+
var raw = typeof el.innerText === 'string' && el.innerText ? el.innerText : (el.textContent || '');
|
|
2871
|
+
return ctxCollapse(raw, CTX_MAX_TEXT);
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
// Accessible name, in the practical order: aria-label, aria-labelledby,
|
|
2875
|
+
// alt, title, a <label for>, then the element's own short text.
|
|
2876
|
+
function ctxName(el) {
|
|
2877
|
+
if (!el.getAttribute) return null;
|
|
2878
|
+
var aria = el.getAttribute('aria-label');
|
|
2879
|
+
if (aria && aria.trim()) return ctxCollapse(aria, 120);
|
|
2880
|
+
var by = el.getAttribute('aria-labelledby');
|
|
2881
|
+
if (by && by.trim()) {
|
|
2882
|
+
var ids = by.trim().split(/\\s+/);
|
|
2883
|
+
var parts = [];
|
|
2884
|
+
for (var i = 0; i < ids.length && i < 4; i++) {
|
|
2885
|
+
var ref = document.getElementById(ids[i]);
|
|
2886
|
+
if (ref) parts.push(ref.textContent || '');
|
|
2887
|
+
}
|
|
2888
|
+
var joined = ctxCollapse(parts.join(' '), 120);
|
|
2889
|
+
if (joined) return joined;
|
|
2890
|
+
}
|
|
2891
|
+
var alt = el.getAttribute('alt');
|
|
2892
|
+
if (alt && alt.trim()) return ctxCollapse(alt, 120);
|
|
2893
|
+
var title = el.getAttribute('title');
|
|
2894
|
+
if (title && title.trim()) return ctxCollapse(title, 120);
|
|
2895
|
+
if (el.id && typeof CSS !== 'undefined' && CSS.escape) {
|
|
2896
|
+
var label = null;
|
|
2897
|
+
try { label = document.querySelector('label[for="' + CSS.escape(el.id) + '"]'); } catch (ex) {}
|
|
2898
|
+
if (label) {
|
|
2899
|
+
var labelText = ctxCollapse(label.textContent, 120);
|
|
2900
|
+
if (labelText) return labelText;
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
var own = ctxCollapse(el.textContent, 120);
|
|
2904
|
+
return own && own.length <= 80 ? own : null;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// One path segment: tag, then the strongest cheap identity it carries, and
|
|
2908
|
+
// a position only when a same-tag sibling would otherwise read the same.
|
|
2909
|
+
function ctxSegment(el) {
|
|
2910
|
+
var seg = el.tagName.toLowerCase();
|
|
2911
|
+
if (el.id) return seg + '#' + ctxCollapse(el.id, 60);
|
|
2912
|
+
var cls = ctxFirstClass(el);
|
|
2913
|
+
if (cls) seg += '.' + cls;
|
|
2914
|
+
var hook = el.getAttribute && (el.getAttribute('data-testid') || el.getAttribute('data-component'));
|
|
2915
|
+
if (hook) seg += '[' + (el.getAttribute('data-testid') ? 'data-testid' : 'data-component') + '="' + ctxCollapse(hook, 40) + '"]';
|
|
2916
|
+
// A position only when a same-tag sibling would read identically (same
|
|
2917
|
+
// first class, same hook): p.beta among p.alpha / p.gamma needs none,
|
|
2918
|
+
// the fourth of forty identical <li class="row"> does.
|
|
2919
|
+
var parent = el.parentElement;
|
|
2920
|
+
if (parent) {
|
|
2921
|
+
var same = 0;
|
|
2922
|
+
var index = 0;
|
|
2923
|
+
var typeIndex = 0;
|
|
2924
|
+
var typeCount = 0;
|
|
2925
|
+
for (var i = 0; i < parent.children.length; i++) {
|
|
2926
|
+
var sibling = parent.children[i];
|
|
2927
|
+
if (sibling.tagName !== el.tagName) continue;
|
|
2928
|
+
typeCount++;
|
|
2929
|
+
if (sibling === el) typeIndex = typeCount;
|
|
2930
|
+
if (sibling.id) continue;
|
|
2931
|
+
if (ctxFirstClass(sibling) === cls && !!(sibling.getAttribute && (sibling.getAttribute('data-testid') || sibling.getAttribute('data-component'))) === !!hook) {
|
|
2932
|
+
same++;
|
|
2933
|
+
if (sibling === el) index = same;
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
if (same > 1) seg += ':nth-of-type(' + typeIndex + ')';
|
|
2937
|
+
}
|
|
2938
|
+
return seg;
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
function ctxPath(el) {
|
|
2942
|
+
var parts = [];
|
|
2943
|
+
var current = el;
|
|
2944
|
+
while (current && current.nodeType === 1 && current !== document.documentElement) {
|
|
2945
|
+
if (parts.length >= CTX_MAX_PATH_SEGMENTS) { parts.unshift('…'); break; }
|
|
2946
|
+
parts.unshift(ctxSegment(current));
|
|
2947
|
+
if (current === document.body) break;
|
|
2948
|
+
current = current.parentElement;
|
|
2949
|
+
}
|
|
2950
|
+
return parts.join(' > ');
|
|
2951
|
+
}
|
|
2952
|
+
|
|
2953
|
+
function ctxIsSkipped(node) {
|
|
2954
|
+
return !node || node.nodeType !== 1 || CONTEXT_SKIP_TAGS[node.tagName] || isViewerOverlayNode(node);
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
function ctxElementChildren(el) {
|
|
2958
|
+
var kids = [];
|
|
2959
|
+
for (var i = 0; i < el.children.length; i++) {
|
|
2960
|
+
if (!ctxIsSkipped(el.children[i])) kids.push(el.children[i]);
|
|
2961
|
+
}
|
|
2962
|
+
return kids;
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
function ctxTagCounts(nodes) {
|
|
2966
|
+
var counts = {};
|
|
2967
|
+
var order = [];
|
|
2968
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
2969
|
+
var tag = nodes[i].tagName.toLowerCase();
|
|
2970
|
+
if (!counts[tag]) { counts[tag] = 0; order.push(tag); }
|
|
2971
|
+
counts[tag]++;
|
|
2972
|
+
}
|
|
2973
|
+
var out = [];
|
|
2974
|
+
for (var j = 0; j < order.length && j < 4; j++) out.push(order[j] + '×' + counts[order[j]]);
|
|
2975
|
+
if (order.length > 4) out.push('…');
|
|
2976
|
+
return out.join(', ');
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
function ctxAttrString(pairs) {
|
|
2980
|
+
var s = '';
|
|
2981
|
+
for (var i = 0; i < pairs.length; i++) s += ' ' + pairs[i][0] + '="' + pairs[i][1].replace(/"/g, '"') + '"';
|
|
2982
|
+
return s;
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
// id and up to four author classes on an opening tag: the grep keys, first.
|
|
2986
|
+
function ctxIdentityString(el) {
|
|
2987
|
+
var s = '';
|
|
2988
|
+
if (el.id) s += ' id="' + ctxCollapse(el.id, 60) + '"';
|
|
2989
|
+
var classes = [];
|
|
2990
|
+
if (el.classList) {
|
|
2991
|
+
for (var i = 0; i < el.classList.length && classes.length < 4; i++) {
|
|
2992
|
+
var cls = String(el.classList[i]);
|
|
2993
|
+
if (cls && !isLikelyGeneratedClass(cls)) classes.push(ctxTruncate(cls, 48));
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
if (classes.length) s += ' class="' + classes.join(' ') + '"';
|
|
2997
|
+
return s;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
// A child rendered inside the outline: its tag with one identity hook, its
|
|
3001
|
+
// own short text when it has no element children, an ellipsis otherwise.
|
|
3002
|
+
function ctxChildTag(child, depth) {
|
|
3003
|
+
var tag = child.tagName.toLowerCase();
|
|
3004
|
+
var open = '<' + tag;
|
|
3005
|
+
if (child.id) open += ' id="' + ctxCollapse(child.id, 60) + '"';
|
|
3006
|
+
else {
|
|
3007
|
+
var cls = ctxFirstClass(child);
|
|
3008
|
+
if (cls) open += ' class="' + cls + '"';
|
|
3009
|
+
}
|
|
3010
|
+
var hook = child.getAttribute && child.getAttribute('data-testid');
|
|
3011
|
+
if (hook) open += ' data-testid="' + ctxCollapse(hook, 40) + '"';
|
|
3012
|
+
if (tag === 'a' && child.getAttribute('href')) {
|
|
3013
|
+
var href = ctxScrubUrl(child.getAttribute('href'));
|
|
3014
|
+
if (href) open += ' href="' + ctxCollapse(href, 60) + '"';
|
|
3015
|
+
}
|
|
3016
|
+
open += '>';
|
|
3017
|
+
var kids = ctxElementChildren(child);
|
|
3018
|
+
if (!kids.length) {
|
|
3019
|
+
var own = ctxCollapse(child.textContent, 40);
|
|
3020
|
+
return open + own + '</' + tag + '>';
|
|
3021
|
+
}
|
|
3022
|
+
if (depth <= 1) {
|
|
3023
|
+
var leafText = ctxCollapse(child.textContent, 40);
|
|
3024
|
+
return open + (leafText || '…') + '</' + tag + '>';
|
|
3025
|
+
}
|
|
3026
|
+
var lines = [open];
|
|
3027
|
+
var shown = kids.slice(0, CTX_MAX_CHILDREN);
|
|
3028
|
+
for (var i = 0; i < shown.length; i++) lines.push(' ' + ctxChildTag(shown[i], depth - 1));
|
|
3029
|
+
if (kids.length > shown.length) lines.push(' <!-- +' + (kids.length - shown.length) + ' more: ' + ctxTagCounts(kids.slice(shown.length)) + ' -->');
|
|
3030
|
+
lines.push('</' + tag + '>');
|
|
3031
|
+
return lines.join('\\n');
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
function ctxOutlineAtDepth(el, attrs, kids, depth) {
|
|
3035
|
+
var tag = el.tagName.toLowerCase();
|
|
3036
|
+
var open = '<' + tag + ctxIdentityString(el) + ctxAttrString(attrs) + '>';
|
|
3037
|
+
if (!kids.length) {
|
|
3038
|
+
var own = ctxCollapse(el.textContent, 80);
|
|
3039
|
+
return open + own + '</' + tag + '>';
|
|
3040
|
+
}
|
|
3041
|
+
if (depth <= 0) return open + '<!-- ' + kids.length + ' children: ' + ctxTagCounts(kids) + ' -->' + '</' + tag + '>';
|
|
3042
|
+
var lines = [open];
|
|
3043
|
+
var shown = kids.slice(0, CTX_MAX_CHILDREN);
|
|
3044
|
+
for (var i = 0; i < shown.length; i++) {
|
|
3045
|
+
var rendered = ctxChildTag(shown[i], depth);
|
|
3046
|
+
var childLines = rendered.split('\\n');
|
|
3047
|
+
for (var j = 0; j < childLines.length; j++) lines.push(' ' + childLines[j]);
|
|
3048
|
+
}
|
|
3049
|
+
if (kids.length > shown.length) lines.push(' <!-- +' + (kids.length - shown.length) + ' more: ' + ctxTagCounts(kids.slice(shown.length)) + ' -->');
|
|
3050
|
+
lines.push('</' + tag + '>');
|
|
3051
|
+
return lines.join('\\n');
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
// Adaptive depth: two levels, then one, then a per-tag count — the first
|
|
3055
|
+
// that fits the outline cap. A whole-page container never costs more than a chip.
|
|
3056
|
+
function ctxOutline(el, attrs) {
|
|
3057
|
+
var kids = ctxElementChildren(el);
|
|
3058
|
+
var depths = [2, 1, 0];
|
|
3059
|
+
for (var i = 0; i < depths.length; i++) {
|
|
3060
|
+
var out = ctxOutlineAtDepth(el, attrs, kids, depths[i]);
|
|
3061
|
+
if (out.length <= CTX_MAX_OUTLINE) return out;
|
|
3062
|
+
}
|
|
3063
|
+
return ctxTruncate(ctxOutlineAtDepth(el, attrs, kids, 0), CTX_MAX_OUTLINE);
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
function ctxLandmark(el) {
|
|
3067
|
+
var parent = el.parentElement;
|
|
3068
|
+
if (!parent || !parent.closest) return null;
|
|
3069
|
+
var region = null;
|
|
3070
|
+
try { region = parent.closest(CONTEXT_LANDMARK_SELECTOR); } catch (ex) { return null; }
|
|
3071
|
+
if (!region || region === document.body) return null;
|
|
3072
|
+
var seg = ctxSegment(region);
|
|
3073
|
+
var name = region.getAttribute('aria-label');
|
|
3074
|
+
if (name && name.trim()) seg += ' "' + ctxCollapse(name, 60) + '"';
|
|
3075
|
+
return ctxTruncate(seg, 80);
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
// The nearest heading ABOVE the element in document order (or one that
|
|
3079
|
+
// contains it): the section it sits in, which often names the source file.
|
|
3080
|
+
function ctxHeading(el) {
|
|
3081
|
+
var headings = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
|
3082
|
+
var best = null;
|
|
3083
|
+
for (var i = 0; i < headings.length && i < 500; i++) {
|
|
3084
|
+
var h = headings[i];
|
|
3085
|
+
var pos = el.compareDocumentPosition(h);
|
|
3086
|
+
if (pos & 2 || pos & 8) best = h; // PRECEDING or CONTAINS
|
|
3087
|
+
else if (pos & 4) break; // FOLLOWING: every later heading follows too
|
|
3088
|
+
}
|
|
3089
|
+
if (!best) return null;
|
|
3090
|
+
var text = ctxCollapse(best.textContent, 120);
|
|
3091
|
+
return text ? best.tagName.toLowerCase() + ' "' + text + '"' : null;
|
|
3092
|
+
}
|
|
3093
|
+
|
|
3094
|
+
// The cheapest safe framework hint: an author-placed component marker on
|
|
3095
|
+
// the element or a near ancestor. Deliberately no React fiber reads.
|
|
3096
|
+
function ctxComponent(el) {
|
|
3097
|
+
var current = el;
|
|
3098
|
+
for (var depth = 0; current && current.nodeType === 1 && depth < 6; depth++) {
|
|
3099
|
+
var attrs = ['data-component', 'data-testid', 'data-qa'];
|
|
3100
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
3101
|
+
var value = current.getAttribute && current.getAttribute(attrs[i]);
|
|
3102
|
+
if (value && value.trim()) return attrs[i] + '=' + ctxCollapse(value, 60);
|
|
3103
|
+
}
|
|
3104
|
+
current = current.parentElement;
|
|
3105
|
+
}
|
|
3106
|
+
return null;
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
function ctxRect(el) {
|
|
3110
|
+
var r = el.getBoundingClientRect();
|
|
3111
|
+
return {
|
|
3112
|
+
x: Math.round(r.left), y: Math.round(r.top),
|
|
3113
|
+
w: Math.round(r.width), h: Math.round(r.height),
|
|
3114
|
+
vw: Math.round(window.innerWidth || 0), vh: Math.round(window.innerHeight || 0)
|
|
3115
|
+
};
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
// Shed order when over budget: the fields an agent can live without first.
|
|
3119
|
+
var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component'];
|
|
3120
|
+
|
|
3121
|
+
function buildElementContext(el, maxBytes) {
|
|
3122
|
+
if (!el || el.nodeType !== 1) return null;
|
|
3123
|
+
try {
|
|
3124
|
+
var attrs = ctxAttrs(el);
|
|
3125
|
+
var context = { tag: el.tagName.toLowerCase() };
|
|
3126
|
+
if (el.id) context.id = ctxCollapse(el.id, 100);
|
|
3127
|
+
var classes = ctxClasses(el);
|
|
3128
|
+
if (classes.length) context.classes = classes;
|
|
3129
|
+
context.path = ctxPath(el);
|
|
3130
|
+
var role = ctxRole(el);
|
|
3131
|
+
if (role) context.role = role;
|
|
3132
|
+
var name = ctxName(el);
|
|
3133
|
+
if (name) context.name = name;
|
|
3134
|
+
if (attrs.length) context.attrs = attrs;
|
|
3135
|
+
var text = ctxText(el);
|
|
3136
|
+
if (text) context.text = text;
|
|
3137
|
+
context.outline = ctxOutline(el, attrs);
|
|
3138
|
+
context.children = ctxElementChildren(el).length;
|
|
3139
|
+
context.rect = ctxRect(el);
|
|
3140
|
+
var landmark = ctxLandmark(el);
|
|
3141
|
+
if (landmark) context.landmark = landmark;
|
|
3142
|
+
var heading = ctxHeading(el);
|
|
3143
|
+
if (heading) context.heading = heading;
|
|
3144
|
+
var component = ctxComponent(el);
|
|
3145
|
+
if (component) context.component = component;
|
|
3146
|
+
if (LIVE) {
|
|
3147
|
+
context.page = { url: currentPageUrl() };
|
|
3148
|
+
var title = ctxCollapse(document.title, 200);
|
|
3149
|
+
if (title) context.page.title = title;
|
|
3150
|
+
}
|
|
3151
|
+
// Byte budget: shed whole fields in order; a shed attrs/classes array
|
|
3152
|
+
// first loses entries from the end before the field goes entirely.
|
|
3153
|
+
for (var s = 0; s < CTX_SHED_ORDER.length && ctxBytes(context) > maxBytes; s++) {
|
|
3154
|
+
var field = CTX_SHED_ORDER[s];
|
|
3155
|
+
if (context[field] === undefined) continue;
|
|
3156
|
+
if ((field === 'attrs' || field === 'classes') && context[field].length > 1) {
|
|
3157
|
+
while (context[field].length > 1 && ctxBytes(context) > maxBytes) context[field].pop();
|
|
3158
|
+
if (ctxBytes(context) <= maxBytes) break;
|
|
3159
|
+
}
|
|
3160
|
+
if (field === 'text' && context.text.length > 80) {
|
|
3161
|
+
context.text = ctxTruncate(context.text, 80);
|
|
3162
|
+
if (ctxBytes(context) <= maxBytes) break;
|
|
3163
|
+
}
|
|
3164
|
+
delete context[field];
|
|
3165
|
+
}
|
|
3166
|
+
return context;
|
|
3167
|
+
} catch (ex) {
|
|
3168
|
+
return null;
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
|
|
2731
3172
|
function clearPendingPin() {
|
|
2732
3173
|
pendingPinEl = null;
|
|
3174
|
+
pendingPinContext = null;
|
|
2733
3175
|
pendingPinAnchor = null;
|
|
2734
3176
|
pendingPinKey = null;
|
|
2735
3177
|
pendingPinLabel = null;
|
|
@@ -2850,6 +3292,7 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2850
3292
|
pendingPinKey = next.key;
|
|
2851
3293
|
pendingPinLabel = next.label;
|
|
2852
3294
|
pendingPinPoint = next.point || null;
|
|
3295
|
+
pendingPinContext = next.context || null;
|
|
2853
3296
|
// A promoted primary commits as an element pin: the original text
|
|
2854
3297
|
// selection belonged to the removed element and no longer applies.
|
|
2855
3298
|
pendingSelection = { element: true };
|
|
@@ -2908,15 +3351,17 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2908
3351
|
if (anchor && point) anchor.point = point;
|
|
2909
3352
|
var label = pinpointHoverLabel(el);
|
|
2910
3353
|
var text = elementTargetText(el, label);
|
|
3354
|
+
var context = buildElementContext(el, CTX_MAX_BYTES_EXTRA);
|
|
2911
3355
|
var key = makeTargetKey();
|
|
2912
3356
|
var box = createMultiTargetBox(el);
|
|
2913
|
-
pendingMultiTargets.push({ key: key, el: el, anchor: anchor, label: label, text: text, point: point, box: box });
|
|
3357
|
+
pendingMultiTargets.push({ key: key, el: el, anchor: anchor, label: label, text: text, point: point, context: context, box: box });
|
|
2914
3358
|
postToParent({
|
|
2915
3359
|
type: PREFIX + 'multi-target-added',
|
|
2916
3360
|
key: key,
|
|
2917
3361
|
label: label,
|
|
2918
3362
|
text: text,
|
|
2919
|
-
anchor: anchor || undefined
|
|
3363
|
+
anchor: anchor || undefined,
|
|
3364
|
+
context: context || undefined
|
|
2920
3365
|
});
|
|
2921
3366
|
}
|
|
2922
3367
|
|
|
@@ -3017,11 +3462,15 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3017
3462
|
pendingPinPoint = normalizePointInElement(el, clickPoint);
|
|
3018
3463
|
if (pendingPinAnchor && pendingPinPoint) pendingPinAnchor.point = pendingPinPoint;
|
|
3019
3464
|
pendingPinViaPinpoint = !!viaPinpoint;
|
|
3465
|
+
// Agent-facing description, captured now while the element is in hand
|
|
3466
|
+
// (an SPA re-render or navigation may have replaced it by submit time).
|
|
3467
|
+
pendingPinContext = buildElementContext(el, CTX_MAX_BYTES);
|
|
3020
3468
|
var extras = {
|
|
3021
3469
|
anchor: pendingPinAnchor,
|
|
3022
3470
|
pinpoint: !!viaPinpoint,
|
|
3023
3471
|
targetKey: pendingPinKey,
|
|
3024
|
-
targetLabel: pendingPinLabel
|
|
3472
|
+
targetLabel: pendingPinLabel,
|
|
3473
|
+
context: pendingPinContext
|
|
3025
3474
|
};
|
|
3026
3475
|
// Pinned outline: stronger accent box that tracks the element until the
|
|
3027
3476
|
// composer resolves (create-mark or cancel-selection).
|
|
@@ -3063,10 +3512,79 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3063
3512
|
pinpoint: !!viaPinpoint || undefined,
|
|
3064
3513
|
targetKey: pendingPinKey || undefined,
|
|
3065
3514
|
targetLabel: pendingPinLabel || undefined,
|
|
3515
|
+
context: pendingPinContext || undefined,
|
|
3066
3516
|
rect: { top: r.top, left: r.left, width: r.width, height: r.height } });
|
|
3067
3517
|
return true;
|
|
3068
3518
|
}
|
|
3069
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
|
+
|
|
3070
3588
|
document.addEventListener('click', function(e) {
|
|
3071
3589
|
if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
|
|
3072
3590
|
// Real placed markers (and any other viewer overlay) own their clicks —
|
|
@@ -3142,15 +3660,21 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3142
3660
|
}
|
|
3143
3661
|
});
|
|
3144
3662
|
|
|
3145
|
-
//
|
|
3146
|
-
// registers
|
|
3147
|
-
//
|
|
3148
|
-
//
|
|
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.
|
|
3149
3670
|
document.addEventListener('keydown', function(e) {
|
|
3150
3671
|
if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return;
|
|
3151
|
-
|
|
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;
|
|
3152
3676
|
e.preventDefault();
|
|
3153
|
-
postToParent({ type: PREFIX +
|
|
3677
|
+
postToParent({ type: PREFIX + message });
|
|
3154
3678
|
}, true);
|
|
3155
3679
|
|
|
3156
3680
|
// Author opt-in: a plain click on any element tagged [data-annotate] pops the
|