@plannotator/ui 0.40.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.
@@ -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
+ }
@@ -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;
@@ -688,11 +690,34 @@
688
690
  var svgGroup = node.closest('g');
689
691
  if (svgGroup) node = svgGroup;
690
692
  }
693
+ node = preferInertFrameAt(node, x, y);
691
694
  node = promoteTinyTarget(node);
692
695
  if (node === document.body || node === document.documentElement) return null;
693
696
  return node;
694
697
  }
695
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
+
696
721
  // Last-position reuse: a pointer that moved under 2px within 16ms resolves
697
722
  // to the cached element instead of re-hit-testing. The scroll reconcile
698
723
  // invalidates this cache — same point, different element after a scroll.
@@ -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})();";
@@ -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;
@@ -912,11 +926,34 @@ export const BRIDGE_SCRIPT = `(function() {
912
926
  var svgGroup = node.closest('g');
913
927
  if (svgGroup) node = svgGroup;
914
928
  }
929
+ node = preferInertFrameAt(node, x, y);
915
930
  node = promoteTinyTarget(node);
916
931
  if (node === document.body || node === document.documentElement) return null;
917
932
  return node;
918
933
  }
919
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
+
920
957
  // Last-position reuse: a pointer that moved under 2px within 16ms resolves
921
958
  // to the cached element instead of re-hit-testing. The scroll reconcile
922
959
  // invalidates this cache — same point, different element after a scroll.
@@ -1036,6 +1036,11 @@ export function useAnnotationHighlighter({
1036
1036
 
1037
1037
  anns.forEach(ann => {
1038
1038
  if (ann.type === AnnotationType.GLOBAL_COMMENT) return;
1039
+ // A comment on a rendered diagram part has no text anchor: the
1040
+ // diagram overlay restores it against its render and reports its own
1041
+ // verdict, so it is neither attempted nor unanchored here (the same
1042
+ // rule the raw-HTML pinpoints follow on their surface).
1043
+ if (ann.diagramAnchor) return;
1039
1044
  attempted.push(ann.id);
1040
1045
 
1041
1046
  // Skip if already highlighted
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",
@@ -10,6 +10,8 @@
10
10
  "./components/goal-setup/*": "./components/goal-setup/*.tsx",
11
11
  "./components/ImageAnnotator": "./components/ImageAnnotator/index.tsx",
12
12
  "./components/html-viewer": "./components/html-viewer/index.ts",
13
+ "./components/diagram": "./components/diagram/index.ts",
14
+ "./components/diagram/*": "./components/diagram/*.tsx",
13
15
  "./components/html-viewer/bridge-script": "./components/html-viewer/bridge-script.ts",
14
16
  "./components/html-viewer/bridge-script.asset.js": "./components/html-viewer/bridge-script.asset.js",
15
17
  "./components/html-viewer/bridge-script.lite": "./components/html-viewer/bridge-script.lite.ts",
@@ -74,11 +76,11 @@
74
76
  "@lezer/highlight": "^1.2.3",
75
77
  "@pierre/diffs": "1.3.6",
76
78
  "@plannotator/atomic-editor": "^0.8.0",
77
- "@plannotator/core": "0.25.3",
79
+ "@plannotator/core": "0.25.4",
78
80
  "@plannotator/markdown-editor": "^0.4.0",
79
81
  "@plannotator/web-highlighter": "^0.8.1",
80
82
  "@tanstack/react-table": "^8.21.3",
81
- "@viz-js/viz": "^3.29.0",
83
+ "@viz-js/viz": "3.30.0",
82
84
  "class-variance-authority": "^0.7.1",
83
85
  "clsx": "^2.1.1",
84
86
  "diff": "^8.0.4",