@plannotator/ui 0.39.0 → 0.41.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 +140 -10
- package/README.md +9 -5
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DiagramBlock.tsx +376 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +17 -630
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +67 -3
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/diagram/DiagramCanvas.tsx +431 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPopout.tsx +70 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +276 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/index.ts +36 -0
- package/components/diagram/useDiagramComments.ts +341 -0
- package/components/diagram/useDiagramRender.ts +91 -0
- package/components/diagram/useDiagramSourceDraft.ts +143 -0
- package/components/diagram/useDiagramViewport.ts +156 -0
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +121 -9
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +133 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +469 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +6 -4
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +5 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/diagram-anchor-graphviz.ts +143 -0
- package/utils/diagram-anchor.ts +401 -0
- package/utils/diagram-projection.ts +66 -0
- package/utils/diagram-render.ts +668 -0
- package/utils/graphviz.ts +93 -0
- 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 +36 -7
- package/utils/terminalToolsAnnouncement.ts +76 -0
- package/components/mermaidSvg.ts +0 -33
|
@@ -60,6 +60,18 @@ body[data-plannotator-pinpoint-cursor],
|
|
|
60
60
|
body[data-plannotator-pinpoint-cursor] * {
|
|
61
61
|
cursor: crosshair !important;
|
|
62
62
|
}
|
|
63
|
+
/* Armed pinpoint over an EMBEDDED local document: the embed is one element
|
|
64
|
+
* from the outer page's point of view, and the bridge is never injected into a
|
|
65
|
+
* nested frame, so a click inside it would simply vanish into another document.
|
|
66
|
+
* Making frames transparent to the pointer while armed is what lets that click
|
|
67
|
+
* pin the <iframe>/<embed>/<object> itself. Interact (Esc, the header pen or
|
|
68
|
+
* Mod+Shift+A) restores native interaction inside the embed — which is also
|
|
69
|
+
* the only state a link inside it can be followed from.
|
|
70
|
+
* Live-app sessions never set this attribute: they annotate a real app whose
|
|
71
|
+
* own nested frames belong to it. */
|
|
72
|
+
body[data-plannotator-frame-inert] :is(iframe, frame, embed, object) {
|
|
73
|
+
pointer-events: none !important;
|
|
74
|
+
}
|
|
63
75
|
@media (prefers-reduced-motion: reduce) {
|
|
64
76
|
[data-plannotator-pinpoint-box].pn-pin-enter {
|
|
65
77
|
animation: none;
|
|
@@ -381,8 +393,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
381
393
|
if (!document.body) return;
|
|
382
394
|
if (annotateModeActive && currentInputMethod === 'pinpoint') {
|
|
383
395
|
document.body.setAttribute('data-plannotator-pinpoint-cursor', '');
|
|
396
|
+
if (!LIVE) document.body.setAttribute('data-plannotator-frame-inert', '');
|
|
384
397
|
} else {
|
|
385
398
|
document.body.removeAttribute('data-plannotator-pinpoint-cursor');
|
|
399
|
+
document.body.removeAttribute('data-plannotator-frame-inert');
|
|
386
400
|
}
|
|
387
401
|
}
|
|
388
402
|
var pinpointHover = null;
|
|
@@ -690,6 +704,13 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
690
704
|
scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
|
|
691
705
|
}
|
|
692
706
|
|
|
707
|
+
else if (type === PREFIX + 'scroll-to-fragment') {
|
|
708
|
+
// A linked document opened from an in-page link carried a #fragment.
|
|
709
|
+
// The srcdoc document has no URL of its own, so the parent cannot set
|
|
710
|
+
// one: it replays the fragment here once the new document is ready.
|
|
711
|
+
scrollToLocalFragment(typeof e.data.fragment === 'string' ? e.data.fragment : '');
|
|
712
|
+
}
|
|
713
|
+
|
|
693
714
|
else if (type === PREFIX + 'focus-mark') {
|
|
694
715
|
focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
|
|
695
716
|
}
|
|
@@ -905,11 +926,34 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
905
926
|
var svgGroup = node.closest('g');
|
|
906
927
|
if (svgGroup) node = svgGroup;
|
|
907
928
|
}
|
|
929
|
+
node = preferInertFrameAt(node, x, y);
|
|
908
930
|
node = promoteTinyTarget(node);
|
|
909
931
|
if (node === document.body || node === document.documentElement) return null;
|
|
910
932
|
return node;
|
|
911
933
|
}
|
|
912
934
|
|
|
935
|
+
// While frames are pointer-transparent (armed pinpoint, srcdoc sessions),
|
|
936
|
+
// hit-testing passes THROUGH an embedded document to the container painted
|
|
937
|
+
// behind it — so a click on an embed would pin its wrapper div. The embed is
|
|
938
|
+
// what the reviewer is pointing at and what the anchor must name, so a point
|
|
939
|
+
// inside a frame's own rect resolves to that frame. Bounded to the frames
|
|
940
|
+
// inside the element already resolved, so it costs nothing on ordinary pages.
|
|
941
|
+
var FRAME_SELECTOR = 'iframe,frame,embed,object';
|
|
942
|
+
function framesArePointerInert() {
|
|
943
|
+
return !LIVE && annotateModeActive && currentInputMethod === 'pinpoint';
|
|
944
|
+
}
|
|
945
|
+
function preferInertFrameAt(node, x, y) {
|
|
946
|
+
if (!framesArePointerInert() || !node.querySelectorAll) return node;
|
|
947
|
+
if (node.matches && node.matches(FRAME_SELECTOR)) return node;
|
|
948
|
+
var frames = node.querySelectorAll(FRAME_SELECTOR);
|
|
949
|
+
for (var i = 0; i < frames.length && i < 64; i++) {
|
|
950
|
+
var r = frames[i].getBoundingClientRect();
|
|
951
|
+
if (r.width <= 0 || r.height <= 0) continue;
|
|
952
|
+
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return frames[i];
|
|
953
|
+
}
|
|
954
|
+
return node;
|
|
955
|
+
}
|
|
956
|
+
|
|
913
957
|
// Last-position reuse: a pointer that moved under 2px within 16ms resolves
|
|
914
958
|
// to the cached element instead of re-hit-testing. The scroll reconcile
|
|
915
959
|
// invalidates this cache — same point, different element after a scroll.
|
|
@@ -2741,8 +2785,9 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2741
2785
|
// Constraints (the "smart" part is that they adapt to the element):
|
|
2742
2786
|
// - attributes are an ALLOWLIST (a page cannot add a key); no form values,
|
|
2743
2787
|
// no on* handlers, no style, no script/style/template contents ever;
|
|
2744
|
-
// -
|
|
2745
|
-
//
|
|
2788
|
+
// - href/src URLs lose their query and fragment (tokens live there),
|
|
2789
|
+
// relative ones included, and data: URIs keep only their media-type
|
|
2790
|
+
// prefix;
|
|
2746
2791
|
// - the outline tries two levels of children, falls back to one, then to a
|
|
2747
2792
|
// per-tag count, whichever first fits CTX_MAX_OUTLINE, so a click on a
|
|
2748
2793
|
// whole <main> costs the same bytes as a click on a chip;
|
|
@@ -2792,7 +2837,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2792
2837
|
}
|
|
2793
2838
|
|
|
2794
2839
|
// URL attribute values: keep what locates the element in source, drop
|
|
2795
|
-
// what identifies the user.
|
|
2840
|
+
// what identifies the user. The path survives in every form; the query and
|
|
2841
|
+
// the fragment never do — a relative URL carries the same per-visit state an
|
|
2842
|
+
// absolute one does (session ids, and the implicit-flow tokens that live in
|
|
2843
|
+
// the fragment specifically), so it is scrubbed the same way.
|
|
2796
2844
|
function ctxScrubUrl(value) {
|
|
2797
2845
|
var v = String(value).trim();
|
|
2798
2846
|
if (/^javascript:/i.test(v)) return null;
|
|
@@ -2806,6 +2854,8 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2806
2854
|
return u.origin + u.pathname + (u.search || u.hash ? '?…' : '');
|
|
2807
2855
|
} catch (ex) { return ctxTruncate(v, CTX_MAX_ATTR_VALUE); }
|
|
2808
2856
|
}
|
|
2857
|
+
var mark = v.search(/[?#]/);
|
|
2858
|
+
if (mark >= 0) return v.slice(0, mark) + '?…';
|
|
2809
2859
|
return v;
|
|
2810
2860
|
}
|
|
2811
2861
|
|
|
@@ -3504,6 +3554,74 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3504
3554
|
return true;
|
|
3505
3555
|
}
|
|
3506
3556
|
|
|
3557
|
+
// --- Local-site link navigation (srcdoc sessions only) ---
|
|
3558
|
+
// A srcdoc document has no URL of its own: its base URL is the PARENT page's,
|
|
3559
|
+
// which is the Plannotator server. So a plain link to 02-detail.html resolves
|
|
3560
|
+
// to http://localhost:<port>/02-detail.html, the server's catch-all answers
|
|
3561
|
+
// with the app itself, and the whole editor renders inside the annotated
|
|
3562
|
+
// frame. An in-page #section link is a cross-document navigation for the
|
|
3563
|
+
// same reason.
|
|
3564
|
+
//
|
|
3565
|
+
// The frame therefore never navigates itself. In-page fragments scroll here;
|
|
3566
|
+
// everything else is handed to the parent, which owns resolution against the
|
|
3567
|
+
// current document's directory and is the trust boundary for the href.
|
|
3568
|
+
// Registered BEFORE the pinpoint handler and never stopping propagation, so
|
|
3569
|
+
// an armed click still pins the link element exactly as it always did.
|
|
3570
|
+
//
|
|
3571
|
+
// Live app sessions are excluded outright: they navigate a real origin
|
|
3572
|
+
// through the proxy, which is the whole point of that surface.
|
|
3573
|
+
function scrollToLocalFragment(rawId) {
|
|
3574
|
+
var id = typeof rawId === 'string' ? rawId : '';
|
|
3575
|
+
try { id = decodeURIComponent(id); } catch (ex) {}
|
|
3576
|
+
if (!id) {
|
|
3577
|
+
try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (ex) { window.scrollTo(0, 0); }
|
|
3578
|
+
return true;
|
|
3579
|
+
}
|
|
3580
|
+
var target = null;
|
|
3581
|
+
try { target = document.getElementById(id); } catch (ex) {}
|
|
3582
|
+
if (!target) {
|
|
3583
|
+
var named = document.getElementsByName(id);
|
|
3584
|
+
if (named && named.length) target = named[0];
|
|
3585
|
+
}
|
|
3586
|
+
if (!target) return false;
|
|
3587
|
+
try { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
|
3588
|
+
catch (ex) { target.scrollIntoView(); }
|
|
3589
|
+
return true;
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
function navigableLinkHref(node) {
|
|
3593
|
+
var el = node && node.nodeType === 1 ? node : node && node.parentElement;
|
|
3594
|
+
if (!el || !el.closest) return '';
|
|
3595
|
+
var link = el.closest('a,area');
|
|
3596
|
+
if (!link) return '';
|
|
3597
|
+
var raw = link.getAttribute('href');
|
|
3598
|
+
// SVG anchors may only carry xlink:href.
|
|
3599
|
+
if (typeof raw !== 'string') raw = link.getAttribute('xlink:href');
|
|
3600
|
+
return typeof raw === 'string' ? raw.trim() : '';
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3603
|
+
if (!LIVE) {
|
|
3604
|
+
document.addEventListener('click', function(e) {
|
|
3605
|
+
if (e.defaultPrevented || e.button !== 0) return;
|
|
3606
|
+
if (isViewerOverlayNode(e.target)) return; // markers own their clicks
|
|
3607
|
+
var raw = navigableLinkHref(e.target);
|
|
3608
|
+
if (!raw) return;
|
|
3609
|
+
// The page's own scripting, not a navigation: leave it alone.
|
|
3610
|
+
if (/^javascript:/i.test(raw)) return;
|
|
3611
|
+
if (raw.charAt(0) === '#') {
|
|
3612
|
+
e.preventDefault();
|
|
3613
|
+
scrollToLocalFragment(raw.slice(1));
|
|
3614
|
+
return;
|
|
3615
|
+
}
|
|
3616
|
+
e.preventDefault();
|
|
3617
|
+
// Armed pinpoint: the click belongs to annotation, and the capture-phase
|
|
3618
|
+
// pinpoint handler below is about to pin this element. Navigation is
|
|
3619
|
+
// already suppressed above, which is all this surface owes the click.
|
|
3620
|
+
if (annotateModeActive && currentInputMethod === 'pinpoint') return;
|
|
3621
|
+
postToParent({ type: PREFIX + 'link-click', href: raw.slice(0, 2048) });
|
|
3622
|
+
}, true);
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3507
3625
|
document.addEventListener('click', function(e) {
|
|
3508
3626
|
if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
|
|
3509
3627
|
// Real placed markers (and any other viewer overlay) own their clicks —
|
|
@@ -3579,15 +3697,21 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3579
3697
|
}
|
|
3580
3698
|
});
|
|
3581
3699
|
|
|
3582
|
-
//
|
|
3583
|
-
// registers
|
|
3584
|
-
//
|
|
3585
|
-
//
|
|
3700
|
+
// The two reserved header chords, mirrored from inside the iframe (the
|
|
3701
|
+
// parent registers both, but focus usually lives in here on live apps):
|
|
3702
|
+
// Mod+Shift+A toggles Interact/Annotate, Mod+Shift+X shows/hides the
|
|
3703
|
+
// floating tools over the page. Capture phase so the page cannot swallow
|
|
3704
|
+
// them; the parent owns both states and answers annotate with
|
|
3705
|
+
// set-annotate-mode. Disarming tears down any pending draft through that
|
|
3706
|
+
// same set-annotate-mode(false) handler, exactly as Esc does.
|
|
3586
3707
|
document.addEventListener('keydown', function(e) {
|
|
3587
3708
|
if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return;
|
|
3588
|
-
|
|
3709
|
+
var message = null;
|
|
3710
|
+
if (e.key === 'a' || e.key === 'A') message = 'annotate-toggle';
|
|
3711
|
+
else if (e.key === 'x' || e.key === 'X') message = 'tools-toggle';
|
|
3712
|
+
if (!message) return;
|
|
3589
3713
|
e.preventDefault();
|
|
3590
|
-
postToParent({ type: PREFIX +
|
|
3714
|
+
postToParent({ type: PREFIX + message });
|
|
3591
3715
|
}, true);
|
|
3592
3716
|
|
|
3593
3717
|
// 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
|
|
136
|
-
|
|
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
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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);
|