@plannotator/ui 0.30.0 → 0.32.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 +46 -1
- package/components/ActionMenu.tsx +6 -1
- package/components/AgentsTab.tsx +8 -9
- package/components/AnalysisLayerToggle.tsx +48 -0
- package/components/AnnotationPanel.tsx +158 -36
- package/components/AnnotationToolbar.tsx +50 -30
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/CommentPopover.tsx +238 -46
- package/components/ConfirmDialog.tsx +42 -28
- package/components/GraphvizBlock.tsx +86 -7
- package/components/HtmlSurfaceControls.tsx +170 -0
- package/components/InlineMarkdown.tsx +25 -4
- 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/MermaidBlock.tsx +60 -26
- 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 +175 -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/blocks/MathBlock.tsx +26 -14
- package/components/core/button.tsx +14 -6
- package/components/html-viewer/HtmlViewer.tsx +320 -41
- package/components/html-viewer/bridge-script.ts +360 -72
- package/components/html-viewer/composerYield.ts +1 -51
- package/components/html-viewer/hostThreads.ts +37 -0
- package/components/html-viewer/index.ts +9 -0
- package/components/html-viewer/unanchored.ts +47 -0
- package/components/html-viewer/useHtmlAnnotation.ts +240 -61
- 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/configure.ts +32 -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/useHtmlRefresh.ts +149 -0
- package/hooks/useIsMobile.ts +37 -0
- package/hooks/useLinkedDoc.ts +7 -0
- package/hooks/useMathRenderer.ts +30 -0
- package/hooks/useScrollViewport.ts +74 -0
- package/hooks/useSharing.ts +31 -5
- package/hooks/useViewportEnvironment.ts +350 -0
- package/package.json +5 -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 +35 -0
- package/utils/annotateAgentTerminal.ts +36 -5
- package/utils/blockTargeting.ts +6 -3
- package/utils/composerYield.ts +45 -0
- package/utils/generateIdentity.ts +64 -14
- package/utils/htmlChrome.ts +20 -16
- package/utils/identity-tater.ts +36 -0
- package/utils/lookAndFeelAnnouncement.ts +12 -8
- package/utils/markdownExtensions.ts +57 -0
- package/utils/math-eager.ts +25 -0
- package/utils/math.ts +146 -0
- package/utils/mermaid-eager.ts +28 -0
- package/utils/mermaid.ts +132 -0
- package/utils/parser.ts +75 -2
- package/utils/quickLabels.ts +13 -0
- package/utils/vimNavigation.ts +4 -1
- package/utils/vimScroll.ts +9 -4
- package/utils/wideMode.ts +20 -0
- package/webmcp/activity.ts +46 -0
- package/webmcp/changes.ts +227 -0
- package/webmcp/index.ts +72 -0
- package/webmcp/modelContext.ts +103 -0
- package/webmcp/nudges.ts +174 -0
- package/webmcp/policy.ts +50 -0
- package/webmcp/preference.ts +50 -0
- package/webmcp/schema.ts +81 -0
- package/webmcp/toolset.ts +337 -0
- package/webmcp/useToolset.ts +74 -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
|
@@ -17,6 +17,7 @@ import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../..
|
|
|
17
17
|
import { AnnotationType } from "../../types";
|
|
18
18
|
import { copyTextPreservingFocus } from "../../utils/clipboard";
|
|
19
19
|
import { getIdentity } from "../../utils/identity";
|
|
20
|
+
import { THUMBS_UP_LABEL } from "../../utils/quickLabels";
|
|
20
21
|
import {
|
|
21
22
|
createVimHudCommand,
|
|
22
23
|
getVimHudPhase,
|
|
@@ -29,7 +30,6 @@ import {
|
|
|
29
30
|
type CommentAskAIHandler,
|
|
30
31
|
type CommentTargetChip,
|
|
31
32
|
} from "../CommentPopover";
|
|
32
|
-
import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker";
|
|
33
33
|
import { VimKeyHud } from "../VimKeyHud";
|
|
34
34
|
import type { ViewerHandle } from "../Viewer";
|
|
35
35
|
import {
|
|
@@ -38,7 +38,13 @@ import {
|
|
|
38
38
|
type ComposerYieldState,
|
|
39
39
|
} from "./composerYield";
|
|
40
40
|
import { buildSyncNumbering } from "./annotationNumbering";
|
|
41
|
-
import {
|
|
41
|
+
import { mergeUnanchoredIds } from "./unanchored";
|
|
42
|
+
import {
|
|
43
|
+
MAX_PAGE_URL_LENGTH,
|
|
44
|
+
rejectsLiveMessage,
|
|
45
|
+
useHtmlAnnotation,
|
|
46
|
+
type HtmlLiveSession,
|
|
47
|
+
} from "./useHtmlAnnotation";
|
|
42
48
|
import {
|
|
43
49
|
THEME_TOKENS,
|
|
44
50
|
buildSrcdocInjection,
|
|
@@ -138,6 +144,17 @@ function parseVimBridgeCopy(value: unknown): string | null {
|
|
|
138
144
|
/** Inputs for the sandboxed raw-HTML viewer and its parent-side annotation UI. */
|
|
139
145
|
export interface HtmlViewerProps {
|
|
140
146
|
rawHtml: string;
|
|
147
|
+
/** Live proxied-app mode: render `src` (no sandbox, no srcdoc) instead of
|
|
148
|
+
* rawHtml. The caller must also set `fullViewport` and `liveSession`. */
|
|
149
|
+
src?: string;
|
|
150
|
+
/** Live session credentials paired with `src`: proxy origin + per-session
|
|
151
|
+
* token, validated on every inbound message and stamped on every post. */
|
|
152
|
+
liveSession?: HtmlLiveSession;
|
|
153
|
+
/** Current page (pathname + search) in a live multi-page session. Restore
|
|
154
|
+
* filters annotations to this page; changing it re-applies the filter. */
|
|
155
|
+
currentPageUrl?: string;
|
|
156
|
+
/** Live-mode page navigation reports (ready pageUrl + page-change). */
|
|
157
|
+
onPageChange?: (pageUrl: string) => void;
|
|
141
158
|
annotations: Annotation[];
|
|
142
159
|
onAddAnnotation: (ann: Annotation) => void;
|
|
143
160
|
onSelectAnnotation: (id: string | null) => void;
|
|
@@ -145,6 +162,18 @@ export interface HtmlViewerProps {
|
|
|
145
162
|
mode: EditorMode;
|
|
146
163
|
/** Input method: 'drag' = text selection, 'pinpoint' = click an element. */
|
|
147
164
|
inputMethod: InputMethod;
|
|
165
|
+
/** Interact/Annotate toggle for HTML and live-app surfaces. While false
|
|
166
|
+
* the bridge keeps clicks native (no pinpoint capture, no hover outline)
|
|
167
|
+
* and clicks/forms/navigation reach the page untouched. Text
|
|
168
|
+
* drag-selection commenting stays live in BOTH modes, and committed
|
|
169
|
+
* markers stay visible and clickable in BOTH modes. Default true (armed)
|
|
170
|
+
* on both surface kinds. */
|
|
171
|
+
annotateModeActive?: boolean;
|
|
172
|
+
/** Esc final rung (bridge-side or the parent-side listener here): the user
|
|
173
|
+
* asked to leave Annotate for Interact. The host owns the mode state. */
|
|
174
|
+
onAnnotateModeExit?: () => void;
|
|
175
|
+
/** Mod+Shift+A pressed while focus lived inside the iframe. */
|
|
176
|
+
onAnnotateModeToggle?: () => void;
|
|
148
177
|
/** Opt-in Vim-style keyboard selection. Default false for compatibility. */
|
|
149
178
|
vimModeEnabled?: boolean;
|
|
150
179
|
/** Replace the iframe-local compact badge with the shared live key HUD. */
|
|
@@ -175,8 +204,21 @@ export interface HtmlViewerProps {
|
|
|
175
204
|
/** Reports the full set of annotation ids with no live representation on
|
|
176
205
|
* the page (fail-closed anchors hide markers rather than guess). Called
|
|
177
206
|
* with the complete current set whenever it changes, including back to
|
|
178
|
-
* empty on recovery. Fires in readOnly mode too.
|
|
207
|
+
* empty on recovery. Fires in readOnly mode too. Complete over the
|
|
208
|
+
* `annotations` prop: page rows with nothing to restore by (no quoted
|
|
209
|
+
* text, no element anchor) are reported even though the bridge never
|
|
210
|
+
* sees them, and an id this viewer minted for a local comment that the
|
|
211
|
+
* host swapped out of `annotations` for its own id is not reported. */
|
|
179
212
|
onUnanchoredChange?: (ids: string[]) => void;
|
|
213
|
+
/** Product cap on additional (shift-click) targets per comment, 0..16.
|
|
214
|
+
* Enforced at the trust boundary, on submit and on restore, and carried
|
|
215
|
+
* to the bridge so the in-page toggle stops at the same number. Default
|
|
216
|
+
* 16 (the package cap); absent leaves every message unchanged. */
|
|
217
|
+
maxAdditionalTargets?: number;
|
|
218
|
+
/** scrollIntoView behavior when a selected annotation is scrolled into
|
|
219
|
+
* view inside the page. Default 'smooth'; pass 'auto' to carry the
|
|
220
|
+
* parent's reduced-motion preference across the iframe boundary. */
|
|
221
|
+
scrollBehavior?: 'smooth' | 'auto';
|
|
180
222
|
/** Accessible iframe title. */
|
|
181
223
|
title?: string;
|
|
182
224
|
}
|
|
@@ -189,12 +231,19 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
189
231
|
(
|
|
190
232
|
{
|
|
191
233
|
rawHtml,
|
|
234
|
+
src,
|
|
235
|
+
liveSession,
|
|
236
|
+
currentPageUrl,
|
|
237
|
+
onPageChange,
|
|
192
238
|
annotations,
|
|
193
239
|
onAddAnnotation,
|
|
194
240
|
onSelectAnnotation,
|
|
195
241
|
selectedAnnotationId,
|
|
196
242
|
mode,
|
|
197
243
|
inputMethod,
|
|
244
|
+
annotateModeActive = true,
|
|
245
|
+
onAnnotateModeExit,
|
|
246
|
+
onAnnotateModeToggle,
|
|
198
247
|
vimModeEnabled = false,
|
|
199
248
|
vimHudEnabled = false,
|
|
200
249
|
vimHudKeyPanelEnabled = true,
|
|
@@ -211,6 +260,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
211
260
|
onAskAI,
|
|
212
261
|
readOnly = false,
|
|
213
262
|
onUnanchoredChange,
|
|
263
|
+
maxAdditionalTargets,
|
|
264
|
+
scrollBehavior,
|
|
214
265
|
title = "HTML Plan Viewer",
|
|
215
266
|
},
|
|
216
267
|
ref,
|
|
@@ -233,11 +284,44 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
233
284
|
contextText: string;
|
|
234
285
|
} | null>(null);
|
|
235
286
|
|
|
287
|
+
// Live proxied-app mode: the iframe navigates a real origin, so the
|
|
288
|
+
// srcdoc pipeline is skipped entirely and its messages carry credentials.
|
|
289
|
+
const liveMode = !!src;
|
|
290
|
+
const liveSessionRef = useRef<HtmlLiveSession | null>(liveSession ?? null);
|
|
291
|
+
liveSessionRef.current = liveSession ?? null;
|
|
292
|
+
const onPageChangeRef = useRef(onPageChange);
|
|
293
|
+
onPageChangeRef.current = onPageChange;
|
|
294
|
+
const onAnnotateModeExitRef = useRef(onAnnotateModeExit);
|
|
295
|
+
onAnnotateModeExitRef.current = onAnnotateModeExit;
|
|
296
|
+
const onAnnotateModeToggleRef = useRef(onAnnotateModeToggle);
|
|
297
|
+
onAnnotateModeToggleRef.current = onAnnotateModeToggle;
|
|
298
|
+
|
|
299
|
+
/** Single choke point for direct-to-bridge posts: live sessions get the
|
|
300
|
+
* token + concrete targetOrigin, srcdoc keeps "*" and no token. */
|
|
301
|
+
const postToBridge = useCallback((msg: Record<string, unknown>) => {
|
|
302
|
+
const win = iframeRef.current?.contentWindow;
|
|
303
|
+
if (!win) return;
|
|
304
|
+
const live = liveSessionRef.current;
|
|
305
|
+
if (live) {
|
|
306
|
+
// Browsers silently drop posts whose targetOrigin does not match the
|
|
307
|
+
// receiving window (mid-navigation frames); some DOM environments
|
|
308
|
+
// throw instead, so align with the browser semantics explicitly.
|
|
309
|
+
try {
|
|
310
|
+
win.postMessage({ ...msg, token: live.token }, live.origin);
|
|
311
|
+
} catch {
|
|
312
|
+
// Dropped, matching browser behavior for unmatched target origins.
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
win.postMessage(msg, "*");
|
|
316
|
+
}
|
|
317
|
+
}, []);
|
|
318
|
+
|
|
236
319
|
// Host theming is opt-in per document (Plannotator-generated artifacts tag
|
|
237
320
|
// themselves); arbitrary HTML renders untouched, like a standalone tab.
|
|
238
|
-
const hostTheme = useMemo(() => hasHostThemeOptIn(rawHtml), [rawHtml]);
|
|
321
|
+
const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
|
|
239
322
|
|
|
240
323
|
const srcdoc = useMemo(() => {
|
|
324
|
+
if (liveMode) return undefined; // src mode: the proxy injects the bridge
|
|
241
325
|
const injection = buildSrcdocInjection({
|
|
242
326
|
tokens: readThemeTokens(),
|
|
243
327
|
isLight: isLightTheme(),
|
|
@@ -245,11 +329,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
245
329
|
diffActive: !!diffActive,
|
|
246
330
|
});
|
|
247
331
|
return injectIntoHead(rawHtml, injection);
|
|
248
|
-
}, [rawHtml, hostTheme, diffActive]);
|
|
332
|
+
}, [liveMode, rawHtml, hostTheme, diffActive]);
|
|
249
333
|
|
|
250
334
|
const handleResize = useCallback((height: number) => {
|
|
335
|
+
if (liveMode) return; // live surfaces are full-viewport; height is ignored
|
|
251
336
|
setIframeHeight(height);
|
|
252
|
-
}, []);
|
|
337
|
+
}, [liveMode]);
|
|
253
338
|
|
|
254
339
|
// Composer yield while shift-selecting (multi-target drafts): fade the
|
|
255
340
|
// composer as the pointer approaches, click-through when over it. Pointer
|
|
@@ -290,6 +375,63 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
290
375
|
[handleYieldPointer],
|
|
291
376
|
);
|
|
292
377
|
|
|
378
|
+
// Unanchored union: the bridge reports ids with no live representation,
|
|
379
|
+
// completed here with what the bridge cannot see (textless page rows it
|
|
380
|
+
// was never asked to restore) and minus locally minted ids the host
|
|
381
|
+
// swapped out of `annotations`. Bridge reports deliver as they arrive
|
|
382
|
+
// (pass-through timing); a prop-side change delivers only when the union
|
|
383
|
+
// actually changes, so a viewer with nothing to complete delivers exactly
|
|
384
|
+
// the bridge list, exactly when the bridge posts it.
|
|
385
|
+
const onUnanchoredChangeRef = useRef(onUnanchoredChange);
|
|
386
|
+
onUnanchoredChangeRef.current = onUnanchoredChange;
|
|
387
|
+
const annotationsRef = useRef(annotations);
|
|
388
|
+
annotationsRef.current = annotations;
|
|
389
|
+
const bridgeUnanchoredRef = useRef<readonly string[]>([]);
|
|
390
|
+
const lastDeliveredUnanchoredRef = useRef("[]");
|
|
391
|
+
const createdIdsRef = useRef<ReadonlySet<string>>(new Set());
|
|
392
|
+
// The bridge's first report for the current document is the one that
|
|
393
|
+
// follows the restore batch (the parent asks for it with
|
|
394
|
+
// report-unanchored, and the bridge answers after its next complete
|
|
395
|
+
// pass, empty set included). Nothing is delivered before it: a host
|
|
396
|
+
// that acknowledges "the first report after a reload" must get the
|
|
397
|
+
// post-restore set, never a prop-side set computed at mount.
|
|
398
|
+
const bridgeReportedRef = useRef(false);
|
|
399
|
+
// An in-place document swap (rawHtml or src changes on one instance):
|
|
400
|
+
// the old document may still emit through the same contentWindow before
|
|
401
|
+
// the new one is ready. From the swap until the next ready, bridge
|
|
402
|
+
// reports belong to the old document and are dropped, and the
|
|
403
|
+
// last-delivered key is reset so the new document's first answer is
|
|
404
|
+
// delivered even when it equals the old one. First mount is not a swap.
|
|
405
|
+
const awaitingReadyRef = useRef(false);
|
|
406
|
+
const documentIdentityRef = useRef<{ rawHtml: string; src: string | undefined } | null>(null);
|
|
407
|
+
const previousIdentity = documentIdentityRef.current;
|
|
408
|
+
if (previousIdentity && (previousIdentity.rawHtml !== rawHtml || previousIdentity.src !== src)) {
|
|
409
|
+
awaitingReadyRef.current = true;
|
|
410
|
+
bridgeReportedRef.current = false;
|
|
411
|
+
lastDeliveredUnanchoredRef.current = "[]";
|
|
412
|
+
bridgeUnanchoredRef.current = [];
|
|
413
|
+
}
|
|
414
|
+
documentIdentityRef.current = { rawHtml, src };
|
|
415
|
+
const deliverUnanchored = useCallback((ids: string[], onlyIfChanged: boolean) => {
|
|
416
|
+
const key = JSON.stringify(ids);
|
|
417
|
+
if (onlyIfChanged && key === lastDeliveredUnanchoredRef.current) return;
|
|
418
|
+
lastDeliveredUnanchoredRef.current = key;
|
|
419
|
+
onUnanchoredChangeRef.current?.(ids);
|
|
420
|
+
}, []);
|
|
421
|
+
const handleBridgeUnanchored = useCallback((ids: string[]) => {
|
|
422
|
+
if (awaitingReadyRef.current) return;
|
|
423
|
+
bridgeUnanchoredRef.current = ids;
|
|
424
|
+
bridgeReportedRef.current = true;
|
|
425
|
+
deliverUnanchored(
|
|
426
|
+
mergeUnanchoredIds({
|
|
427
|
+
bridgeIds: ids,
|
|
428
|
+
annotations: annotationsRef.current,
|
|
429
|
+
createdIds: createdIdsRef.current,
|
|
430
|
+
}),
|
|
431
|
+
false,
|
|
432
|
+
);
|
|
433
|
+
}, [deliverUnanchored]);
|
|
434
|
+
|
|
293
435
|
const hook = useHtmlAnnotation({
|
|
294
436
|
iframeRef,
|
|
295
437
|
enabled: !readOnly,
|
|
@@ -299,9 +441,26 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
299
441
|
selectedAnnotationId,
|
|
300
442
|
mode,
|
|
301
443
|
onResize: handleResize,
|
|
444
|
+
live: liveSession,
|
|
445
|
+
onPageChange,
|
|
302
446
|
onBridgePointer: handleBridgePointer,
|
|
303
|
-
onUnanchoredChange,
|
|
447
|
+
onUnanchoredChange: handleBridgeUnanchored,
|
|
448
|
+
maxAdditionalTargets,
|
|
449
|
+
scrollBehavior,
|
|
304
450
|
});
|
|
451
|
+
createdIdsRef.current = hook.createdAnnotationIds;
|
|
452
|
+
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
if (!bridgeReportedRef.current) return;
|
|
455
|
+
deliverUnanchored(
|
|
456
|
+
mergeUnanchoredIds({
|
|
457
|
+
bridgeIds: bridgeUnanchoredRef.current,
|
|
458
|
+
annotations,
|
|
459
|
+
createdIds: hook.createdAnnotationIds,
|
|
460
|
+
}),
|
|
461
|
+
true,
|
|
462
|
+
);
|
|
463
|
+
}, [annotations, hook.createdAnnotationIds, deliverUnanchored]);
|
|
305
464
|
|
|
306
465
|
const multiSelectActive = !readOnly && !!hook.commentPopover && hook.draftTargets.length > 0;
|
|
307
466
|
|
|
@@ -353,11 +512,36 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
353
512
|
useEffect(() => {
|
|
354
513
|
function handler(e: MessageEvent<unknown>) {
|
|
355
514
|
if (e.source !== iframeRef.current?.contentWindow) return;
|
|
515
|
+
// Live sessions verify origin + token before reading anything.
|
|
516
|
+
const live = liveSessionRef.current;
|
|
517
|
+
if (live && rejectsLiveMessage(live, e.origin, e.data)) return;
|
|
356
518
|
if (isBridgeReadyMessage(e.data)) {
|
|
357
519
|
setIframeReadyVersion((version) => version + 1);
|
|
358
520
|
setVimBridgePhase("inactive");
|
|
359
521
|
setVimHudCommand(null);
|
|
360
522
|
setVimHelpOpen(false);
|
|
523
|
+
// Live ready carries the page identity (validated like page-change)
|
|
524
|
+
// so reloads and cross-page navigations re-anchor the restore filter.
|
|
525
|
+
if (live && isRecord(e.data)) {
|
|
526
|
+
const pageUrl = e.data.pageUrl;
|
|
527
|
+
if (
|
|
528
|
+
typeof pageUrl === "string"
|
|
529
|
+
&& pageUrl.length > 0
|
|
530
|
+
&& pageUrl.length <= MAX_PAGE_URL_LENGTH
|
|
531
|
+
) {
|
|
532
|
+
onPageChangeRef.current?.(pageUrl);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
// Interact/Annotate mode messages ride the same authenticated path:
|
|
538
|
+
// live sessions already rejected wrong-origin/tokenless data above.
|
|
539
|
+
if (isRecord(e.data) && e.data.type === `${PREFIX}annotate-exit`) {
|
|
540
|
+
onAnnotateModeExitRef.current?.();
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (isRecord(e.data) && e.data.type === `${PREFIX}annotate-toggle`) {
|
|
544
|
+
onAnnotateModeToggleRef.current?.();
|
|
361
545
|
return;
|
|
362
546
|
}
|
|
363
547
|
const vimCopy = parseVimBridgeCopy(e.data);
|
|
@@ -408,9 +592,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
408
592
|
|
|
409
593
|
const handleVimHelpOpenChange = useCallback((open: boolean) => {
|
|
410
594
|
setVimHelpOpen(open);
|
|
411
|
-
|
|
595
|
+
postToBridge(
|
|
412
596
|
{ type: `${PREFIX}set-vim-help`, open },
|
|
413
|
-
"*",
|
|
414
597
|
);
|
|
415
598
|
}, []);
|
|
416
599
|
|
|
@@ -425,9 +608,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
425
608
|
if (document.activeElement === iframe) return false;
|
|
426
609
|
iframe.focus({ preventScroll: true });
|
|
427
610
|
if (document.activeElement !== iframe) return false;
|
|
428
|
-
|
|
611
|
+
postToBridge(
|
|
429
612
|
{ type: `${PREFIX}focus-vim` },
|
|
430
|
-
"*",
|
|
431
613
|
);
|
|
432
614
|
return true;
|
|
433
615
|
}, [readOnly, vimModeEnabled]);
|
|
@@ -438,13 +620,58 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
438
620
|
focusDocument: focusVimDocument,
|
|
439
621
|
});
|
|
440
622
|
|
|
623
|
+
// Restore filter for live multi-page sessions: only annotations made on
|
|
624
|
+
// the current page (or without page identity) are pushed for restoration.
|
|
625
|
+
// Numbering (sync-annotations) still ships the FULL list: numbers are
|
|
626
|
+
// parent-authoritative and global across pages, matching export.
|
|
627
|
+
const forCurrentPage = useCallback(
|
|
628
|
+
(anns: Annotation[]) =>
|
|
629
|
+
anns.filter((a) => !a.pageUrl || a.pageUrl === currentPageUrl),
|
|
630
|
+
[currentPageUrl],
|
|
631
|
+
);
|
|
632
|
+
|
|
441
633
|
useEffect(() => {
|
|
442
634
|
if (iframeReadyVersion === 0) return;
|
|
443
|
-
|
|
444
|
-
|
|
635
|
+
const restorable = forCurrentPage(annotations);
|
|
636
|
+
if (restorable.length > 0) {
|
|
637
|
+
hook.applyAnnotations(restorable);
|
|
445
638
|
}
|
|
639
|
+
// A fresh document: the bridge starts from an empty set and would stay
|
|
640
|
+
// silent when everything restores. Ask for one complete report after
|
|
641
|
+
// this restore batch (posted after it, so the answering pass sees it),
|
|
642
|
+
// which becomes this document's first delivery, empty set included.
|
|
643
|
+
awaitingReadyRef.current = false;
|
|
644
|
+
bridgeReportedRef.current = false;
|
|
645
|
+
postToBridge({ type: `${PREFIX}report-unanchored` });
|
|
446
646
|
}, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
447
647
|
|
|
648
|
+
// Live page navigation with a ready iframe: explicitly clear the previous
|
|
649
|
+
// page's marks, then re-apply the filtered set. Relying on dead anchors to
|
|
650
|
+
// hide pins would risk cross-page text-search false matches and waste
|
|
651
|
+
// reconcile budget.
|
|
652
|
+
const lastAppliedPageRef = useRef<string | undefined>(currentPageUrl);
|
|
653
|
+
useEffect(() => {
|
|
654
|
+
if (lastAppliedPageRef.current === currentPageUrl) return;
|
|
655
|
+
lastAppliedPageRef.current = currentPageUrl;
|
|
656
|
+
if (iframeReadyVersion === 0) return;
|
|
657
|
+
postToBridge({ type: `${PREFIX}clear-marks` });
|
|
658
|
+
const restorable = forCurrentPage(annotations);
|
|
659
|
+
if (restorable.length > 0) {
|
|
660
|
+
hook.applyAnnotations(restorable);
|
|
661
|
+
}
|
|
662
|
+
// clear-marks drops the bridge's synced numbering; re-establish it so
|
|
663
|
+
// restored markers keep their export-matching global numbers.
|
|
664
|
+
postToBridge({
|
|
665
|
+
type: `${PREFIX}sync-annotations`,
|
|
666
|
+
annotations: buildSyncNumbering(annotations),
|
|
667
|
+
});
|
|
668
|
+
// A new page is a new restore batch: report its complete set once, and
|
|
669
|
+
// deliver nothing computed against the previous page's report until
|
|
670
|
+
// that answer arrives.
|
|
671
|
+
bridgeReportedRef.current = false;
|
|
672
|
+
postToBridge({ type: `${PREFIX}report-unanchored` });
|
|
673
|
+
}, [currentPageUrl, iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
674
|
+
|
|
448
675
|
// Placed-marker numbering is parent-authoritative and matches the
|
|
449
676
|
// numbers exportAnnotations writes into the submitted feedback: the full
|
|
450
677
|
// list INCLUDING globals is numbered by ARRAY position (the export's
|
|
@@ -454,9 +681,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
454
681
|
// order is only a pre-sync fallback.
|
|
455
682
|
useEffect(() => {
|
|
456
683
|
if (iframeReadyVersion === 0) return;
|
|
457
|
-
|
|
684
|
+
postToBridge(
|
|
458
685
|
{ type: `${PREFIX}sync-annotations`, annotations: buildSyncNumbering(annotations) },
|
|
459
|
-
"*",
|
|
460
686
|
);
|
|
461
687
|
}, [iframeReadyVersion, annotations]);
|
|
462
688
|
|
|
@@ -464,31 +690,67 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
464
690
|
// ready (fresh iframe) and whenever the user switches it in the toolstrip.
|
|
465
691
|
useEffect(() => {
|
|
466
692
|
if (iframeReadyVersion === 0) return;
|
|
467
|
-
|
|
693
|
+
postToBridge(
|
|
468
694
|
{ type: `${PREFIX}set-input-method`, method: inputMethod },
|
|
469
|
-
"*",
|
|
470
695
|
);
|
|
471
696
|
}, [iframeReadyVersion, inputMethod]);
|
|
472
697
|
|
|
698
|
+
// Tell the bridge whether Annotate is armed. Same re-post pattern as
|
|
699
|
+
// set-input-method, so the mode survives live page changes / HMR reloads
|
|
700
|
+
// and bridge re-injection without ever reloading the iframe.
|
|
701
|
+
useEffect(() => {
|
|
702
|
+
if (iframeReadyVersion === 0) return;
|
|
703
|
+
postToBridge(
|
|
704
|
+
{ type: `${PREFIX}set-annotate-mode`, active: annotateModeActive },
|
|
705
|
+
);
|
|
706
|
+
}, [iframeReadyVersion, annotateModeActive]);
|
|
707
|
+
|
|
708
|
+
// Parent-side Esc rung: with focus outside the iframe the bridge never
|
|
709
|
+
// sees the keydown. Any open composer/toolbar/picker still closes first —
|
|
710
|
+
// their state is read from this render's closure, so an Esc that closed
|
|
711
|
+
// one this same keydown is not double-consumed here.
|
|
712
|
+
useEffect(() => {
|
|
713
|
+
if (readOnly || !annotateModeActive || !onAnnotateModeExit) return;
|
|
714
|
+
const overlayOpen =
|
|
715
|
+
!!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker || !!globalCommentPopover;
|
|
716
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
717
|
+
if (e.key !== 'Escape' || e.defaultPrevented) return;
|
|
718
|
+
if (overlayOpen) return;
|
|
719
|
+
// A text field or dialog owns its own Escape.
|
|
720
|
+
const target = e.target as HTMLElement | null;
|
|
721
|
+
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
|
|
722
|
+
if (document.querySelector('[data-plannotator-confirm-dialog="true"]')) return;
|
|
723
|
+
onAnnotateModeExit();
|
|
724
|
+
};
|
|
725
|
+
window.addEventListener('keydown', onKeyDown);
|
|
726
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
727
|
+
}, [
|
|
728
|
+
readOnly,
|
|
729
|
+
annotateModeActive,
|
|
730
|
+
onAnnotateModeExit,
|
|
731
|
+
hook.toolbarState,
|
|
732
|
+
hook.commentPopover,
|
|
733
|
+
hook.quickLabelPicker,
|
|
734
|
+
globalCommentPopover,
|
|
735
|
+
]);
|
|
736
|
+
|
|
473
737
|
useEffect(() => {
|
|
474
738
|
if (iframeReadyVersion === 0) return;
|
|
475
739
|
const iframe = iframeRef.current;
|
|
476
|
-
|
|
740
|
+
postToBridge(
|
|
477
741
|
{
|
|
478
742
|
type: `${PREFIX}set-vim-mode`,
|
|
479
743
|
enabled: !readOnly && vimModeEnabled,
|
|
480
744
|
hudEnabled: vimHudEnabled,
|
|
481
745
|
mode,
|
|
482
746
|
},
|
|
483
|
-
"*",
|
|
484
747
|
);
|
|
485
748
|
if (!readOnly && vimModeEnabled && iframe && iframe === document.activeElement) {
|
|
486
749
|
// The initial parent focus can land before the sandbox bridge is ready.
|
|
487
750
|
// Reassert it after configuration so raw HTML enters BLOCK immediately,
|
|
488
751
|
// matching the Markdown surface instead of waiting for the first key.
|
|
489
|
-
|
|
752
|
+
postToBridge(
|
|
490
753
|
{ type: `${PREFIX}focus-vim` },
|
|
491
|
-
"*",
|
|
492
754
|
);
|
|
493
755
|
}
|
|
494
756
|
}, [iframeReadyVersion, mode, readOnly, vimHudEnabled, vimModeEnabled]);
|
|
@@ -506,9 +768,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
506
768
|
&& (document.activeElement === document.body || document.activeElement === null)
|
|
507
769
|
) {
|
|
508
770
|
iframeRef.current?.focus({ preventScroll: true });
|
|
509
|
-
|
|
771
|
+
postToBridge(
|
|
510
772
|
{ type: `${PREFIX}focus-vim` },
|
|
511
|
-
"*",
|
|
512
773
|
);
|
|
513
774
|
}
|
|
514
775
|
}, [hook.commentPopover, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
|
|
@@ -516,14 +777,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
516
777
|
useEffect(() => {
|
|
517
778
|
if (iframeReadyVersion === 0) return;
|
|
518
779
|
function sendTheme() {
|
|
519
|
-
|
|
780
|
+
postToBridge(
|
|
520
781
|
{
|
|
521
782
|
type: `${PREFIX}theme`,
|
|
522
783
|
tokens: buildThemeTokenPayload(readThemeTokens(), hostTheme),
|
|
523
784
|
isLight: isLightTheme(),
|
|
524
785
|
hostTheme,
|
|
525
786
|
},
|
|
526
|
-
"*",
|
|
527
787
|
);
|
|
528
788
|
}
|
|
529
789
|
sendTheme();
|
|
@@ -538,7 +798,9 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
538
798
|
useImperativeHandle(ref, () => ({
|
|
539
799
|
removeHighlight: hook.removeHighlight,
|
|
540
800
|
clearAllHighlights: hook.clearAllHighlights,
|
|
541
|
-
|
|
801
|
+
// Shared/draft restores respect the live page filter too.
|
|
802
|
+
applySharedAnnotations: (anns: Annotation[]) =>
|
|
803
|
+
hook.applyAnnotations(forCurrentPage(anns)),
|
|
542
804
|
}));
|
|
543
805
|
|
|
544
806
|
const handleGlobalCommentSubmit = useCallback(
|
|
@@ -629,6 +891,20 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
629
891
|
data-print-region="article"
|
|
630
892
|
className={fullViewport ? "relative overflow-hidden w-full flex-1" : "relative bg-card rounded-xl shadow-xl overflow-hidden w-full"}
|
|
631
893
|
>
|
|
894
|
+
{/* Armed affordance: a subtle accent ring floats over the iframe
|
|
895
|
+
while Annotate is armed (hosts that wire the toggle only).
|
|
896
|
+
Overlaid + pointer-transparent, so it never shifts layout and
|
|
897
|
+
never eats a click; an inset shadow on the article itself
|
|
898
|
+
would paint UNDER the covering iframe. */}
|
|
899
|
+
{!readOnly && annotateModeActive && (onAnnotateModeExit || onAnnotateModeToggle) && (
|
|
900
|
+
<div
|
|
901
|
+
aria-hidden
|
|
902
|
+
data-print-hide
|
|
903
|
+
data-annotate-armed-ring
|
|
904
|
+
className="pointer-events-none absolute inset-0 z-10"
|
|
905
|
+
style={{ boxShadow: "inset 0 0 0 2px color-mix(in srgb, var(--primary) 45%, transparent)" }}
|
|
906
|
+
/>
|
|
907
|
+
)}
|
|
632
908
|
{/* Full-viewport mode has no card chrome, so float the same controls
|
|
633
909
|
over the top-right of the iframe (with a backdrop so they read over
|
|
634
910
|
any HTML). The selection toolbar is portaled separately. */}
|
|
@@ -640,10 +916,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
640
916
|
{actionButtons}
|
|
641
917
|
</div>
|
|
642
918
|
)}
|
|
919
|
+
{/* Live proxied-app mode navigates a real loopback origin: no
|
|
920
|
+
sandbox (the user's own app needs cookies, storage, and
|
|
921
|
+
same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
|
|
643
922
|
<iframe
|
|
644
923
|
ref={iframeRef}
|
|
645
|
-
srcDoc
|
|
646
|
-
sandbox="allow-scripts"
|
|
924
|
+
{...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" })}
|
|
647
925
|
style={{
|
|
648
926
|
width: "100%",
|
|
649
927
|
height: fullViewport ? "100%" : `${iframeHeight}px`,
|
|
@@ -700,9 +978,17 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
700
978
|
positionMode="center-above"
|
|
701
979
|
element={hook.toolbarState.element}
|
|
702
980
|
copyText={hook.toolbarState.selectionText}
|
|
981
|
+
// HTML/live surfaces are comment-only: no Delete, no label
|
|
982
|
+
// picker, no Alt+digit labels (commentOnly). Exactly ONE label
|
|
983
|
+
// affordance is restored: the hardcoded 👍 "Looks good". The
|
|
984
|
+
// wrapper filters by id as defense in depth, so no present or
|
|
985
|
+
// future toolbar path can emit an arbitrary label here.
|
|
986
|
+
commentOnly
|
|
987
|
+
onQuickLabel={(label) => {
|
|
988
|
+
if (label.id === THUMBS_UP_LABEL.id) hook.handleQuickLabel(label);
|
|
989
|
+
}}
|
|
703
990
|
onAnnotate={hook.handleAnnotate}
|
|
704
991
|
onRequestComment={hook.handleRequestComment}
|
|
705
|
-
onQuickLabel={hook.handleQuickLabel}
|
|
706
992
|
onClose={hook.handleToolbarClose}
|
|
707
993
|
/>,
|
|
708
994
|
document.body,
|
|
@@ -716,7 +1002,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
716
1002
|
contextText={hook.commentPopover.contextText}
|
|
717
1003
|
initialText={hook.commentPopover.initialText}
|
|
718
1004
|
isGlobal={false}
|
|
1005
|
+
draftKey={`html:${hook.commentPopover.draftKey}`}
|
|
719
1006
|
onSubmit={hook.handleCommentSubmit}
|
|
1007
|
+
// Pinpoint clicks open this composer directly, so it carries
|
|
1008
|
+
// the surface's one-click "Looks good" (the global composer
|
|
1009
|
+
// does not: a document-wide thumbs-up is not a thing).
|
|
1010
|
+
onQuickLookGood={hook.handleCommentLooksGood}
|
|
720
1011
|
onClose={hook.handleCommentClose}
|
|
721
1012
|
skillReferences
|
|
722
1013
|
onAskAI={onAskAI}
|
|
@@ -735,18 +1026,6 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
735
1026
|
document.body,
|
|
736
1027
|
)}
|
|
737
1028
|
|
|
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
1029
|
{/* Global comment popover portal */}
|
|
751
1030
|
{!readOnly && globalCommentPopover &&
|
|
752
1031
|
createPortal(
|