@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.
- package/HANDOFF.md +46 -5
- package/README.md +6 -2
- package/components/DiagramBlock.tsx +376 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/MermaidBlock.tsx +17 -664
- package/components/Viewer.tsx +42 -2
- package/components/diagram/DiagramCanvas.tsx +431 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPopout.tsx +70 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +276 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/index.ts +36 -0
- package/components/diagram/useDiagramComments.ts +341 -0
- package/components/diagram/useDiagramRender.ts +91 -0
- package/components/diagram/useDiagramSourceDraft.ts +143 -0
- package/components/diagram/useDiagramViewport.ts +156 -0
- package/components/html-viewer/bridge-script.asset.js +25 -0
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +37 -0
- package/hooks/useAnnotationHighlighter.ts +5 -0
- package/package.json +5 -3
- package/styles.css +1 -1
- package/types.ts +5 -0
- package/utils/diagram-anchor-graphviz.ts +143 -0
- package/utils/diagram-anchor.ts +401 -0
- package/utils/diagram-projection.ts +66 -0
- package/utils/diagram-render.ts +668 -0
- package/utils/graphviz.ts +93 -0
- package/utils/parser.ts +15 -1
- 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
|
+
}
|
|
@@ -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.
|
|
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.
|
|
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": "
|
|
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",
|