@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.
Files changed (52) hide show
  1. package/HANDOFF.md +140 -10
  2. package/README.md +9 -5
  3. package/components/AnnotationPanel.tsx +244 -13
  4. package/components/CommentPopover.tsx +91 -2
  5. package/components/DiagramBlock.tsx +376 -0
  6. package/components/GraphvizBlock.tsx +22 -597
  7. package/components/HtmlSurfaceControls.tsx +188 -23
  8. package/components/ListMarker.tsx +10 -1
  9. package/components/MermaidBlock.tsx +17 -630
  10. package/components/TableOfContents.tsx +5 -1
  11. package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
  12. package/components/Viewer.tsx +67 -3
  13. package/components/blocks/AlertBlock.tsx +7 -2
  14. package/components/diagram/DiagramCanvas.tsx +431 -0
  15. package/components/diagram/DiagramComposer.tsx +135 -0
  16. package/components/diagram/DiagramOverlay.tsx +215 -0
  17. package/components/diagram/DiagramPopout.tsx +70 -0
  18. package/components/diagram/DiagramSourcePane.tsx +244 -0
  19. package/components/diagram/DiagramViewer.tsx +276 -0
  20. package/components/diagram/anchorClaims.ts +71 -0
  21. package/components/diagram/index.ts +36 -0
  22. package/components/diagram/useDiagramComments.ts +341 -0
  23. package/components/diagram/useDiagramRender.ts +91 -0
  24. package/components/diagram/useDiagramSourceDraft.ts +143 -0
  25. package/components/diagram/useDiagramViewport.ts +156 -0
  26. package/components/html-viewer/HtmlViewer.tsx +32 -0
  27. package/components/html-viewer/bridge-script.asset.js +121 -9
  28. package/components/html-viewer/bridge-script.lite.ts +1 -1
  29. package/components/html-viewer/bridge-script.ts +133 -9
  30. package/components/html-viewer/useHtmlAnnotation.ts +44 -151
  31. package/hooks/useAnnotationHighlighter.ts +469 -14
  32. package/hooks/useLinkedDoc.ts +100 -9
  33. package/package.json +6 -4
  34. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
  35. package/styles.css +1 -1
  36. package/theme.css +62 -0
  37. package/types.ts +5 -0
  38. package/utils/annotationScope.ts +159 -0
  39. package/utils/cssColor.ts +463 -0
  40. package/utils/diagram-anchor-graphviz.ts +143 -0
  41. package/utils/diagram-anchor.ts +401 -0
  42. package/utils/diagram-projection.ts +66 -0
  43. package/utils/diagram-render.ts +668 -0
  44. package/utils/graphviz.ts +93 -0
  45. package/utils/htmlChrome.ts +70 -5
  46. package/utils/htmlLinkNavigation.ts +196 -0
  47. package/utils/mermaid-eager.ts +13 -11
  48. package/utils/mermaid.ts +19 -10
  49. package/utils/mermaidTheme.ts +732 -0
  50. package/utils/parser.ts +36 -7
  51. package/utils/terminalToolsAnnouncement.ts +76 -0
  52. package/components/mermaidSvg.ts +0 -33
