@plannotator/ui 0.39.0 → 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 -10
- package/README.md +3 -3
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- 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 +96 -9
- package/components/html-viewer/bridge-script.ts +96 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- 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/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -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 +21 -6
- package/utils/terminalToolsAnnouncement.ts +76 -0
|
@@ -466,6 +466,13 @@
|
|
|
466
466
|
scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
+
else if (type === PREFIX + 'scroll-to-fragment') {
|
|
470
|
+
// A linked document opened from an in-page link carried a #fragment.
|
|
471
|
+
// The srcdoc document has no URL of its own, so the parent cannot set
|
|
472
|
+
// one: it replays the fragment here once the new document is ready.
|
|
473
|
+
scrollToLocalFragment(typeof e.data.fragment === 'string' ? e.data.fragment : '');
|
|
474
|
+
}
|
|
475
|
+
|
|
469
476
|
else if (type === PREFIX + 'focus-mark') {
|
|
470
477
|
focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
|
|
471
478
|
}
|
|
@@ -2517,8 +2524,9 @@
|
|
|
2517
2524
|
// Constraints (the "smart" part is that they adapt to the element):
|
|
2518
2525
|
// - attributes are an ALLOWLIST (a page cannot add a key); no form values,
|
|
2519
2526
|
// no on* handlers, no style, no script/style/template contents ever;
|
|
2520
|
-
// -
|
|
2521
|
-
//
|
|
2527
|
+
// - href/src URLs lose their query and fragment (tokens live there),
|
|
2528
|
+
// relative ones included, and data: URIs keep only their media-type
|
|
2529
|
+
// prefix;
|
|
2522
2530
|
// - the outline tries two levels of children, falls back to one, then to a
|
|
2523
2531
|
// per-tag count, whichever first fits CTX_MAX_OUTLINE, so a click on a
|
|
2524
2532
|
// whole <main> costs the same bytes as a click on a chip;
|
|
@@ -2568,7 +2576,10 @@
|
|
|
2568
2576
|
}
|
|
2569
2577
|
|
|
2570
2578
|
// URL attribute values: keep what locates the element in source, drop
|
|
2571
|
-
// what identifies the user.
|
|
2579
|
+
// what identifies the user. The path survives in every form; the query and
|
|
2580
|
+
// the fragment never do — a relative URL carries the same per-visit state an
|
|
2581
|
+
// absolute one does (session ids, and the implicit-flow tokens that live in
|
|
2582
|
+
// the fragment specifically), so it is scrubbed the same way.
|
|
2572
2583
|
function ctxScrubUrl(value) {
|
|
2573
2584
|
var v = String(value).trim();
|
|
2574
2585
|
if (/^javascript:/i.test(v)) return null;
|
|
@@ -2582,6 +2593,8 @@
|
|
|
2582
2593
|
return u.origin + u.pathname + (u.search || u.hash ? '?…' : '');
|
|
2583
2594
|
} catch (ex) { return ctxTruncate(v, CTX_MAX_ATTR_VALUE); }
|
|
2584
2595
|
}
|
|
2596
|
+
var mark = v.search(/[?#]/);
|
|
2597
|
+
if (mark >= 0) return v.slice(0, mark) + '?…';
|
|
2585
2598
|
return v;
|
|
2586
2599
|
}
|
|
2587
2600
|
|
|
@@ -3280,6 +3293,74 @@
|
|
|
3280
3293
|
return true;
|
|
3281
3294
|
}
|
|
3282
3295
|
|
|
3296
|
+
// --- Local-site link navigation (srcdoc sessions only) ---
|
|
3297
|
+
// A srcdoc document has no URL of its own: its base URL is the PARENT page's,
|
|
3298
|
+
// which is the Plannotator server. So a plain link to 02-detail.html resolves
|
|
3299
|
+
// to http://localhost:<port>/02-detail.html, the server's catch-all answers
|
|
3300
|
+
// with the app itself, and the whole editor renders inside the annotated
|
|
3301
|
+
// frame. An in-page #section link is a cross-document navigation for the
|
|
3302
|
+
// same reason.
|
|
3303
|
+
//
|
|
3304
|
+
// The frame therefore never navigates itself. In-page fragments scroll here;
|
|
3305
|
+
// everything else is handed to the parent, which owns resolution against the
|
|
3306
|
+
// current document's directory and is the trust boundary for the href.
|
|
3307
|
+
// Registered BEFORE the pinpoint handler and never stopping propagation, so
|
|
3308
|
+
// an armed click still pins the link element exactly as it always did.
|
|
3309
|
+
//
|
|
3310
|
+
// Live app sessions are excluded outright: they navigate a real origin
|
|
3311
|
+
// through the proxy, which is the whole point of that surface.
|
|
3312
|
+
function scrollToLocalFragment(rawId) {
|
|
3313
|
+
var id = typeof rawId === 'string' ? rawId : '';
|
|
3314
|
+
try { id = decodeURIComponent(id); } catch (ex) {}
|
|
3315
|
+
if (!id) {
|
|
3316
|
+
try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (ex) { window.scrollTo(0, 0); }
|
|
3317
|
+
return true;
|
|
3318
|
+
}
|
|
3319
|
+
var target = null;
|
|
3320
|
+
try { target = document.getElementById(id); } catch (ex) {}
|
|
3321
|
+
if (!target) {
|
|
3322
|
+
var named = document.getElementsByName(id);
|
|
3323
|
+
if (named && named.length) target = named[0];
|
|
3324
|
+
}
|
|
3325
|
+
if (!target) return false;
|
|
3326
|
+
try { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
|
3327
|
+
catch (ex) { target.scrollIntoView(); }
|
|
3328
|
+
return true;
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
function navigableLinkHref(node) {
|
|
3332
|
+
var el = node && node.nodeType === 1 ? node : node && node.parentElement;
|
|
3333
|
+
if (!el || !el.closest) return '';
|
|
3334
|
+
var link = el.closest('a,area');
|
|
3335
|
+
if (!link) return '';
|
|
3336
|
+
var raw = link.getAttribute('href');
|
|
3337
|
+
// SVG anchors may only carry xlink:href.
|
|
3338
|
+
if (typeof raw !== 'string') raw = link.getAttribute('xlink:href');
|
|
3339
|
+
return typeof raw === 'string' ? raw.trim() : '';
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
if (!LIVE) {
|
|
3343
|
+
document.addEventListener('click', function(e) {
|
|
3344
|
+
if (e.defaultPrevented || e.button !== 0) return;
|
|
3345
|
+
if (isViewerOverlayNode(e.target)) return; // markers own their clicks
|
|
3346
|
+
var raw = navigableLinkHref(e.target);
|
|
3347
|
+
if (!raw) return;
|
|
3348
|
+
// The page's own scripting, not a navigation: leave it alone.
|
|
3349
|
+
if (/^javascript:/i.test(raw)) return;
|
|
3350
|
+
if (raw.charAt(0) === '#') {
|
|
3351
|
+
e.preventDefault();
|
|
3352
|
+
scrollToLocalFragment(raw.slice(1));
|
|
3353
|
+
return;
|
|
3354
|
+
}
|
|
3355
|
+
e.preventDefault();
|
|
3356
|
+
// Armed pinpoint: the click belongs to annotation, and the capture-phase
|
|
3357
|
+
// pinpoint handler below is about to pin this element. Navigation is
|
|
3358
|
+
// already suppressed above, which is all this surface owes the click.
|
|
3359
|
+
if (annotateModeActive && currentInputMethod === 'pinpoint') return;
|
|
3360
|
+
postToParent({ type: PREFIX + 'link-click', href: raw.slice(0, 2048) });
|
|
3361
|
+
}, true);
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3283
3364
|
document.addEventListener('click', function(e) {
|
|
3284
3365
|
if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
|
|
3285
3366
|
// Real placed markers (and any other viewer overlay) own their clicks —
|
|
@@ -3355,15 +3436,21 @@
|
|
|
3355
3436
|
}
|
|
3356
3437
|
});
|
|
3357
3438
|
|
|
3358
|
-
//
|
|
3359
|
-
// registers
|
|
3360
|
-
//
|
|
3361
|
-
//
|
|
3439
|
+
// The two reserved header chords, mirrored from inside the iframe (the
|
|
3440
|
+
// parent registers both, but focus usually lives in here on live apps):
|
|
3441
|
+
// Mod+Shift+A toggles Interact/Annotate, Mod+Shift+X shows/hides the
|
|
3442
|
+
// floating tools over the page. Capture phase so the page cannot swallow
|
|
3443
|
+
// them; the parent owns both states and answers annotate with
|
|
3444
|
+
// set-annotate-mode. Disarming tears down any pending draft through that
|
|
3445
|
+
// same set-annotate-mode(false) handler, exactly as Esc does.
|
|
3362
3446
|
document.addEventListener('keydown', function(e) {
|
|
3363
3447
|
if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return;
|
|
3364
|
-
|
|
3448
|
+
var message = null;
|
|
3449
|
+
if (e.key === 'a' || e.key === 'A') message = 'annotate-toggle';
|
|
3450
|
+
else if (e.key === 'x' || e.key === 'X') message = 'tools-toggle';
|
|
3451
|
+
if (!message) return;
|
|
3365
3452
|
e.preventDefault();
|
|
3366
|
-
postToParent({ type: PREFIX +
|
|
3453
|
+
postToParent({ type: PREFIX + message });
|
|
3367
3454
|
}, true);
|
|
3368
3455
|
|
|
3369
3456
|
// Author opt-in: a plain click on any element tagged [data-annotate] pops the
|
|
@@ -690,6 +690,13 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
690
690
|
scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
|
|
691
691
|
}
|
|
692
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
|
+
|
|
693
700
|
else if (type === PREFIX + 'focus-mark') {
|
|
694
701
|
focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
|
|
695
702
|
}
|
|
@@ -2741,8 +2748,9 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2741
2748
|
// Constraints (the "smart" part is that they adapt to the element):
|
|
2742
2749
|
// - attributes are an ALLOWLIST (a page cannot add a key); no form values,
|
|
2743
2750
|
// no on* handlers, no style, no script/style/template contents ever;
|
|
2744
|
-
// -
|
|
2745
|
-
//
|
|
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;
|
|
2746
2754
|
// - the outline tries two levels of children, falls back to one, then to a
|
|
2747
2755
|
// per-tag count, whichever first fits CTX_MAX_OUTLINE, so a click on a
|
|
2748
2756
|
// whole <main> costs the same bytes as a click on a chip;
|
|
@@ -2792,7 +2800,10 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2792
2800
|
}
|
|
2793
2801
|
|
|
2794
2802
|
// URL attribute values: keep what locates the element in source, drop
|
|
2795
|
-
// what identifies the user.
|
|
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.
|
|
2796
2807
|
function ctxScrubUrl(value) {
|
|
2797
2808
|
var v = String(value).trim();
|
|
2798
2809
|
if (/^javascript:/i.test(v)) return null;
|
|
@@ -2806,6 +2817,8 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
2806
2817
|
return u.origin + u.pathname + (u.search || u.hash ? '?…' : '');
|
|
2807
2818
|
} catch (ex) { return ctxTruncate(v, CTX_MAX_ATTR_VALUE); }
|
|
2808
2819
|
}
|
|
2820
|
+
var mark = v.search(/[?#]/);
|
|
2821
|
+
if (mark >= 0) return v.slice(0, mark) + '?…';
|
|
2809
2822
|
return v;
|
|
2810
2823
|
}
|
|
2811
2824
|
|
|
@@ -3504,6 +3517,74 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3504
3517
|
return true;
|
|
3505
3518
|
}
|
|
3506
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
|
+
|
|
3507
3588
|
document.addEventListener('click', function(e) {
|
|
3508
3589
|
if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
|
|
3509
3590
|
// Real placed markers (and any other viewer overlay) own their clicks —
|
|
@@ -3579,15 +3660,21 @@ export const BRIDGE_SCRIPT = `(function() {
|
|
|
3579
3660
|
}
|
|
3580
3661
|
});
|
|
3581
3662
|
|
|
3582
|
-
//
|
|
3583
|
-
// registers
|
|
3584
|
-
//
|
|
3585
|
-
//
|
|
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.
|
|
3586
3670
|
document.addEventListener('keydown', function(e) {
|
|
3587
3671
|
if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return;
|
|
3588
|
-
|
|
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;
|
|
3589
3676
|
e.preventDefault();
|
|
3590
|
-
postToParent({ type: PREFIX +
|
|
3677
|
+
postToParent({ type: PREFIX + message });
|
|
3591
3678
|
}, true);
|
|
3592
3679
|
|
|
3593
3680
|
// 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);
|