@plannotator/ui 0.29.1 → 0.31.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/README.md +1 -0
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +68 -35
- package/components/AnnotationToolbar.tsx +28 -8
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +212 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/InlineMarkdown.tsx +3 -2
- package/components/KeyboardShortcuts.tsx +9 -0
- package/components/Landing.tsx +1 -1
- package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
- package/components/MarkdownEditor/embedPicker.ts +349 -0
- package/components/MarkdownEditor.tsx +12 -0
- package/components/ModeToggle.tsx +2 -1
- package/components/PermissionModeSetup.tsx +24 -5
- package/components/PinpointOverlay.tsx +11 -6
- package/components/PlanHeaderMenu.tsx +140 -1
- package/components/SearchableSelect.tsx +2 -0
- package/components/Settings.tsx +136 -10
- package/components/SkillReferenceMenu.tsx +9 -0
- package/components/StickyHeaderLane.tsx +9 -2
- package/components/TableOfContents.tsx +9 -4
- package/components/TextShimmer.tsx +8 -5
- package/components/ThemeProvider.tsx +43 -1
- package/components/ThemeTab.tsx +52 -1
- package/components/Tooltip.tsx +3 -1
- package/components/Viewer.tsx +19 -5
- package/components/VimTargetReticle.tsx +12 -4
- package/components/ai/DocumentAIChatPanel.tsx +1 -0
- package/components/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +215 -39
- package/components/html-viewer/bridge-script.ts +384 -66
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/useHtmlAnnotation.ts +179 -58
- package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
- package/components/sidebar/FileBrowser.tsx +17 -5
- package/components/sidebar/SidebarContainer.tsx +124 -28
- package/components/ui/button.tsx +10 -8
- package/components/ui/dialog.tsx +35 -25
- package/config/index.ts +6 -1
- package/config/reviewView.ts +42 -9
- package/config/settings.ts +141 -0
- package/hooks/useAIProviderConfig.ts +8 -7
- package/hooks/useActiveSection.ts +6 -4
- package/hooks/useAgentJobs.ts +3 -0
- package/hooks/useAnnotationHighlighter.ts +20 -0
- package/hooks/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +3 -2
- package/shortcuts/index.ts +3 -0
- package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
- package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
- package/styles.css +1 -1
- package/theme.css +229 -0
- package/types.ts +34 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/htmlChrome.ts +20 -16
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/parser.ts +37 -2
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -0
- package/components/PlanAIAnnouncementDialog.tsx +0 -187
- package/components/VimModeAnnouncementDialog.tsx +0 -557
- package/utils/planAIAnnouncement.ts +0 -17
- package/utils/vimModeAnnouncement.ts +0 -23
|
@@ -4,7 +4,11 @@ import {
|
|
|
4
4
|
useState,
|
|
5
5
|
type RefObject,
|
|
6
6
|
} from 'react';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
addScrollViewportListener,
|
|
9
|
+
getScrollViewportRect,
|
|
10
|
+
useScrollViewport,
|
|
11
|
+
} from '../hooks/useScrollViewport';
|
|
8
12
|
import type { SemanticTarget } from '../utils/blockTargeting';
|
|
9
13
|
import {
|
|
10
14
|
createRangeBetweenTextPositions,
|
|
@@ -186,7 +190,9 @@ export function VimTargetReticle({
|
|
|
186
190
|
return;
|
|
187
191
|
}
|
|
188
192
|
const containerRect = container.getBoundingClientRect();
|
|
189
|
-
const viewportTop = scrollViewport
|
|
193
|
+
const viewportTop = scrollViewport
|
|
194
|
+
? getScrollViewportRect(scrollViewport).top
|
|
195
|
+
: containerRect.top;
|
|
190
196
|
const stickyBottom = container
|
|
191
197
|
.querySelector<HTMLElement>('[data-sticky-actions]')
|
|
192
198
|
?.getBoundingClientRect()
|
|
@@ -205,7 +211,9 @@ export function VimTargetReticle({
|
|
|
205
211
|
|
|
206
212
|
update();
|
|
207
213
|
window.addEventListener('resize', scheduleUpdate, { passive: true });
|
|
208
|
-
|
|
214
|
+
const removeScrollListener = scrollViewport
|
|
215
|
+
? addScrollViewportListener(scrollViewport, scheduleUpdate)
|
|
216
|
+
: undefined;
|
|
209
217
|
const resizeObserver = typeof ResizeObserver === 'undefined'
|
|
210
218
|
? null
|
|
211
219
|
: new ResizeObserver(scheduleUpdate);
|
|
@@ -214,7 +222,7 @@ export function VimTargetReticle({
|
|
|
214
222
|
return () => {
|
|
215
223
|
cancelAnimationFrame(rafRef.current);
|
|
216
224
|
window.removeEventListener('resize', scheduleUpdate);
|
|
217
|
-
|
|
225
|
+
removeScrollListener?.();
|
|
218
226
|
resizeObserver?.disconnect();
|
|
219
227
|
};
|
|
220
228
|
}, [containerRef, restoredState, scrollViewport, target]);
|
|
@@ -268,6 +268,7 @@ const GeneralInput: React.FC<{
|
|
|
268
268
|
<div className="border-t border-border/50 p-2">
|
|
269
269
|
<div className="flex items-end gap-1.5">
|
|
270
270
|
<textarea
|
|
271
|
+
data-pn-mobile-editable="true"
|
|
271
272
|
ref={textareaRef}
|
|
272
273
|
value={value}
|
|
273
274
|
onChange={(event) => onChange(event.target.value)}
|
|
@@ -13,6 +13,12 @@ function cx(...classes: Array<string | false | null | undefined>): string {
|
|
|
13
13
|
return classes.filter(Boolean).join(' ');
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Legacy shared button primitive.
|
|
18
|
+
*
|
|
19
|
+
* The visual sizes remain compact, while the shared touch marker expands the
|
|
20
|
+
* physical target only when a coarse pointer is present.
|
|
21
|
+
*/
|
|
16
22
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
|
|
17
23
|
className,
|
|
18
24
|
variant = 'outline',
|
|
@@ -22,23 +28,25 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
|
|
|
22
28
|
...props
|
|
23
29
|
}, ref) => (
|
|
24
30
|
<button
|
|
31
|
+
{...props}
|
|
25
32
|
ref={ref}
|
|
26
33
|
type={type}
|
|
34
|
+
data-pn-touch-target="true"
|
|
35
|
+
data-pn-touch-target-icon={size === 'icon' || variant === 'icon' ? 'true' : undefined}
|
|
27
36
|
className={cx(
|
|
28
37
|
'inline-flex items-center justify-center gap-2 rounded-md text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50',
|
|
29
38
|
size === 'sm' && 'h-8 px-2.5',
|
|
30
39
|
size === 'md' && 'h-9 px-3',
|
|
31
40
|
size === 'icon' && 'h-8 w-8',
|
|
32
|
-
variant === 'primary' && 'bg-primary text-primary-foreground hover:opacity-90',
|
|
33
|
-
variant === 'outline' && 'border border-border bg-card text-foreground hover:bg-muted',
|
|
34
|
-
variant === 'ghost' && 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
41
|
+
variant === 'primary' && 'bg-primary text-primary-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:opacity-90',
|
|
42
|
+
variant === 'outline' && 'border border-border bg-card text-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted',
|
|
43
|
+
variant === 'ghost' && 'text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted [@media(hover:hover)_and_(pointer:fine)]:hover:text-foreground',
|
|
35
44
|
variant === 'icon' && (active
|
|
36
45
|
? 'border border-primary/50 bg-primary/10 text-primary'
|
|
37
|
-
: 'border border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground'),
|
|
38
|
-
variant === 'danger' && 'border border-border bg-background text-muted-foreground hover:border-destructive/50 hover:bg-destructive/10 hover:text-destructive',
|
|
46
|
+
: 'border border-border bg-background text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted [@media(hover:hover)_and_(pointer:fine)]:hover:text-foreground'),
|
|
47
|
+
variant === 'danger' && 'border border-border bg-background text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:border-destructive/50 [@media(hover:hover)_and_(pointer:fine)]:hover:bg-destructive/10 [@media(hover:hover)_and_(pointer:fine)]:hover:text-destructive',
|
|
39
48
|
className
|
|
40
49
|
)}
|
|
41
|
-
{...props}
|
|
42
50
|
/>
|
|
43
51
|
));
|
|
44
52
|
Button.displayName = 'Button';
|
|
@@ -29,7 +29,6 @@ import {
|
|
|
29
29
|
type CommentAskAIHandler,
|
|
30
30
|
type CommentTargetChip,
|
|
31
31
|
} from "../CommentPopover";
|
|
32
|
-
import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker";
|
|
33
32
|
import { VimKeyHud } from "../VimKeyHud";
|
|
34
33
|
import type { ViewerHandle } from "../Viewer";
|
|
35
34
|
import {
|
|
@@ -38,7 +37,12 @@ import {
|
|
|
38
37
|
type ComposerYieldState,
|
|
39
38
|
} from "./composerYield";
|
|
40
39
|
import { buildSyncNumbering } from "./annotationNumbering";
|
|
41
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
MAX_PAGE_URL_LENGTH,
|
|
42
|
+
rejectsLiveMessage,
|
|
43
|
+
useHtmlAnnotation,
|
|
44
|
+
type HtmlLiveSession,
|
|
45
|
+
} from "./useHtmlAnnotation";
|
|
42
46
|
import {
|
|
43
47
|
THEME_TOKENS,
|
|
44
48
|
buildSrcdocInjection,
|
|
@@ -138,6 +142,17 @@ function parseVimBridgeCopy(value: unknown): string | null {
|
|
|
138
142
|
/** Inputs for the sandboxed raw-HTML viewer and its parent-side annotation UI. */
|
|
139
143
|
export interface HtmlViewerProps {
|
|
140
144
|
rawHtml: string;
|
|
145
|
+
/** Live proxied-app mode: render `src` (no sandbox, no srcdoc) instead of
|
|
146
|
+
* rawHtml. The caller must also set `fullViewport` and `liveSession`. */
|
|
147
|
+
src?: string;
|
|
148
|
+
/** Live session credentials paired with `src`: proxy origin + per-session
|
|
149
|
+
* token, validated on every inbound message and stamped on every post. */
|
|
150
|
+
liveSession?: HtmlLiveSession;
|
|
151
|
+
/** Current page (pathname + search) in a live multi-page session. Restore
|
|
152
|
+
* filters annotations to this page; changing it re-applies the filter. */
|
|
153
|
+
currentPageUrl?: string;
|
|
154
|
+
/** Live-mode page navigation reports (ready pageUrl + page-change). */
|
|
155
|
+
onPageChange?: (pageUrl: string) => void;
|
|
141
156
|
annotations: Annotation[];
|
|
142
157
|
onAddAnnotation: (ann: Annotation) => void;
|
|
143
158
|
onSelectAnnotation: (id: string | null) => void;
|
|
@@ -145,6 +160,18 @@ export interface HtmlViewerProps {
|
|
|
145
160
|
mode: EditorMode;
|
|
146
161
|
/** Input method: 'drag' = text selection, 'pinpoint' = click an element. */
|
|
147
162
|
inputMethod: InputMethod;
|
|
163
|
+
/** Interact/Annotate toggle for HTML and live-app surfaces. While false
|
|
164
|
+
* the bridge keeps clicks native (no pinpoint capture, no hover outline)
|
|
165
|
+
* and clicks/forms/navigation reach the page untouched. Text
|
|
166
|
+
* drag-selection commenting stays live in BOTH modes, and committed
|
|
167
|
+
* markers stay visible and clickable in BOTH modes. Default true (armed)
|
|
168
|
+
* on both surface kinds. */
|
|
169
|
+
annotateModeActive?: boolean;
|
|
170
|
+
/** Esc final rung (bridge-side or the parent-side listener here): the user
|
|
171
|
+
* asked to leave Annotate for Interact. The host owns the mode state. */
|
|
172
|
+
onAnnotateModeExit?: () => void;
|
|
173
|
+
/** Mod+Shift+A pressed while focus lived inside the iframe. */
|
|
174
|
+
onAnnotateModeToggle?: () => void;
|
|
148
175
|
/** Opt-in Vim-style keyboard selection. Default false for compatibility. */
|
|
149
176
|
vimModeEnabled?: boolean;
|
|
150
177
|
/** Replace the iframe-local compact badge with the shared live key HUD. */
|
|
@@ -172,6 +199,11 @@ export interface HtmlViewerProps {
|
|
|
172
199
|
onAskAI?: CommentAskAIHandler;
|
|
173
200
|
/** Disable every annotation mutation entry point while preserving reading and navigation. */
|
|
174
201
|
readOnly?: boolean;
|
|
202
|
+
/** Reports the full set of annotation ids with no live representation on
|
|
203
|
+
* the page (fail-closed anchors hide markers rather than guess). Called
|
|
204
|
+
* with the complete current set whenever it changes, including back to
|
|
205
|
+
* empty on recovery. Fires in readOnly mode too. */
|
|
206
|
+
onUnanchoredChange?: (ids: string[]) => void;
|
|
175
207
|
/** Accessible iframe title. */
|
|
176
208
|
title?: string;
|
|
177
209
|
}
|
|
@@ -184,12 +216,19 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
184
216
|
(
|
|
185
217
|
{
|
|
186
218
|
rawHtml,
|
|
219
|
+
src,
|
|
220
|
+
liveSession,
|
|
221
|
+
currentPageUrl,
|
|
222
|
+
onPageChange,
|
|
187
223
|
annotations,
|
|
188
224
|
onAddAnnotation,
|
|
189
225
|
onSelectAnnotation,
|
|
190
226
|
selectedAnnotationId,
|
|
191
227
|
mode,
|
|
192
228
|
inputMethod,
|
|
229
|
+
annotateModeActive = true,
|
|
230
|
+
onAnnotateModeExit,
|
|
231
|
+
onAnnotateModeToggle,
|
|
193
232
|
vimModeEnabled = false,
|
|
194
233
|
vimHudEnabled = false,
|
|
195
234
|
vimHudKeyPanelEnabled = true,
|
|
@@ -205,6 +244,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
205
244
|
onToggleDiff,
|
|
206
245
|
onAskAI,
|
|
207
246
|
readOnly = false,
|
|
247
|
+
onUnanchoredChange,
|
|
208
248
|
title = "HTML Plan Viewer",
|
|
209
249
|
},
|
|
210
250
|
ref,
|
|
@@ -227,11 +267,44 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
227
267
|
contextText: string;
|
|
228
268
|
} | null>(null);
|
|
229
269
|
|
|
270
|
+
// Live proxied-app mode: the iframe navigates a real origin, so the
|
|
271
|
+
// srcdoc pipeline is skipped entirely and its messages carry credentials.
|
|
272
|
+
const liveMode = !!src;
|
|
273
|
+
const liveSessionRef = useRef<HtmlLiveSession | null>(liveSession ?? null);
|
|
274
|
+
liveSessionRef.current = liveSession ?? null;
|
|
275
|
+
const onPageChangeRef = useRef(onPageChange);
|
|
276
|
+
onPageChangeRef.current = onPageChange;
|
|
277
|
+
const onAnnotateModeExitRef = useRef(onAnnotateModeExit);
|
|
278
|
+
onAnnotateModeExitRef.current = onAnnotateModeExit;
|
|
279
|
+
const onAnnotateModeToggleRef = useRef(onAnnotateModeToggle);
|
|
280
|
+
onAnnotateModeToggleRef.current = onAnnotateModeToggle;
|
|
281
|
+
|
|
282
|
+
/** Single choke point for direct-to-bridge posts: live sessions get the
|
|
283
|
+
* token + concrete targetOrigin, srcdoc keeps "*" and no token. */
|
|
284
|
+
const postToBridge = useCallback((msg: Record<string, unknown>) => {
|
|
285
|
+
const win = iframeRef.current?.contentWindow;
|
|
286
|
+
if (!win) return;
|
|
287
|
+
const live = liveSessionRef.current;
|
|
288
|
+
if (live) {
|
|
289
|
+
// Browsers silently drop posts whose targetOrigin does not match the
|
|
290
|
+
// receiving window (mid-navigation frames); some DOM environments
|
|
291
|
+
// throw instead, so align with the browser semantics explicitly.
|
|
292
|
+
try {
|
|
293
|
+
win.postMessage({ ...msg, token: live.token }, live.origin);
|
|
294
|
+
} catch {
|
|
295
|
+
// Dropped, matching browser behavior for unmatched target origins.
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
win.postMessage(msg, "*");
|
|
299
|
+
}
|
|
300
|
+
}, []);
|
|
301
|
+
|
|
230
302
|
// Host theming is opt-in per document (Plannotator-generated artifacts tag
|
|
231
303
|
// themselves); arbitrary HTML renders untouched, like a standalone tab.
|
|
232
|
-
const hostTheme = useMemo(() => hasHostThemeOptIn(rawHtml), [rawHtml]);
|
|
304
|
+
const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
|
|
233
305
|
|
|
234
306
|
const srcdoc = useMemo(() => {
|
|
307
|
+
if (liveMode) return undefined; // src mode: the proxy injects the bridge
|
|
235
308
|
const injection = buildSrcdocInjection({
|
|
236
309
|
tokens: readThemeTokens(),
|
|
237
310
|
isLight: isLightTheme(),
|
|
@@ -239,11 +312,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
239
312
|
diffActive: !!diffActive,
|
|
240
313
|
});
|
|
241
314
|
return injectIntoHead(rawHtml, injection);
|
|
242
|
-
}, [rawHtml, hostTheme, diffActive]);
|
|
315
|
+
}, [liveMode, rawHtml, hostTheme, diffActive]);
|
|
243
316
|
|
|
244
317
|
const handleResize = useCallback((height: number) => {
|
|
318
|
+
if (liveMode) return; // live surfaces are full-viewport; height is ignored
|
|
245
319
|
setIframeHeight(height);
|
|
246
|
-
}, []);
|
|
320
|
+
}, [liveMode]);
|
|
247
321
|
|
|
248
322
|
// Composer yield while shift-selecting (multi-target drafts): fade the
|
|
249
323
|
// composer as the pointer approaches, click-through when over it. Pointer
|
|
@@ -293,7 +367,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
293
367
|
selectedAnnotationId,
|
|
294
368
|
mode,
|
|
295
369
|
onResize: handleResize,
|
|
370
|
+
live: liveSession,
|
|
371
|
+
onPageChange,
|
|
296
372
|
onBridgePointer: handleBridgePointer,
|
|
373
|
+
onUnanchoredChange,
|
|
297
374
|
});
|
|
298
375
|
|
|
299
376
|
const multiSelectActive = !readOnly && !!hook.commentPopover && hook.draftTargets.length > 0;
|
|
@@ -346,11 +423,36 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
346
423
|
useEffect(() => {
|
|
347
424
|
function handler(e: MessageEvent<unknown>) {
|
|
348
425
|
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
426
|
+
// Live sessions verify origin + token before reading anything.
|
|
427
|
+
const live = liveSessionRef.current;
|
|
428
|
+
if (live && rejectsLiveMessage(live, e.origin, e.data)) return;
|
|
349
429
|
if (isBridgeReadyMessage(e.data)) {
|
|
350
430
|
setIframeReadyVersion((version) => version + 1);
|
|
351
431
|
setVimBridgePhase("inactive");
|
|
352
432
|
setVimHudCommand(null);
|
|
353
433
|
setVimHelpOpen(false);
|
|
434
|
+
// Live ready carries the page identity (validated like page-change)
|
|
435
|
+
// so reloads and cross-page navigations re-anchor the restore filter.
|
|
436
|
+
if (live && isRecord(e.data)) {
|
|
437
|
+
const pageUrl = e.data.pageUrl;
|
|
438
|
+
if (
|
|
439
|
+
typeof pageUrl === "string"
|
|
440
|
+
&& pageUrl.length > 0
|
|
441
|
+
&& pageUrl.length <= MAX_PAGE_URL_LENGTH
|
|
442
|
+
) {
|
|
443
|
+
onPageChangeRef.current?.(pageUrl);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
// Interact/Annotate mode messages ride the same authenticated path:
|
|
449
|
+
// live sessions already rejected wrong-origin/tokenless data above.
|
|
450
|
+
if (isRecord(e.data) && e.data.type === `${PREFIX}annotate-exit`) {
|
|
451
|
+
onAnnotateModeExitRef.current?.();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
if (isRecord(e.data) && e.data.type === `${PREFIX}annotate-toggle`) {
|
|
455
|
+
onAnnotateModeToggleRef.current?.();
|
|
354
456
|
return;
|
|
355
457
|
}
|
|
356
458
|
const vimCopy = parseVimBridgeCopy(e.data);
|
|
@@ -401,9 +503,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
401
503
|
|
|
402
504
|
const handleVimHelpOpenChange = useCallback((open: boolean) => {
|
|
403
505
|
setVimHelpOpen(open);
|
|
404
|
-
|
|
506
|
+
postToBridge(
|
|
405
507
|
{ type: `${PREFIX}set-vim-help`, open },
|
|
406
|
-
"*",
|
|
407
508
|
);
|
|
408
509
|
}, []);
|
|
409
510
|
|
|
@@ -418,9 +519,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
418
519
|
if (document.activeElement === iframe) return false;
|
|
419
520
|
iframe.focus({ preventScroll: true });
|
|
420
521
|
if (document.activeElement !== iframe) return false;
|
|
421
|
-
|
|
522
|
+
postToBridge(
|
|
422
523
|
{ type: `${PREFIX}focus-vim` },
|
|
423
|
-
"*",
|
|
424
524
|
);
|
|
425
525
|
return true;
|
|
426
526
|
}, [readOnly, vimModeEnabled]);
|
|
@@ -431,13 +531,46 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
431
531
|
focusDocument: focusVimDocument,
|
|
432
532
|
});
|
|
433
533
|
|
|
534
|
+
// Restore filter for live multi-page sessions: only annotations made on
|
|
535
|
+
// the current page (or without page identity) are pushed for restoration.
|
|
536
|
+
// Numbering (sync-annotations) still ships the FULL list: numbers are
|
|
537
|
+
// parent-authoritative and global across pages, matching export.
|
|
538
|
+
const forCurrentPage = useCallback(
|
|
539
|
+
(anns: Annotation[]) =>
|
|
540
|
+
anns.filter((a) => !a.pageUrl || a.pageUrl === currentPageUrl),
|
|
541
|
+
[currentPageUrl],
|
|
542
|
+
);
|
|
543
|
+
|
|
434
544
|
useEffect(() => {
|
|
435
545
|
if (iframeReadyVersion === 0) return;
|
|
436
|
-
|
|
437
|
-
|
|
546
|
+
const restorable = forCurrentPage(annotations);
|
|
547
|
+
if (restorable.length > 0) {
|
|
548
|
+
hook.applyAnnotations(restorable);
|
|
438
549
|
}
|
|
439
550
|
}, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
440
551
|
|
|
552
|
+
// Live page navigation with a ready iframe: explicitly clear the previous
|
|
553
|
+
// page's marks, then re-apply the filtered set. Relying on dead anchors to
|
|
554
|
+
// hide pins would risk cross-page text-search false matches and waste
|
|
555
|
+
// reconcile budget.
|
|
556
|
+
const lastAppliedPageRef = useRef<string | undefined>(currentPageUrl);
|
|
557
|
+
useEffect(() => {
|
|
558
|
+
if (lastAppliedPageRef.current === currentPageUrl) return;
|
|
559
|
+
lastAppliedPageRef.current = currentPageUrl;
|
|
560
|
+
if (iframeReadyVersion === 0) return;
|
|
561
|
+
postToBridge({ type: `${PREFIX}clear-marks` });
|
|
562
|
+
const restorable = forCurrentPage(annotations);
|
|
563
|
+
if (restorable.length > 0) {
|
|
564
|
+
hook.applyAnnotations(restorable);
|
|
565
|
+
}
|
|
566
|
+
// clear-marks drops the bridge's synced numbering; re-establish it so
|
|
567
|
+
// restored markers keep their export-matching global numbers.
|
|
568
|
+
postToBridge({
|
|
569
|
+
type: `${PREFIX}sync-annotations`,
|
|
570
|
+
annotations: buildSyncNumbering(annotations),
|
|
571
|
+
});
|
|
572
|
+
}, [currentPageUrl, iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
573
|
+
|
|
441
574
|
// Placed-marker numbering is parent-authoritative and matches the
|
|
442
575
|
// numbers exportAnnotations writes into the submitted feedback: the full
|
|
443
576
|
// list INCLUDING globals is numbered by ARRAY position (the export's
|
|
@@ -447,9 +580,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
447
580
|
// order is only a pre-sync fallback.
|
|
448
581
|
useEffect(() => {
|
|
449
582
|
if (iframeReadyVersion === 0) return;
|
|
450
|
-
|
|
583
|
+
postToBridge(
|
|
451
584
|
{ type: `${PREFIX}sync-annotations`, annotations: buildSyncNumbering(annotations) },
|
|
452
|
-
"*",
|
|
453
585
|
);
|
|
454
586
|
}, [iframeReadyVersion, annotations]);
|
|
455
587
|
|
|
@@ -457,31 +589,67 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
457
589
|
// ready (fresh iframe) and whenever the user switches it in the toolstrip.
|
|
458
590
|
useEffect(() => {
|
|
459
591
|
if (iframeReadyVersion === 0) return;
|
|
460
|
-
|
|
592
|
+
postToBridge(
|
|
461
593
|
{ type: `${PREFIX}set-input-method`, method: inputMethod },
|
|
462
|
-
"*",
|
|
463
594
|
);
|
|
464
595
|
}, [iframeReadyVersion, inputMethod]);
|
|
465
596
|
|
|
597
|
+
// Tell the bridge whether Annotate is armed. Same re-post pattern as
|
|
598
|
+
// set-input-method, so the mode survives live page changes / HMR reloads
|
|
599
|
+
// and bridge re-injection without ever reloading the iframe.
|
|
600
|
+
useEffect(() => {
|
|
601
|
+
if (iframeReadyVersion === 0) return;
|
|
602
|
+
postToBridge(
|
|
603
|
+
{ type: `${PREFIX}set-annotate-mode`, active: annotateModeActive },
|
|
604
|
+
);
|
|
605
|
+
}, [iframeReadyVersion, annotateModeActive]);
|
|
606
|
+
|
|
607
|
+
// Parent-side Esc rung: with focus outside the iframe the bridge never
|
|
608
|
+
// sees the keydown. Any open composer/toolbar/picker still closes first —
|
|
609
|
+
// their state is read from this render's closure, so an Esc that closed
|
|
610
|
+
// one this same keydown is not double-consumed here.
|
|
611
|
+
useEffect(() => {
|
|
612
|
+
if (readOnly || !annotateModeActive || !onAnnotateModeExit) return;
|
|
613
|
+
const overlayOpen =
|
|
614
|
+
!!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker || !!globalCommentPopover;
|
|
615
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
616
|
+
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
|
617
|
+
if (overlayOpen) return;
|
|
618
|
+
// A text field or dialog owns its own Escape.
|
|
619
|
+
const target = e.target as HTMLElement | null;
|
|
620
|
+
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
|
|
621
|
+
if (document.querySelector('[data-plannotator-confirm-dialog="true"]')) return;
|
|
622
|
+
onAnnotateModeExit();
|
|
623
|
+
};
|
|
624
|
+
window.addEventListener('keydown', onKeyDown);
|
|
625
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
626
|
+
}, [
|
|
627
|
+
readOnly,
|
|
628
|
+
annotateModeActive,
|
|
629
|
+
onAnnotateModeExit,
|
|
630
|
+
hook.toolbarState,
|
|
631
|
+
hook.commentPopover,
|
|
632
|
+
hook.quickLabelPicker,
|
|
633
|
+
globalCommentPopover,
|
|
634
|
+
]);
|
|
635
|
+
|
|
466
636
|
useEffect(() => {
|
|
467
637
|
if (iframeReadyVersion === 0) return;
|
|
468
638
|
const iframe = iframeRef.current;
|
|
469
|
-
|
|
639
|
+
postToBridge(
|
|
470
640
|
{
|
|
471
641
|
type: `${PREFIX}set-vim-mode`,
|
|
472
642
|
enabled: !readOnly && vimModeEnabled,
|
|
473
643
|
hudEnabled: vimHudEnabled,
|
|
474
644
|
mode,
|
|
475
645
|
},
|
|
476
|
-
"*",
|
|
477
646
|
);
|
|
478
647
|
if (!readOnly && vimModeEnabled && iframe && iframe === document.activeElement) {
|
|
479
648
|
// The initial parent focus can land before the sandbox bridge is ready.
|
|
480
649
|
// Reassert it after configuration so raw HTML enters BLOCK immediately,
|
|
481
650
|
// matching the Markdown surface instead of waiting for the first key.
|
|
482
|
-
|
|
651
|
+
postToBridge(
|
|
483
652
|
{ type: `${PREFIX}focus-vim` },
|
|
484
|
-
"*",
|
|
485
653
|
);
|
|
486
654
|
}
|
|
487
655
|
}, [iframeReadyVersion, mode, readOnly, vimHudEnabled, vimModeEnabled]);
|
|
@@ -499,9 +667,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
499
667
|
&& (document.activeElement === document.body || document.activeElement === null)
|
|
500
668
|
) {
|
|
501
669
|
iframeRef.current?.focus({ preventScroll: true });
|
|
502
|
-
|
|
670
|
+
postToBridge(
|
|
503
671
|
{ type: `${PREFIX}focus-vim` },
|
|
504
|
-
"*",
|
|
505
672
|
);
|
|
506
673
|
}
|
|
507
674
|
}, [hook.commentPopover, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
|
|
@@ -509,14 +676,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
509
676
|
useEffect(() => {
|
|
510
677
|
if (iframeReadyVersion === 0) return;
|
|
511
678
|
function sendTheme() {
|
|
512
|
-
|
|
679
|
+
postToBridge(
|
|
513
680
|
{
|
|
514
681
|
type: `${PREFIX}theme`,
|
|
515
682
|
tokens: buildThemeTokenPayload(readThemeTokens(), hostTheme),
|
|
516
683
|
isLight: isLightTheme(),
|
|
517
684
|
hostTheme,
|
|
518
685
|
},
|
|
519
|
-
"*",
|
|
520
686
|
);
|
|
521
687
|
}
|
|
522
688
|
sendTheme();
|
|
@@ -531,7 +697,9 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
531
697
|
useImperativeHandle(ref, () => ({
|
|
532
698
|
removeHighlight: hook.removeHighlight,
|
|
533
699
|
clearAllHighlights: hook.clearAllHighlights,
|
|
534
|
-
|
|
700
|
+
// Shared/draft restores respect the live page filter too.
|
|
701
|
+
applySharedAnnotations: (anns: Annotation[]) =>
|
|
702
|
+
hook.applyAnnotations(forCurrentPage(anns)),
|
|
535
703
|
}));
|
|
536
704
|
|
|
537
705
|
const handleGlobalCommentSubmit = useCallback(
|
|
@@ -622,6 +790,20 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
622
790
|
data-print-region="article"
|
|
623
791
|
className={fullViewport ? "relative overflow-hidden w-full flex-1" : "relative bg-card rounded-xl shadow-xl overflow-hidden w-full"}
|
|
624
792
|
>
|
|
793
|
+
{/* Armed affordance: a subtle accent ring floats over the iframe
|
|
794
|
+
while Annotate is armed (hosts that wire the toggle only).
|
|
795
|
+
Overlaid + pointer-transparent, so it never shifts layout and
|
|
796
|
+
never eats a click; an inset shadow on the article itself
|
|
797
|
+
would paint UNDER the covering iframe. */}
|
|
798
|
+
{!readOnly && annotateModeActive && (onAnnotateModeExit || onAnnotateModeToggle) && (
|
|
799
|
+
<div
|
|
800
|
+
aria-hidden
|
|
801
|
+
data-print-hide
|
|
802
|
+
data-annotate-armed-ring
|
|
803
|
+
className="pointer-events-none absolute inset-0 z-10"
|
|
804
|
+
style={{ boxShadow: "inset 0 0 0 2px color-mix(in srgb, var(--primary) 45%, transparent)" }}
|
|
805
|
+
/>
|
|
806
|
+
)}
|
|
625
807
|
{/* Full-viewport mode has no card chrome, so float the same controls
|
|
626
808
|
over the top-right of the iframe (with a backdrop so they read over
|
|
627
809
|
any HTML). The selection toolbar is portaled separately. */}
|
|
@@ -633,10 +815,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
633
815
|
{actionButtons}
|
|
634
816
|
</div>
|
|
635
817
|
)}
|
|
818
|
+
{/* Live proxied-app mode navigates a real loopback origin: no
|
|
819
|
+
sandbox (the user's own app needs cookies, storage, and
|
|
820
|
+
same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
|
|
636
821
|
<iframe
|
|
637
822
|
ref={iframeRef}
|
|
638
|
-
srcDoc
|
|
639
|
-
sandbox="allow-scripts"
|
|
823
|
+
{...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" })}
|
|
640
824
|
style={{
|
|
641
825
|
width: "100%",
|
|
642
826
|
height: fullViewport ? "100%" : `${iframeHeight}px`,
|
|
@@ -693,9 +877,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
693
877
|
positionMode="center-above"
|
|
694
878
|
element={hook.toolbarState.element}
|
|
695
879
|
copyText={hook.toolbarState.selectionText}
|
|
880
|
+
// HTML/live surfaces are comment-only: no Delete, no quick
|
|
881
|
+
// labels (onQuickLabel deliberately not passed). The markdown
|
|
882
|
+
// surface keeps the full toolbar.
|
|
883
|
+
commentOnly
|
|
696
884
|
onAnnotate={hook.handleAnnotate}
|
|
697
885
|
onRequestComment={hook.handleRequestComment}
|
|
698
|
-
onQuickLabel={hook.handleQuickLabel}
|
|
699
886
|
onClose={hook.handleToolbarClose}
|
|
700
887
|
/>,
|
|
701
888
|
document.body,
|
|
@@ -709,6 +896,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
709
896
|
contextText={hook.commentPopover.contextText}
|
|
710
897
|
initialText={hook.commentPopover.initialText}
|
|
711
898
|
isGlobal={false}
|
|
899
|
+
draftKey={`html:${hook.commentPopover.draftKey}`}
|
|
712
900
|
onSubmit={hook.handleCommentSubmit}
|
|
713
901
|
onClose={hook.handleCommentClose}
|
|
714
902
|
skillReferences
|
|
@@ -728,18 +916,6 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
728
916
|
document.body,
|
|
729
917
|
)}
|
|
730
918
|
|
|
731
|
-
{/* Quick label picker portal */}
|
|
732
|
-
{!readOnly && hook.quickLabelPicker &&
|
|
733
|
-
createPortal(
|
|
734
|
-
<FloatingQuickLabelPicker
|
|
735
|
-
anchorEl={hook.quickLabelPicker.anchorEl}
|
|
736
|
-
cursorHint={hook.quickLabelPicker.cursorHint}
|
|
737
|
-
onSelect={hook.handleFloatingQuickLabel}
|
|
738
|
-
onDismiss={hook.handleQuickLabelPickerDismiss}
|
|
739
|
-
/>,
|
|
740
|
-
document.body,
|
|
741
|
-
)}
|
|
742
|
-
|
|
743
919
|
{/* Global comment popover portal */}
|
|
744
920
|
{!readOnly && globalCommentPopover &&
|
|
745
921
|
createPortal(
|