@@ -0,0 +1,156 @@
1
+ import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
2
+ import type { ScreenRect } from '../../utils/diagram-projection';
3
+
4
+ /**
5
+ * The canvas viewport: a translate plus a scale on the wrapper around the
6
+ * rendered svg (zoom, pan and fit are the primary interaction). Every
7
+ * constant below is a design constant with its reason, never a cap on what
8
+ * a person may do with the diagram.
9
+ */
10
+
11
+ /** Zoom range. Below 0.1 a 1000px diagram is a 100px smudge and the rings
12
+ * collapse onto each other; above 8 a node label is a screenful. The
13
+ * range keeps the render legible and the transform finite. */
14
+ export const ZOOM_MIN = 0.1;
15
+ export const ZOOM_MAX = 8;
16
+ /** One key press or one corner-control click. 1.25 needs four presses to
17
+ * double, which is fine-grained enough to land on a node. */
18
+ export const ZOOM_STEP = 1.25;
19
+ /** Wheel zoom: factor = exp(-deltaY * sensitivity). One 100px notch is
20
+ * about 1.22x, close to ZOOM_STEP so keys and wheel feel the same. */
21
+ export const WHEEL_ZOOM_SENSITIVITY = 0.002;
22
+ /** The distance a pointer must travel before a press becomes a pan. Under
23
+ * it, releasing is a click on the part beneath (the pinpoint). */
24
+ export const DRAG_THRESHOLD_PX = 4;
25
+ /** The same threshold for a finger, which wobbles more than a mouse: a tap
26
+ * that drifts a few pixels must still be a tap. */
27
+ export const TOUCH_DRAG_THRESHOLD_PX = 10;
28
+ /** Fit leaves this much air on every side of the host so the outermost
29
+ * node and its ring never touch the edge; the badge (a 20px disc at the
30
+ * node's top-right corner) stays inside it. */
31
+ export const FIT_PADDING_PX = 24;
32
+
33
+ export interface Viewport {
34
+ readonly x: number;
35
+ readonly y: number;
36
+ readonly scale: number;
37
+ }
38
+
39
+ export interface ContentSize {
40
+ readonly width: number;
41
+ readonly height: number;
42
+ }
43
+
44
+ const IDENTITY: Viewport = { x: 0, y: 0, scale: 1 };
45
+
46
+ function clampScale(scale: number): number {
47
+ return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, scale));
48
+ }
49
+
50
+ export function useDiagramViewport(
51
+ hostRef: RefObject<HTMLElement | null>,
52
+ content: ContentSize | null,
53
+ ): {
54
+ readonly viewport: Viewport;
55
+ readonly fit: () => void;
56
+ readonly zoomBy: (factor: number, clientX?: number, clientY?: number) => void;
57
+ readonly panBy: (dx: number, dy: number) => void;
58
+ readonly panIntoView: (rect: ScreenRect) => void;
59
+ } {
60
+ const [viewport, setViewport] = useState<Viewport>(IDENTITY);
61
+ const contentRef = useRef(content);
62
+ contentRef.current = content;
63
+ // Whether the person zoomed or panned since the last fit. While true, a
64
+ // new render (the draft preview) or a host resize (the Source pane
65
+ // opening, the window) keeps their view; a fit (the key, the control, a
66
+ // first arrival) clears it.
67
+ const adjustedRef = useRef(false);
68
+
69
+ const fit = useCallback(() => {
70
+ const host = hostRef.current;
71
+ const size = contentRef.current;
72
+ if (host === null || size === null || size.width <= 0 || size.height <= 0) {
73
+ setViewport(IDENTITY);
74
+ return;
75
+ }
76
+ const rect = host.getBoundingClientRect();
77
+ if (rect.width <= 0 || rect.height <= 0) {
78
+ // No layout (a hidden tab, happy-dom): identity until a size arrives.
79
+ setViewport(IDENTITY);
80
+ return;
81
+ }
82
+ const availableWidth = Math.max(1, rect.width - FIT_PADDING_PX * 2);
83
+ const availableHeight = Math.max(1, rect.height - FIT_PADDING_PX * 2);
84
+ const scale = clampScale(Math.min(availableWidth / size.width, availableHeight / size.height));
85
+ adjustedRef.current = false;
86
+ setViewport({
87
+ x: (rect.width - size.width * scale) / 2,
88
+ y: (rect.height - size.height * scale) / 2,
89
+ scale,
90
+ });
91
+ }, [hostRef]);
92
+
93
+ // Fit on arrival and on every new content size (a first render, a
94
+ // re-render with a different bounding box) and on host resize (the
95
+ // Source pane opening, the window), unless the person has zoomed or
96
+ // panned since the last fit: then their view stands until they fit again.
97
+ useEffect(() => {
98
+ if (!adjustedRef.current) fit();
99
+ const host = hostRef.current;
100
+ if (host === null || typeof ResizeObserver === 'undefined') return;
101
+ const observer = new ResizeObserver(() => {
102
+ if (!adjustedRef.current) fit();
103
+ });
104
+ observer.observe(host);
105
+ return () => observer.disconnect();
106
+ }, [fit, hostRef, content]);
107
+
108
+ const zoomBy = useCallback(
109
+ (factor: number, clientX?: number, clientY?: number) => {
110
+ const host = hostRef.current;
111
+ adjustedRef.current = true;
112
+ setViewport((current) => {
113
+ const scale = clampScale(current.scale * factor);
114
+ if (scale === current.scale) return current;
115
+ const rect = host?.getBoundingClientRect();
116
+ // Zoom about the pointer when given, else the host's center, so
117
+ // the part under the cursor stays under the cursor.
118
+ const px = clientX !== undefined && rect ? clientX - rect.left : (rect?.width ?? 0) / 2;
119
+ const py = clientY !== undefined && rect ? clientY - rect.top : (rect?.height ?? 0) / 2;
120
+ const ratio = scale / current.scale;
121
+ return {
122
+ x: px - (px - current.x) * ratio,
123
+ y: py - (py - current.y) * ratio,
124
+ scale,
125
+ };
126
+ });
127
+ },
128
+ [hostRef],
129
+ );
130
+
131
+ const panBy = useCallback((dx: number, dy: number) => {
132
+ adjustedRef.current = true;
133
+ setViewport((current) => ({ ...current, x: current.x + dx, y: current.y + dy }));
134
+ }, []);
135
+
136
+ const panIntoView = useCallback(
137
+ (target: ScreenRect) => {
138
+ const host = hostRef.current;
139
+ if (host === null) return;
140
+ const rect = host.getBoundingClientRect();
141
+ if (rect.width <= 0 || rect.height <= 0) return;
142
+ const inside =
143
+ target.left >= 0 &&
144
+ target.top >= 0 &&
145
+ target.left + target.width <= rect.width &&
146
+ target.top + target.height <= rect.height;
147
+ if (inside) return;
148
+ const dx = rect.width / 2 - (target.left + target.width / 2);
149
+ const dy = rect.height / 2 - (target.top + target.height / 2);
150
+ setViewport((current) => ({ ...current, x: current.x + dx, y: current.y + dy }));
151
+ },
152
+ [hostRef],
153
+ );
154
+
155
+ return { viewport, fit, zoomBy, panBy, panIntoView };
156
+ }
@@ -190,6 +190,18 @@ export interface HtmlViewerProps {
190
190
  currentPageUrl?: string;
191
191
  /** Live-mode page navigation reports (ready pageUrl + page-change). */
192
192
  onPageChange?: (pageUrl: string) => void;
193
+ /** A link the framed srcdoc document swallowed rather than navigating to.
194
+ * A srcdoc document's base URL is the PARENT page's, so an unhandled
195
+ * `<a href="other.html">` would load the host app inside the frame; the
196
+ * bridge suppresses every such navigation and relays the RAW href here
197
+ * (bounded and screened at the trust boundary) for the host to resolve —
198
+ * see `resolveHtmlLinkIntent`. In-page `#fragment` links are scrolled by
199
+ * the bridge and never reported. Never fires in live (`src`) sessions,
200
+ * which navigate the proxied app for real. */
201
+ onOpenLink?: (href: string) => void;
202
+ /** Fragment to scroll to once this document's bridge is ready, for a
203
+ * linked document opened from a `#`-carrying link. Bare id, no `#`. */
204
+ initialFragment?: string;
193
205
  annotations: Annotation[];
194
206
  onAddAnnotation: (ann: Annotation) => void;
195
207
  onSelectAnnotation: (id: string | null) => void;
@@ -209,6 +221,9 @@ export interface HtmlViewerProps {
209
221
  onAnnotateModeExit?: () => void;
210
222
  /** Mod+Shift+A pressed while focus lived inside the iframe. */
211
223
  onAnnotateModeToggle?: () => void;
224
+ /** Mod+Shift+X pressed while focus lived inside the iframe: show/hide the
225
+ * host's floating tools over the page. The host owns that state. */
226
+ onToolsToggle?: () => void;
212
227
  /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
213
228
  vimModeEnabled?: boolean;
214
229
  /** Replace the iframe-local compact badge with the shared live key HUD. */
@@ -308,6 +323,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
308
323
  liveSession,
309
324
  currentPageUrl,
310
325
  onPageChange,
326
+ onOpenLink,
327
+ initialFragment,
311
328
  annotations,
312
329
  onAddAnnotation,
313
330
  onSelectAnnotation,
@@ -317,6 +334,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
317
334
  annotateModeActive = true,
318
335
  onAnnotateModeExit,
319
336
  onAnnotateModeToggle,
337
+ onToolsToggle,
320
338
  vimModeEnabled = false,
321
339
  vimHudEnabled = false,
322
340
  vimHudKeyPanelEnabled = true,
@@ -372,6 +390,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
372
390
  onAnnotateModeExitRef.current = onAnnotateModeExit;
373
391
  const onAnnotateModeToggleRef = useRef(onAnnotateModeToggle);
374
392
  onAnnotateModeToggleRef.current = onAnnotateModeToggle;
393
+ const onToolsToggleRef = useRef(onToolsToggle);
394
+ onToolsToggleRef.current = onToolsToggle;
375
395
 
376
396
  /** Single choke point for direct-to-bridge posts: live sessions get the
377
397
  * token + concrete targetOrigin, srcdoc keeps "*" and no token. */
@@ -569,6 +589,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
569
589
  onResize: handleResize,
570
590
  live: liveSession,
571
591
  onPageChange,
592
+ onLinkClick: onOpenLink,
572
593
  onBridgePointer: handleBridgePointer,
573
594
  onUnanchoredChange: handleBridgeUnanchored,
574
595
  maxAdditionalTargets,
@@ -697,6 +718,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
697
718
  onAnnotateModeToggleRef.current?.();
698
719
  return;
699
720
  }
721
+ if (isRecord(e.data) && e.data.type === `${PREFIX}tools-toggle`) {
722
+ onToolsToggleRef.current?.();
723
+ return;
724
+ }
700
725
  const vimCopy = parseVimBridgeCopy(e.data);
701
726
  if (vimCopy !== null) {
702
727
  const iframe = iframeRef.current;
@@ -798,6 +823,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
798
823
  postToBridge({ type: `${PREFIX}report-unanchored` });
799
824
  }, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
800
825
 
826
+ // A linked document opened from a `#`-carrying link: the srcdoc document
827
+ // has no URL, so the fragment is replayed once its bridge is ready.
828
+ useEffect(() => {
829
+ if (iframeReadyVersion === 0 || !initialFragment) return;
830
+ postToBridge({ type: `${PREFIX}scroll-to-fragment`, fragment: initialFragment });
831
+ }, [iframeReadyVersion, initialFragment, postToBridge]);
832
+
801
833
  // Live page navigation with a ready iframe: explicitly clear the previous
802
834
  // page's marks, then re-apply the filtered set. Relying on dead anchors to
803
835
  // hide pins would risk cross-page text-search false matches and waste
@@ -157,8 +157,10 @@
157
157
  if (!document.body) return;
158
158
  if (annotateModeActive && currentInputMethod === 'pinpoint') {
159
159
  document.body.setAttribute('data-plannotator-pinpoint-cursor', '');
160
+ if (!LIVE) document.body.setAttribute('data-plannotator-frame-inert', '');
160
161
  } else {
161
162
  document.body.removeAttribute('data-plannotator-pinpoint-cursor');
163
+ document.body.removeAttribute('data-plannotator-frame-inert');
162
164
  }
163
165
  }
