@plannotator/ui 0.39.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +140 -10
- package/README.md +9 -5
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DiagramBlock.tsx +376 -0
- package/components/GraphvizBlock.tsx +22 -597
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +17 -630
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +67 -3
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/diagram/DiagramCanvas.tsx +431 -0
- package/components/diagram/DiagramComposer.tsx +135 -0
- package/components/diagram/DiagramOverlay.tsx +215 -0
- package/components/diagram/DiagramPopout.tsx +70 -0
- package/components/diagram/DiagramSourcePane.tsx +244 -0
- package/components/diagram/DiagramViewer.tsx +276 -0
- package/components/diagram/anchorClaims.ts +71 -0
- package/components/diagram/index.ts +36 -0
- package/components/diagram/useDiagramComments.ts +341 -0
- package/components/diagram/useDiagramRender.ts +91 -0
- package/components/diagram/useDiagramSourceDraft.ts +143 -0
- package/components/diagram/useDiagramViewport.ts +156 -0
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +121 -9
- package/components/html-viewer/bridge-script.lite.ts +1 -1
- package/components/html-viewer/bridge-script.ts +133 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +469 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +6 -4
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/types.ts +5 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/diagram-anchor-graphviz.ts +143 -0
- package/utils/diagram-anchor.ts +401 -0
- package/utils/diagram-projection.ts +66 -0
- package/utils/diagram-render.ts +668 -0
- package/utils/graphviz.ts +93 -0
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +36 -7
- package/utils/terminalToolsAnnouncement.ts +76 -0
- package/components/mermaidSvg.ts +0 -33
|
@@ -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
|
-
// -
|
|
2521
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
3359
|
-
// registers
|
|
3360
|
-
//
|
|
3361
|
-
//
|
|
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
|
-
|
|
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 +
|
|
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})();";
|