@plannotator/ui 0.30.0 → 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 +62 -32
- 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 +208 -39
- package/components/html-viewer/bridge-script.ts +319 -65
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/useHtmlAnnotation.ts +146 -57
- 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. */
|
|
@@ -189,12 +216,19 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
189
216
|
(
|
|
190
217
|
{
|
|
191
218
|
rawHtml,
|
|
219
|
+
src,
|
|
220
|
+
liveSession,
|
|
221
|
+
currentPageUrl,
|
|
222
|
+
onPageChange,
|
|
192
223
|
annotations,
|
|
193
224
|
onAddAnnotation,
|
|
194
225
|
onSelectAnnotation,
|
|
195
226
|
selectedAnnotationId,
|
|
196
227
|
mode,
|
|
197
228
|
inputMethod,
|
|
229
|
+
annotateModeActive = true,
|
|
230
|
+
onAnnotateModeExit,
|
|
231
|
+
onAnnotateModeToggle,
|
|
198
232
|
vimModeEnabled = false,
|
|
199
233
|
vimHudEnabled = false,
|
|
200
234
|
vimHudKeyPanelEnabled = true,
|
|
@@ -233,11 +267,44 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
233
267
|
contextText: string;
|
|
234
268
|
} | null>(null);
|
|
235
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
|
+
|
|
236
302
|
// Host theming is opt-in per document (Plannotator-generated artifacts tag
|
|
237
303
|
// themselves); arbitrary HTML renders untouched, like a standalone tab.
|
|
238
|
-
const hostTheme = useMemo(() => hasHostThemeOptIn(rawHtml), [rawHtml]);
|
|
304
|
+
const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
|
|
239
305
|
|
|
240
306
|
const srcdoc = useMemo(() => {
|
|
307
|
+
if (liveMode) return undefined; // src mode: the proxy injects the bridge
|
|
241
308
|
const injection = buildSrcdocInjection({
|
|
242
309
|
tokens: readThemeTokens(),
|
|
243
310
|
isLight: isLightTheme(),
|
|
@@ -245,11 +312,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
245
312
|
diffActive: !!diffActive,
|
|
246
313
|
});
|
|
247
314
|
return injectIntoHead(rawHtml, injection);
|
|
248
|
-
}, [rawHtml, hostTheme, diffActive]);
|
|
315
|
+
}, [liveMode, rawHtml, hostTheme, diffActive]);
|
|
249
316
|
|
|
250
317
|
const handleResize = useCallback((height: number) => {
|
|
318
|
+
if (liveMode) return; // live surfaces are full-viewport; height is ignored
|
|
251
319
|
setIframeHeight(height);
|
|
252
|
-
}, []);
|
|
320
|
+
}, [liveMode]);
|
|
253
321
|
|
|
254
322
|
// Composer yield while shift-selecting (multi-target drafts): fade the
|
|
255
323
|
// composer as the pointer approaches, click-through when over it. Pointer
|
|
@@ -299,6 +367,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
299
367
|
selectedAnnotationId,
|
|
300
368
|
mode,
|
|
301
369
|
onResize: handleResize,
|
|
370
|
+
live: liveSession,
|
|
371
|
+
onPageChange,
|
|
302
372
|
onBridgePointer: handleBridgePointer,
|
|
303
373
|
onUnanchoredChange,
|
|
304
374
|
});
|
|
@@ -353,11 +423,36 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
353
423
|
useEffect(() => {
|
|
354
424
|
function handler(e: MessageEvent<unknown>) {
|
|
355
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;
|
|
356
429
|
if (isBridgeReadyMessage(e.data)) {
|
|
357
430
|
setIframeReadyVersion((version) => version + 1);
|
|
358
431
|
setVimBridgePhase("inactive");
|
|
359
432
|
setVimHudCommand(null);
|
|
360
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?.();
|
|
361
456
|
return;
|
|
362
457
|
}
|
|
363
458
|
const vimCopy = parseVimBridgeCopy(e.data);
|
|
@@ -408,9 +503,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
408
503
|
|
|
409
504
|
const handleVimHelpOpenChange = useCallback((open: boolean) => {
|
|
410
505
|
setVimHelpOpen(open);
|
|
411
|
-
|
|
506
|
+
postToBridge(
|
|
412
507
|
{ type: `${PREFIX}set-vim-help`, open },
|
|
413
|
-
"*",
|
|
414
508
|
);
|
|
415
509
|
}, []);
|
|
416
510
|
|
|
@@ -425,9 +519,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
425
519
|
if (document.activeElement === iframe) return false;
|
|
426
520
|
iframe.focus({ preventScroll: true });
|
|
427
521
|
if (document.activeElement !== iframe) return false;
|
|
428
|
-
|
|
522
|
+
postToBridge(
|
|
429
523
|
{ type: `${PREFIX}focus-vim` },
|
|
430
|
-
"*",
|
|
431
524
|
);
|
|
432
525
|
return true;
|
|
433
526
|
}, [readOnly, vimModeEnabled]);
|
|
@@ -438,13 +531,46 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
438
531
|
focusDocument: focusVimDocument,
|
|
439
532
|
});
|
|
440
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
|
+
|
|
441
544
|
useEffect(() => {
|
|
442
545
|
if (iframeReadyVersion === 0) return;
|
|
443
|
-
|
|
444
|
-
|
|
546
|
+
const restorable = forCurrentPage(annotations);
|
|
547
|
+
if (restorable.length > 0) {
|
|
548
|
+
hook.applyAnnotations(restorable);
|
|
445
549
|
}
|
|
446
550
|
}, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
447
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
|
+
|
|
448
574
|
// Placed-marker numbering is parent-authoritative and matches the
|
|
449
575
|
// numbers exportAnnotations writes into the submitted feedback: the full
|
|
450
576
|
// list INCLUDING globals is numbered by ARRAY position (the export's
|
|
@@ -454,9 +580,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
454
580
|
// order is only a pre-sync fallback.
|
|
455
581
|
useEffect(() => {
|
|
456
582
|
if (iframeReadyVersion === 0) return;
|
|
457
|
-
|
|
583
|
+
postToBridge(
|
|
458
584
|
{ type: `${PREFIX}sync-annotations`, annotations: buildSyncNumbering(annotations) },
|
|
459
|
-
"*",
|
|
460
585
|
);
|
|
461
586
|
}, [iframeReadyVersion, annotations]);
|
|
462
587
|
|
|
@@ -464,31 +589,67 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
464
589
|
// ready (fresh iframe) and whenever the user switches it in the toolstrip.
|
|
465
590
|
useEffect(() => {
|
|
466
591
|
if (iframeReadyVersion === 0) return;
|
|
467
|
-
|
|
592
|
+
postToBridge(
|
|
468
593
|
{ type: `${PREFIX}set-input-method`, method: inputMethod },
|
|
469
|
-
"*",
|
|
470
594
|
);
|
|
471
595
|
}, [iframeReadyVersion, inputMethod]);
|
|
472
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
|
+
|
|
473
636
|
useEffect(() => {
|
|
474
637
|
if (iframeReadyVersion === 0) return;
|
|
475
638
|
const iframe = iframeRef.current;
|
|
476
|
-
|
|
639
|
+
postToBridge(
|
|
477
640
|
{
|
|
478
641
|
type: `${PREFIX}set-vim-mode`,
|
|
479
642
|
enabled: !readOnly && vimModeEnabled,
|
|
480
643
|
hudEnabled: vimHudEnabled,
|
|
481
644
|
mode,
|
|
482
645
|
},
|
|
483
|
-
"*",
|
|
484
646
|
);
|
|
485
647
|
if (!readOnly && vimModeEnabled && iframe && iframe === document.activeElement) {
|
|
486
648
|
// The initial parent focus can land before the sandbox bridge is ready.
|
|
487
649
|
// Reassert it after configuration so raw HTML enters BLOCK immediately,
|
|
488
650
|
// matching the Markdown surface instead of waiting for the first key.
|
|
489
|
-
|
|
651
|
+
postToBridge(
|
|
490
652
|
{ type: `${PREFIX}focus-vim` },
|
|
491
|
-
"*",
|
|
492
653
|
);
|
|
493
654
|
}
|
|
494
655
|
}, [iframeReadyVersion, mode, readOnly, vimHudEnabled, vimModeEnabled]);
|
|
@@ -506,9 +667,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
506
667
|
&& (document.activeElement === document.body || document.activeElement === null)
|
|
507
668
|
) {
|
|
508
669
|
iframeRef.current?.focus({ preventScroll: true });
|
|
509
|
-
|
|
670
|
+
postToBridge(
|
|
510
671
|
{ type: `${PREFIX}focus-vim` },
|
|
511
|
-
"*",
|
|
512
672
|
);
|
|
513
673
|
}
|
|
514
674
|
}, [hook.commentPopover, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
|
|
@@ -516,14 +676,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
516
676
|
useEffect(() => {
|
|
517
677
|
if (iframeReadyVersion === 0) return;
|
|
518
678
|
function sendTheme() {
|
|
519
|
-
|
|
679
|
+
postToBridge(
|
|
520
680
|
{
|
|
521
681
|
type: `${PREFIX}theme`,
|
|
522
682
|
tokens: buildThemeTokenPayload(readThemeTokens(), hostTheme),
|
|
523
683
|
isLight: isLightTheme(),
|
|
524
684
|
hostTheme,
|
|
525
685
|
},
|
|
526
|
-
"*",
|
|
527
686
|
);
|
|
528
687
|
}
|
|
529
688
|
sendTheme();
|
|
@@ -538,7 +697,9 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
538
697
|
useImperativeHandle(ref, () => ({
|
|
539
698
|
removeHighlight: hook.removeHighlight,
|
|
540
699
|
clearAllHighlights: hook.clearAllHighlights,
|
|
541
|
-
|
|
700
|
+
// Shared/draft restores respect the live page filter too.
|
|
701
|
+
applySharedAnnotations: (anns: Annotation[]) =>
|
|
702
|
+
hook.applyAnnotations(forCurrentPage(anns)),
|
|
542
703
|
}));
|
|
543
704
|
|
|
544
705
|
const handleGlobalCommentSubmit = useCallback(
|
|
@@ -629,6 +790,20 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
629
790
|
data-print-region="article"
|
|
630
791
|
className={fullViewport ? "relative overflow-hidden w-full flex-1" : "relative bg-card rounded-xl shadow-xl overflow-hidden w-full"}
|
|
631
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
|
+
)}
|
|
632
807
|
{/* Full-viewport mode has no card chrome, so float the same controls
|
|
633
808
|
over the top-right of the iframe (with a backdrop so they read over
|
|
634
809
|
any HTML). The selection toolbar is portaled separately. */}
|
|
@@ -640,10 +815,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
640
815
|
{actionButtons}
|
|
641
816
|
</div>
|
|
642
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. */}
|
|
643
821
|
<iframe
|
|
644
822
|
ref={iframeRef}
|
|
645
|
-
srcDoc
|
|
646
|
-
sandbox="allow-scripts"
|
|
823
|
+
{...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" })}
|
|
647
824
|
style={{
|
|
648
825
|
width: "100%",
|
|
649
826
|
height: fullViewport ? "100%" : `${iframeHeight}px`,
|
|
@@ -700,9 +877,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
700
877
|
positionMode="center-above"
|
|
701
878
|
element={hook.toolbarState.element}
|
|
702
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
|
|
703
884
|
onAnnotate={hook.handleAnnotate}
|
|
704
885
|
onRequestComment={hook.handleRequestComment}
|
|
705
|
-
onQuickLabel={hook.handleQuickLabel}
|
|
706
886
|
onClose={hook.handleToolbarClose}
|
|
707
887
|
/>,
|
|
708
888
|
document.body,
|
|
@@ -716,6 +896,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
716
896
|
contextText={hook.commentPopover.contextText}
|
|
717
897
|
initialText={hook.commentPopover.initialText}
|
|
718
898
|
isGlobal={false}
|
|
899
|
+
draftKey={`html:${hook.commentPopover.draftKey}`}
|
|
719
900
|
onSubmit={hook.handleCommentSubmit}
|
|
720
901
|
onClose={hook.handleCommentClose}
|
|
721
902
|
skillReferences
|
|
@@ -735,18 +916,6 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
735
916
|
document.body,
|
|
736
917
|
)}
|
|
737
918
|
|
|
738
|
-
{/* Quick label picker portal */}
|
|
739
|
-
{!readOnly && hook.quickLabelPicker &&
|
|
740
|
-
createPortal(
|
|
741
|
-
<FloatingQuickLabelPicker
|
|
742
|
-
anchorEl={hook.quickLabelPicker.anchorEl}
|
|
743
|
-
cursorHint={hook.quickLabelPicker.cursorHint}
|
|
744
|
-
onSelect={hook.handleFloatingQuickLabel}
|
|
745
|
-
onDismiss={hook.handleQuickLabelPickerDismiss}
|
|
746
|
-
/>,
|
|
747
|
-
document.body,
|
|
748
|
-
)}
|
|
749
|
-
|
|
750
919
|
{/* Global comment popover portal */}
|
|
751
920
|
{!readOnly && globalCommentPopover &&
|
|
752
921
|
createPortal(
|