164
166
  var pinpointHover = null;
@@ -466,6 +468,13 @@
466
468
  scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
467
469
  }
468
470
 
471
+ else if (type === PREFIX + 'scroll-to-fragment') {
472
+ // A linked document opened from an in-page link carried a #fragment.
473
+ // The srcdoc document has no URL of its own, so the parent cannot set
474
+ // one: it replays the fragment here once the new document is ready.
475
+ scrollToLocalFragment(typeof e.data.fragment === 'string' ? e.data.fragment : '');
476
+ }
477
+
469
478
  else if (type === PREFIX + 'focus-mark') {
470
479
  focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
471
480
  }
@@ -681,11 +690,34 @@
681
690
  var svgGroup = node.closest('g');
682
691
  if (svgGroup) node = svgGroup;
683
692
  }
693
+ node = preferInertFrameAt(node, x, y);
684
694
  node = promoteTinyTarget(node);
685
695
  if (node === document.body || node === document.documentElement) return null;
686
696
  return node;
687
697
  }
688
698
 
699
+ // While frames are pointer-transparent (armed pinpoint, srcdoc sessions),
700
+ // hit-testing passes THROUGH an embedded document to the container painted
701
+ // behind it — so a click on an embed would pin its wrapper div. The embed is
702
+ // what the reviewer is pointing at and what the anchor must name, so a point
703
+ // inside a frame's own rect resolves to that frame. Bounded to the frames
704
+ // inside the element already resolved, so it costs nothing on ordinary pages.
705
+ var FRAME_SELECTOR = 'iframe,frame,embed,object';
706
+ function framesArePointerInert() {
707
+ return !LIVE && annotateModeActive && currentInputMethod === 'pinpoint';
708
+ }
709
+ function preferInertFrameAt(node, x, y) {
710
+ if (!framesArePointerInert() || !node.querySelectorAll) return node;
711
+ if (node.matches && node.matches(FRAME_SELECTOR)) return node;
712
+ var frames = node.querySelectorAll(FRAME_SELECTOR);
713
+ for (var i = 0; i < frames.length && i < 64; i++) {
714
+ var r = frames[i].getBoundingClientRect();
715
+ if (r.width <= 0 || r.height <= 0) continue;
716
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return frames[i];
717
+ }
718
+ return node;
719
+ }
720
+
689
721
  // Last-position reuse: a pointer that moved under 2px within 16ms resolves
690
722
  // to the cached element instead of re-hit-testing. The scroll reconcile
691
723
  // invalidates this cache — same point, different element after a scroll.
@@ -2517,8 +2549,9 @@
2517
2549
  // Constraints (the "smart" part is that they adapt to the element):
2518
2550
  // - attributes are an ALLOWLIST (a page cannot add a key); no form values,
2519
2551
  // no on* handlers, no style, no script/style/template contents ever;
2520
- // - absolute http(s) URLs lose their query and fragment (tokens live
2521
- // there), data: URIs keep only their media-type prefix;
2552
+ // - href/src URLs lose their query and fragment (tokens live there),
2553
+ // relative ones included, and data: URIs keep only their media-type
2554
+ // prefix;
2522
2555
  // - the outline tries two levels of children, falls back to one, then to a
2523
2556
  // per-tag count, whichever first fits CTX_MAX_OUTLINE, so a click on a
2524
2557
  // whole <main> costs the same bytes as a click on a chip;
@@ -2568,7 +2601,10 @@
2568
2601
  }
2569
2602
 
2570
2603
  // URL attribute values: keep what locates the element in source, drop
2571
- // what identifies the user. Relative URLs are route state and stay whole.
2604
+ // what identifies the user. The path survives in every form; the query and
2605
+ // the fragment never do — a relative URL carries the same per-visit state an
2606
+ // absolute one does (session ids, and the implicit-flow tokens that live in
2607
+ // the fragment specifically), so it is scrubbed the same way.
2572
2608
  function ctxScrubUrl(value) {
2573
2609
  var v = String(value).trim();
2574
2610
  if (/^javascript:/i.test(v)) return null;
@@ -2582,6 +2618,8 @@
2582
2618
  return u.origin + u.pathname + (u.search || u.hash ? '?…' : '');
2583
2619
  } catch (ex) { return ctxTruncate(v, CTX_MAX_ATTR_VALUE); }
2584
2620
  }
2621
+ var mark = v.search(/[?#]/);
2622
+ if (mark >= 0) return v.slice(0, mark) + '?…';
2585
2623
  return v;
2586
2624
  }
2587
2625
 
@@ -3280,6 +3318,74 @@
3280
3318
  return true;
3281
3319
  }
3282
3320
 
3321
+ // --- Local-site link navigation (srcdoc sessions only) ---
3322
+ // A srcdoc document has no URL of its own: its base URL is the PARENT page's,
3323
+ // which is the Plannotator server. So a plain link to 02-detail.html resolves
3324
+ // to http://localhost:<port>/02-detail.html, the server's catch-all answers
3325
+ // with the app itself, and the whole editor renders inside the annotated
3326
+ // frame. An in-page #section link is a cross-document navigation for the
3327
+ // same reason.
3328
+ //
3329
+ // The frame therefore never navigates itself. In-page fragments scroll here;
3330
+ // everything else is handed to the parent, which owns resolution against the
3331
+ // current document's directory and is the trust boundary for the href.
3332
+ // Registered BEFORE the pinpoint handler and never stopping propagation, so
3333
+ // an armed click still pins the link element exactly as it always did.
3334
+ //
3335
+ // Live app sessions are excluded outright: they navigate a real origin
3336
+ // through the proxy, which is the whole point of that surface.
3337
+ function scrollToLocalFragment(rawId) {
3338
+ var id = typeof rawId === 'string' ? rawId : '';
3339
+ try { id = decodeURIComponent(id); } catch (ex) {}
3340
+ if (!id) {
3341
+ try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (ex) { window.scrollTo(0, 0); }
3342
+ return true;
3343
+ }
3344
+ var target = null;
3345
+ try { target = document.getElementById(id); } catch (ex) {}
3346
+ if (!target) {
3347
+ var named = document.getElementsByName(id);
3348
+ if (named && named.length) target = named[0];
3349
+ }
3350
+ if (!target) return false;
3351
+ try { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
3352
+ catch (ex) { target.scrollIntoView(); }
3353
+ return true;
3354
+ }
3355
+
3356
+ function navigableLinkHref(node) {
3357
+ var el = node && node.nodeType === 1 ? node : node && node.parentElement;
3358
+ if (!el || !el.closest) return '';
3359
+ var link = el.closest('a,area');
3360
+ if (!link) return '';
3361
+ var raw = link.getAttribute('href');
3362
+ // SVG anchors may only carry xlink:href.
3363
+ if (typeof raw !== 'string') raw = link.getAttribute('xlink:href');
3364
+ return typeof raw === 'string' ? raw.trim() : '';
3365
+ }
3366
+
3367
+ if (!LIVE) {
3368
+ document.addEventListener('click', function(e) {
3369
+ if (e.defaultPrevented || e.button !== 0) return;
3370
+ if (isViewerOverlayNode(e.target)) return; // markers own their clicks
3371
+ var raw = navigableLinkHref(e.target);
3372
+ if (!raw) return;
3373
+ // The page's own scripting, not a navigation: leave it alone.
3374
+ if (/^javascript:/i.test(raw)) return;
3375
+ if (raw.charAt(0) === '#') {
3376
+ e.preventDefault();
3377
+ scrollToLocalFragment(raw.slice(1));
3378
+ return;
3379
+ }
3380
+ e.preventDefault();
3381
+ // Armed pinpoint: the click belongs to annotation, and the capture-phase
3382
+ // pinpoint handler below is about to pin this element. Navigation is
3383
+ // already suppressed above, which is all this surface owes the click.
3384
+ if (annotateModeActive && currentInputMethod === 'pinpoint') return;
3385
+ postToParent({ type: PREFIX + 'link-click', href: raw.slice(0, 2048) });
3386
+ }, true);
3387
+ }
3388
+
3283
3389
  document.addEventListener('click', function(e) {
3284
3390
  if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
3285
3391
  // Real placed markers (and any other viewer overlay) own their clicks —
@@ -3355,15 +3461,21 @@
3355
3461
  }
3356
3462
  });
3357
3463
 
3358
- // Mod+Shift+A toggles Interact/Annotate from inside the iframe (the parent
3359
- // registers the same chord, but focus usually lives in here on live apps).
3360
- // Capture phase so the page cannot swallow the reserved chord; the parent
3361
- // answers with set-annotate-mode.
3464
+ // The two reserved header chords, mirrored from inside the iframe (the
3465
+ // parent registers both, but focus usually lives in here on live apps):
3466
+ // Mod+Shift+A toggles Interact/Annotate, Mod+Shift+X shows/hides the
3467
+ // floating tools over the page. Capture phase so the page cannot swallow
3468
+ // them; the parent owns both states and answers annotate with
3469
+ // set-annotate-mode. Disarming tears down any pending draft through that
3470
+ // same set-annotate-mode(false) handler, exactly as Esc does.
3362
3471
  document.addEventListener('keydown', function(e) {
3363
3472
  if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return;
3364
- if (e.key !== 'a' && e.key !== 'A') return;
3473
+ var message = null;
3474
+ if (e.key === 'a' || e.key === 'A') message = 'annotate-toggle';
3475
+ else if (e.key === 'x' || e.key === 'X') message = 'tools-toggle';
3476
+ if (!message) return;
3365
3477
  e.preventDefault();
3366
- postToParent({ type: PREFIX + 'annotate-toggle' });
3478
+ postToParent({ type: PREFIX + message });
3367
3479
  }, true);
3368
3480
 
3369
3481
  // Author opt-in: a plain click on any element tagged [data-annotate] pops the
@@ -3,7 +3,7 @@
3
3
  // annotation CSS, protocol version and live bootstrap as ./bridge-script, with
4
4
  // the inline bridge literal stubbed out so a bundler drops it from the viewer
5
5
  // chunk. Rendering an HtmlViewer WITHOUT bridgeScriptUrl under this alias throws.
6
- export const ANNOTATION_HIGHLIGHT_CSS = "\n/* Committed annotation visuals (highlight rectangles + numbered placed\n * markers) render inside a shadow-rooted fixed overlay host — see OVERLAY_CSS\n * in the bridge script. Nothing annotation-related is ever wrapped into or\n * styled onto the author's own elements. */\n/* Vim pinpoint target tint. The MOUSE pinpoint path no longer mutates author\n * elements — it draws the dedicated overlay box below — but keyboard (vim)\n * navigation keeps this class-based visual. */\n.plannotator-pinpoint-hover {\n background-color: oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.12) !important;\n border-radius: 3px;\n cursor: pointer !important;\n}\n/* SVG groups can't render a CSS background, so use a soft glow instead. */\n.plannotator-pinpoint-hover:is(g, svg) {\n filter: drop-shadow(0 0 4px oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.55));\n}\n/* Mouse pinpoint hover: a fixed-position outline box sized to the hovered\n * element's rect. Never a class/style write on the page's own elements. */\n[data-plannotator-pinpoint-box] {\n position: fixed;\n z-index: 2147483643;\n pointer-events: none;\n display: none;\n box-sizing: border-box;\n border: 2px solid oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.85);\n border-radius: 5px;\n background: oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.06);\n}\n[data-plannotator-pinpoint-box].pn-pin-enter {\n animation: pn-pinpoint-in 0.12s ease-out;\n}\n[data-plannotator-pinpoint-box][data-pinned] {\n border-color: var(--pn-accent, #d97757);\n background: oklch(from var(--pn-accent, #d97757) l c h / 0.08);\n}\n@keyframes pn-pinpoint-in {\n from { opacity: 0; transform: scale(0.985); }\n to { opacity: 1; transform: scale(1); }\n}\n/* Pinpoint mode affordance: crosshair everywhere. Placed markers live in the\n * shadow overlay and keep their own pointer cursor there. */\nbody[data-plannotator-pinpoint-cursor],\nbody[data-plannotator-pinpoint-cursor] * {\n cursor: crosshair !important;\n}\n@media (prefers-reduced-motion: reduce) {\n [data-plannotator-pinpoint-box].pn-pin-enter {\n animation: none;\n }\n}\n@media print {\n /* Viewer overlays are review chrome, not page content: never bake pinpoint\n boxes/labels or vim UI into a printed page. The outer app chrome is\n print-hidden by print.css, but this CSS lives inside the iframe's own\n document and must carry its own rule. The annotation overlay host carries\n its own print rule inside its shadow root. */\n [data-plannotator-pinpoint-box],\n [data-plannotator-pinpoint-label],\n [data-plannotator-vim-ui],\n [data-plannotator-vim-cursor] {\n display: none !important;\n }\n}\n/* Print-parity layer: committed highlight rects re-projected into an\n * absolute-positioned light-DOM layer built on beforeprint and torn down on\n * afterprint (the fixed overlay cannot paginate). Guarded here so it can\n * never flash on screen even if an afterprint teardown is missed. */\n@media screen {\n [data-plannotator-print-layer] {\n display: none !important;\n }\n}\nbody[data-plannotator-vim-focus-owner]:focus {\n outline: none !important;\n}\n[data-plannotator-vim-cursor] {\n position: fixed;\n z-index: 2147483646;\n width: 2px;\n min-height: 1em;\n border-radius: 2px;\n background: var(--pn-focus-highlight, #4493f8);\n pointer-events: none;\n}\n[data-plannotator-vim-reticle] {\n position: fixed;\n z-index: 2147483645;\n inset: 0;\n overflow: visible;\n pointer-events: none;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-fill],\n[data-plannotator-vim-reticle] [data-vim-reticle-corner],\n[data-plannotator-vim-reticle] [data-vim-reticle-label] {\n position: absolute;\n top: 0;\n left: 0;\n will-change: transform;\n transition: transform 90ms cubic-bezier(.22,1,.36,1);\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-fill] {\n width: 100px;\n height: 100px;\n transform-origin: 0 0;\n border-radius: 8px;\n background: rgba(167,139,250,.045);\n box-shadow:\n inset 0 0 0 1px rgba(196,181,253,.16),\n 0 0 42px rgba(139,92,246,.12);\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner] {\n width: 28px;\n height: 28px;\n border-color: #c4b5fd;\n filter:\n drop-shadow(0 0 6px rgba(167,139,250,.92))\n drop-shadow(0 0 18px rgba(124,58,237,.42));\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner=\"top-left\"] {\n border-top: 3px solid;\n border-left: 3px solid;\n border-top-left-radius: 8px;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner=\"top-right\"] {\n border-top: 3px solid;\n border-right: 3px solid;\n border-top-right-radius: 8px;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner=\"bottom-left\"] {\n border-bottom: 3px solid;\n border-left: 3px solid;\n border-bottom-left-radius: 8px;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner=\"bottom-right\"] {\n border-right: 3px solid;\n border-bottom: 3px solid;\n border-bottom-right-radius: 8px;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-label] {\n z-index: 1;\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 118px;\n height: 30px;\n max-width: min(280px, calc(100vw - 24px));\n padding: 0 11px;\n overflow: hidden;\n border: 1px solid rgba(216,206,255,.42);\n border-radius: 9px;\n color: #f6f2ff;\n background: rgba(18,14,28,.84);\n box-shadow:\n 0 10px 28px rgba(0,0,0,.42),\n 0 0 20px rgba(139,92,246,.18);\n backdrop-filter: blur(10px);\n font: 700 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;\n letter-spacing: .13em;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-label]::before {\n width: 7px;\n height: 7px;\n flex: 0 0 auto;\n border-radius: 999px;\n background: #c4b5fd;\n box-shadow: 0 0 12px rgba(167,139,250,.94);\n content: \"\";\n}\n@media (prefers-reduced-motion: reduce) {\n [data-plannotator-vim-reticle] [data-vim-reticle-fill],\n [data-plannotator-vim-reticle] [data-vim-reticle-corner],\n [data-plannotator-vim-reticle] [data-vim-reticle-label] {\n transition: none;\n }\n}\n[data-plannotator-vim-badge] {\n position: fixed;\n z-index: 2147483647;\n left: 50%;\n bottom: 12px;\n transform: translateX(-50%);\n padding: 4px 9px;\n border: 1px solid color-mix(in srgb, var(--pn-focus-highlight, #4493f8) 35%, transparent);\n border-radius: 6px;\n background: color-mix(in srgb, var(--pn-background, #111) 94%, transparent);\n color: var(--pn-focus-highlight, #4493f8);\n box-shadow: 0 4px 18px rgba(0,0,0,.25);\n font: 700 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;\n letter-spacing: .04em;\n pointer-events: none;\n}\n";
6
+ export const ANNOTATION_HIGHLIGHT_CSS = "\n/* Committed annotation visuals (highlight rectangles + numbered placed\n * markers) render inside a shadow-rooted fixed overlay host — see OVERLAY_CSS\n * in the bridge script. Nothing annotation-related is ever wrapped into or\n * styled onto the author's own elements. */\n/* Vim pinpoint target tint. The MOUSE pinpoint path no longer mutates author\n * elements — it draws the dedicated overlay box below — but keyboard (vim)\n * navigation keeps this class-based visual. */\n.plannotator-pinpoint-hover {\n background-color: oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.12) !important;\n border-radius: 3px;\n cursor: pointer !important;\n}\n/* SVG groups can't render a CSS background, so use a soft glow instead. */\n.plannotator-pinpoint-hover:is(g, svg) {\n filter: drop-shadow(0 0 4px oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.55));\n}\n/* Mouse pinpoint hover: a fixed-position outline box sized to the hovered\n * element's rect. Never a class/style write on the page's own elements. */\n[data-plannotator-pinpoint-box] {\n position: fixed;\n z-index: 2147483643;\n pointer-events: none;\n display: none;\n box-sizing: border-box;\n border: 2px solid oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.85);\n border-radius: 5px;\n background: oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.06);\n}\n[data-plannotator-pinpoint-box].pn-pin-enter {\n animation: pn-pinpoint-in 0.12s ease-out;\n}\n[data-plannotator-pinpoint-box][data-pinned] {\n border-color: var(--pn-accent, #d97757);\n background: oklch(from var(--pn-accent, #d97757) l c h / 0.08);\n}\n@keyframes pn-pinpoint-in {\n from { opacity: 0; transform: scale(0.985); }\n to { opacity: 1; transform: scale(1); }\n}\n/* Pinpoint mode affordance: crosshair everywhere. Placed markers live in the\n * shadow overlay and keep their own pointer cursor there. */\nbody[data-plannotator-pinpoint-cursor],\nbody[data-plannotator-pinpoint-cursor] * {\n cursor: crosshair !important;\n}\n/* Armed pinpoint over an EMBEDDED local document: the embed is one element\n * from the outer page's point of view, and the bridge is never injected into a\n * nested frame, so a click inside it would simply vanish into another document.\n * Making frames transparent to the pointer while armed is what lets that click\n * pin the <iframe>/<embed>/<object> itself. Interact (Esc, the header pen or\n * Mod+Shift+A) restores native interaction inside the embed — which is also\n * the only state a link inside it can be followed from.\n * Live-app sessions never set this attribute: they annotate a real app whose\n * own nested frames belong to it. */\nbody[data-plannotator-frame-inert] :is(iframe, frame, embed, object) {\n pointer-events: none !important;\n}\n@media (prefers-reduced-motion: reduce) {\n [data-plannotator-pinpoint-box].pn-pin-enter {\n animation: none;\n }\n}\n@media print {\n /* Viewer overlays are review chrome, not page content: never bake pinpoint\n boxes/labels or vim UI into a printed page. The outer app chrome is\n print-hidden by print.css, but this CSS lives inside the iframe's own\n document and must carry its own rule. The annotation overlay host carries\n its own print rule inside its shadow root. */\n [data-plannotator-pinpoint-box],\n [data-plannotator-pinpoint-label],\n [data-plannotator-vim-ui],\n [data-plannotator-vim-cursor] {\n display: none !important;\n }\n}\n/* Print-parity layer: committed highlight rects re-projected into an\n * absolute-positioned light-DOM layer built on beforeprint and torn down on\n * afterprint (the fixed overlay cannot paginate). Guarded here so it can\n * never flash on screen even if an afterprint teardown is missed. */\n@media screen {\n [data-plannotator-print-layer] {\n display: none !important;\n }\n}\nbody[data-plannotator-vim-focus-owner]:focus {\n outline: none !important;\n}\n[data-plannotator-vim-cursor] {\n position: fixed;\n z-index: 2147483646;\n width: 2px;\n min-height: 1em;\n border-radius: 2px;\n background: var(--pn-focus-highlight, #4493f8);\n pointer-events: none;\n}\n[data-plannotator-vim-reticle] {\n position: fixed;\n z-index: 2147483645;\n inset: 0;\n overflow: visible;\n pointer-events: none;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-fill],\n[data-plannotator-vim-reticle] [data-vim-reticle-corner],\n[data-plannotator-vim-reticle] [data-vim-reticle-label] {\n position: absolute;\n top: 0;\n left: 0;\n will-change: transform;\n transition: transform 90ms cubic-bezier(.22,1,.36,1);\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-fill] {\n width: 100px;\n height: 100px;\n transform-origin: 0 0;\n border-radius: 8px;\n background: rgba(167,139,250,.045);\n box-shadow:\n inset 0 0 0 1px rgba(196,181,253,.16),\n 0 0 42px rgba(139,92,246,.12);\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner] {\n width: 28px;\n height: 28px;\n border-color: #c4b5fd;\n filter:\n drop-shadow(0 0 6px rgba(167,139,250,.92))\n drop-shadow(0 0 18px rgba(124,58,237,.42));\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner=\"top-left\"] {\n border-top: 3px solid;\n border-left: 3px solid;\n border-top-left-radius: 8px;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner=\"top-right\"] {\n border-top: 3px solid;\n border-right: 3px solid;\n border-top-right-radius: 8px;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner=\"bottom-left\"] {\n border-bottom: 3px solid;\n border-left: 3px solid;\n border-bottom-left-radius: 8px;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-corner=\"bottom-right\"] {\n border-right: 3px solid;\n border-bottom: 3px solid;\n border-bottom-right-radius: 8px;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-label] {\n z-index: 1;\n display: flex;\n align-items: center;\n gap: 8px;\n min-width: 118px;\n height: 30px;\n max-width: min(280px, calc(100vw - 24px));\n padding: 0 11px;\n overflow: hidden;\n border: 1px solid rgba(216,206,255,.42);\n border-radius: 9px;\n color: #f6f2ff;\n background: rgba(18,14,28,.84);\n box-shadow:\n 0 10px 28px rgba(0,0,0,.42),\n 0 0 20px rgba(139,92,246,.18);\n backdrop-filter: blur(10px);\n font: 700 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;\n letter-spacing: .13em;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n[data-plannotator-vim-reticle] [data-vim-reticle-label]::before {\n width: 7px;\n height: 7px;\n flex: 0 0 auto;\n border-radius: 999px;\n background: #c4b5fd;\n box-shadow: 0 0 12px rgba(167,139,250,.94);\n content: \"\";\n}\n@media (prefers-reduced-motion: reduce) {\n [data-plannotator-vim-reticle] [data-vim-reticle-fill],\n [data-plannotator-vim-reticle] [data-vim-reticle-corner],\n [data-plannotator-vim-reticle] [data-vim-reticle-label] {\n transition: none;\n }\n}\n[data-plannotator-vim-badge] {\n position: fixed;\n z-index: 2147483647;\n left: 50%;\n bottom: 12px;\n transform: translateX(-50%);\n padding: 4px 9px;\n border: 1px solid color-mix(in srgb, var(--pn-focus-highlight, #4493f8) 35%, transparent);\n border-radius: 6px;\n background: color-mix(in srgb, var(--pn-background, #111) 94%, transparent);\n color: var(--pn-focus-highlight, #4493f8);\n box-shadow: 0 4px 18px rgba(0,0,0,.25);\n font: 700 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;\n letter-spacing: .04em;\n pointer-events: none;\n}\n";
7
7
  export const BRIDGE_PROTOCOL_VERSION = 1;
8
8
  export const BRIDGE_SCRIPT = "";
9
9
  export const LIVE_BRIDGE_BOOTSTRAP = "(function() {\n var config = window.__plannotatorLiveConfig;\n if (!config || typeof config.css !== 'string') return;\n try {\n var style = document.createElement('style');\n style.setAttribute('data-plannotator-live-css', '');\n style.appendChild(document.createTextNode(config.css));\n (document.head || document.documentElement).appendChild(style);\n } catch (ex) {}\n})();";