@plannotator/ui 0.31.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HANDOFF.md +664 -0
- package/README.md +57 -1
- package/components/AnnotationPanel.tsx +96 -4
- package/components/AnnotationToolbar.tsx +25 -25
- package/components/CommentPopover.tsx +26 -0
- package/components/GraphvizBlock.tsx +86 -7
- package/components/HtmlSurfaceControls.tsx +170 -0
- package/components/InlineMarkdown.tsx +22 -2
- package/components/MermaidBlock.tsx +60 -26
- package/components/Settings.tsx +40 -1
- package/components/blocks/MathBlock.tsx +26 -14
- package/components/html-viewer/HtmlViewer.tsx +289 -6
- package/components/html-viewer/bridge-script.asset.js +4392 -0
- package/components/html-viewer/bridge-script.lite.ts +9 -0
- package/components/html-viewer/bridge-script.ts +54 -8
- package/components/html-viewer/hostThreads.ts +37 -0
- package/components/html-viewer/index.ts +22 -1
- package/components/html-viewer/srcdoc.ts +70 -1
- package/components/html-viewer/unanchored.ts +47 -0
- package/components/html-viewer/useHtmlAnnotation.ts +132 -5
- package/configure.ts +32 -0
- package/hooks/useHtmlRefresh.ts +149 -0
- package/hooks/useMathRenderer.ts +30 -0
- package/hooks/useSharing.ts +31 -5
- package/package.json +9 -3
- package/styles.css +1 -1
- package/types.ts +1 -0
- package/utils/generateIdentity.ts +64 -14
- package/utils/identity-tater.ts +36 -0
- package/utils/math-default-loader.ts +24 -0
- package/utils/math-eager.ts +25 -0
- package/utils/math.ts +149 -0
- package/utils/mermaid-eager.ts +28 -0
- package/utils/mermaid.ts +132 -0
- package/utils/parser.ts +38 -0
- package/utils/quickLabels.ts +13 -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
|
@@ -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,
|
|
@@ -37,8 +38,11 @@ import {
|
|
|
37
38
|
type ComposerYieldState,
|
|
38
39
|
} from "./composerYield";
|
|
39
40
|
import { buildSyncNumbering } from "./annotationNumbering";
|
|
41
|
+
import { mergeUnanchoredIds } from "./unanchored";
|
|
40
42
|
import {
|
|
41
43
|
MAX_PAGE_URL_LENGTH,
|
|
44
|
+
checkBridgeProtocolVersion,
|
|
45
|
+
formatBridgeProtocolWarning,
|
|
42
46
|
rejectsLiveMessage,
|
|
43
47
|
useHtmlAnnotation,
|
|
44
48
|
type HtmlLiveSession,
|
|
@@ -49,6 +53,7 @@ import {
|
|
|
49
53
|
buildThemeTokenPayload,
|
|
50
54
|
hasHostThemeOptIn,
|
|
51
55
|
injectIntoHead,
|
|
56
|
+
resolveBridgeScriptUrl,
|
|
52
57
|
} from "./srcdoc";
|
|
53
58
|
|
|
54
59
|
const PREFIX = "plannotator-bridge-";
|
|
@@ -129,6 +134,38 @@ function parseVimBridgeHelp(value: unknown): boolean | null {
|
|
|
129
134
|
|
|
130
135
|
const MAX_VIM_COPY_TEXT_LENGTH = 2 * 1024 * 1024;
|
|
131
136
|
|
|
137
|
+
/** Default wait for the bridge's `ready` on the `bridgeScriptUrl` path. */
|
|
138
|
+
export const DEFAULT_BRIDGE_READY_TIMEOUT_MS = 5000;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Why the bridge could not be established on the `bridgeScriptUrl` path.
|
|
142
|
+
* Never produced on the inline path (the bridge and the parent are one
|
|
143
|
+
* bundle there, and no ready timer runs).
|
|
144
|
+
*/
|
|
145
|
+
export type BridgeUnavailableInfo =
|
|
146
|
+
| {
|
|
147
|
+
kind: "timeout";
|
|
148
|
+
url: string;
|
|
149
|
+
/** The wait that elapsed without a `ready`. */
|
|
150
|
+
timeoutMs: number;
|
|
151
|
+
}
|
|
152
|
+
| {
|
|
153
|
+
kind: "version-mismatch";
|
|
154
|
+
url: string;
|
|
155
|
+
expectedVersion: number;
|
|
156
|
+
/** Absent when the ready carried no stamp (a pre-stamp asset). */
|
|
157
|
+
reportedVersion?: number;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** User-facing message for the in-surface error banner. */
|
|
161
|
+
export function formatBridgeUnavailableMessage(info: BridgeUnavailableInfo): string {
|
|
162
|
+
if (info.kind === "timeout") {
|
|
163
|
+
return `Annotation tools did not load: the bridge script at ${info.url} sent no ready signal within ${info.timeoutMs} ms. The page is shown without annotation. Check that the URL is reachable and that your Content Security Policy allows script-src for that origin.`;
|
|
164
|
+
}
|
|
165
|
+
const reported = info.reportedVersion === undefined ? "no version" : `version ${info.reportedVersion}`;
|
|
166
|
+
return `Annotation tools may not work: this viewer expects bridge protocol version ${info.expectedVersion}, but the script at ${info.url} reported ${reported}. Serve the bridge-script asset from the same @plannotator/ui version as the viewer.`;
|
|
167
|
+
}
|
|
168
|
+
|
|
132
169
|
function parseVimBridgeCopy(value: unknown): string | null {
|
|
133
170
|
return isRecord(value)
|
|
134
171
|
&& value.type === `${PREFIX}vim-copy`
|
|
@@ -202,10 +239,51 @@ export interface HtmlViewerProps {
|
|
|
202
239
|
/** Reports the full set of annotation ids with no live representation on
|
|
203
240
|
* the page (fail-closed anchors hide markers rather than guess). Called
|
|
204
241
|
* with the complete current set whenever it changes, including back to
|
|
205
|
-
* empty on recovery. Fires in readOnly mode too.
|
|
242
|
+
* empty on recovery. Fires in readOnly mode too. Complete over the
|
|
243
|
+
* `annotations` prop: page rows with nothing to restore by (no quoted
|
|
244
|
+
* text, no element anchor) are reported even though the bridge never
|
|
245
|
+
* sees them, and an id this viewer minted for a local comment that the
|
|
246
|
+
* host swapped out of `annotations` for its own id is not reported. */
|
|
206
247
|
onUnanchoredChange?: (ids: string[]) => void;
|
|
248
|
+
/** Product cap on additional (shift-click) targets per comment, 0..16.
|
|
249
|
+
* Enforced at the trust boundary, on submit and on restore, and carried
|
|
250
|
+
* to the bridge so the in-page toggle stops at the same number. Default
|
|
251
|
+
* 16 (the package cap); absent leaves every message unchanged. */
|
|
252
|
+
maxAdditionalTargets?: number;
|
|
253
|
+
/** scrollIntoView behavior when a selected annotation is scrolled into
|
|
254
|
+
* view inside the page. Default 'smooth'; pass 'auto' to carry the
|
|
255
|
+
* parent's reduced-motion preference across the iframe boundary. */
|
|
256
|
+
scrollBehavior?: 'smooth' | 'auto';
|
|
207
257
|
/** Accessible iframe title. */
|
|
208
258
|
title?: string;
|
|
259
|
+
/**
|
|
260
|
+
* Opt-in: load the annotation bridge into the srcdoc document through a
|
|
261
|
+
* classic `<script src>` from this URL (the package's generated
|
|
262
|
+
* `components/html-viewer/bridge-script.asset.js`, served by the host)
|
|
263
|
+
* instead of inlining the 185 KB script into every document. Absent (the
|
|
264
|
+
* default, and Plannotator's only path): inline, unchanged. The tag lands
|
|
265
|
+
* where the inline script does, at the end of `<head>`, before the body.
|
|
266
|
+
* The URL is resolved against THIS document's base (`document.baseURI`)
|
|
267
|
+
* before it is written, never against the framed page, so a page's own
|
|
268
|
+
* `<base href>` cannot redirect it. The srcdoc frame is an opaque origin,
|
|
269
|
+
* so the script needs no CORS and no `crossorigin` attribute is set; a CSP
|
|
270
|
+
* header on the host page is inherited by the frame and must allow
|
|
271
|
+
* `script-src` for the asset origin, and the asset must not be served with
|
|
272
|
+
* `Cross-Origin-Resource-Policy: same-origin`. Ignored in live (`src`)
|
|
273
|
+
* mode, where the proxy injects the bridge.
|
|
274
|
+
*/
|
|
275
|
+
bridgeScriptUrl?: string;
|
|
276
|
+
/**
|
|
277
|
+
* How long to wait for the bridge's `ready` after each document load on
|
|
278
|
+
* the `bridgeScriptUrl` path before the surface shows an error state.
|
|
279
|
+
* Default 5000 ms. No timer runs on the inline path.
|
|
280
|
+
*/
|
|
281
|
+
bridgeReadyTimeoutMs?: number;
|
|
282
|
+
/** The bridge could not be established on the `bridgeScriptUrl` path (no
|
|
283
|
+
* ready within the timeout, or a protocol version mismatch). The surface
|
|
284
|
+
* shows its own banner as well; this lets the host react (telemetry, a
|
|
285
|
+
* retry affordance). Never called on the inline path. */
|
|
286
|
+
onBridgeUnavailable?: (info: BridgeUnavailableInfo) => void;
|
|
209
287
|
}
|
|
210
288
|
|
|
211
289
|
/**
|
|
@@ -245,7 +323,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
245
323
|
onAskAI,
|
|
246
324
|
readOnly = false,
|
|
247
325
|
onUnanchoredChange,
|
|
326
|
+
maxAdditionalTargets,
|
|
327
|
+
scrollBehavior,
|
|
248
328
|
title = "HTML Plan Viewer",
|
|
329
|
+
bridgeScriptUrl,
|
|
330
|
+
bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
|
|
331
|
+
onBridgeUnavailable,
|
|
249
332
|
},
|
|
250
333
|
ref,
|
|
251
334
|
) => {
|
|
@@ -303,6 +386,18 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
303
386
|
// themselves); arbitrary HTML renders untouched, like a standalone tab.
|
|
304
387
|
const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
|
|
305
388
|
|
|
389
|
+
// The URL path is srcdoc-only: live mode has the proxy inject the bridge.
|
|
390
|
+
// Resolved against THIS document's base before it is written into the
|
|
391
|
+
// srcdoc, so a framed page's own <base href> can never re-anchor it.
|
|
392
|
+
const bridgeUrl = useMemo(
|
|
393
|
+
() => (!liveMode && bridgeScriptUrl
|
|
394
|
+
? resolveBridgeScriptUrl(bridgeScriptUrl, document.baseURI)
|
|
395
|
+
: undefined),
|
|
396
|
+
[liveMode, bridgeScriptUrl],
|
|
397
|
+
);
|
|
398
|
+
const bridgeUrlRef = useRef(bridgeUrl);
|
|
399
|
+
bridgeUrlRef.current = bridgeUrl;
|
|
400
|
+
|
|
306
401
|
const srcdoc = useMemo(() => {
|
|
307
402
|
if (liveMode) return undefined; // src mode: the proxy injects the bridge
|
|
308
403
|
const injection = buildSrcdocInjection({
|
|
@@ -310,9 +405,46 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
310
405
|
isLight: isLightTheme(),
|
|
311
406
|
hostTheme,
|
|
312
407
|
diffActive: !!diffActive,
|
|
408
|
+
bridgeScriptUrl: bridgeUrl,
|
|
313
409
|
});
|
|
314
410
|
return injectIntoHead(rawHtml, injection);
|
|
315
|
-
}, [liveMode, rawHtml, hostTheme, diffActive]);
|
|
411
|
+
}, [liveMode, rawHtml, hostTheme, diffActive, bridgeUrl]);
|
|
412
|
+
|
|
413
|
+
// Error state for the bridgeScriptUrl path only: the inline path never
|
|
414
|
+
// sets it (no timer, and a version mismatch there can only be a forged
|
|
415
|
+
// message, which is warned about and otherwise ignored).
|
|
416
|
+
const [bridgeError, setBridgeError] = useState<BridgeUnavailableInfo | null>(null);
|
|
417
|
+
// A version-mismatch banner is dismissible (the older bridge keeps
|
|
418
|
+
// working); reset whenever the error itself changes.
|
|
419
|
+
const [bridgeErrorDismissed, setBridgeErrorDismissed] = useState(false);
|
|
420
|
+
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
421
|
+
const onBridgeUnavailableRef = useRef(onBridgeUnavailable);
|
|
422
|
+
onBridgeUnavailableRef.current = onBridgeUnavailable;
|
|
423
|
+
// The timeout is read through a ref at arming time: the timer is armed
|
|
424
|
+
// once per document load (URL or srcdoc change), never re-armed by a
|
|
425
|
+
// later prop change, so a host adjusting bridgeReadyTimeoutMs after the
|
|
426
|
+
// bridge is ready can never produce a false timeout.
|
|
427
|
+
const bridgeReadyTimeoutMsRef = useRef(bridgeReadyTimeoutMs);
|
|
428
|
+
bridgeReadyTimeoutMsRef.current = bridgeReadyTimeoutMs;
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
if (!bridgeUrl || srcdoc === undefined) return;
|
|
431
|
+
setBridgeError(null);
|
|
432
|
+
setBridgeErrorDismissed(false);
|
|
433
|
+
const url = bridgeUrl;
|
|
434
|
+
const timeoutMs = bridgeReadyTimeoutMsRef.current;
|
|
435
|
+
readyTimerRef.current = setTimeout(() => {
|
|
436
|
+
readyTimerRef.current = null;
|
|
437
|
+
setBridgeError({ kind: "timeout", url, timeoutMs });
|
|
438
|
+
}, timeoutMs);
|
|
439
|
+
return () => {
|
|
440
|
+
if (readyTimerRef.current !== null) clearTimeout(readyTimerRef.current);
|
|
441
|
+
readyTimerRef.current = null;
|
|
442
|
+
};
|
|
443
|
+
}, [bridgeUrl, srcdoc]);
|
|
444
|
+
useEffect(() => {
|
|
445
|
+
setBridgeErrorDismissed(false);
|
|
446
|
+
if (bridgeError) onBridgeUnavailableRef.current?.(bridgeError);
|
|
447
|
+
}, [bridgeError]);
|
|
316
448
|
|
|
317
449
|
const handleResize = useCallback((height: number) => {
|
|
318
450
|
if (liveMode) return; // live surfaces are full-viewport; height is ignored
|
|
@@ -358,6 +490,63 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
358
490
|
[handleYieldPointer],
|
|
359
491
|
);
|
|
360
492
|
|
|
493
|
+
// Unanchored union: the bridge reports ids with no live representation,
|
|
494
|
+
// completed here with what the bridge cannot see (textless page rows it
|
|
495
|
+
// was never asked to restore) and minus locally minted ids the host
|
|
496
|
+
// swapped out of `annotations`. Bridge reports deliver as they arrive
|
|
497
|
+
// (pass-through timing); a prop-side change delivers only when the union
|
|
498
|
+
// actually changes, so a viewer with nothing to complete delivers exactly
|
|
499
|
+
// the bridge list, exactly when the bridge posts it.
|
|
500
|
+
const onUnanchoredChangeRef = useRef(onUnanchoredChange);
|
|
501
|
+
onUnanchoredChangeRef.current = onUnanchoredChange;
|
|
502
|
+
const annotationsRef = useRef(annotations);
|
|
503
|
+
annotationsRef.current = annotations;
|
|
504
|
+
const bridgeUnanchoredRef = useRef<readonly string[]>([]);
|
|
505
|
+
const lastDeliveredUnanchoredRef = useRef("[]");
|
|
506
|
+
const createdIdsRef = useRef<ReadonlySet<string>>(new Set());
|
|
507
|
+
// The bridge's first report for the current document is the one that
|
|
508
|
+
// follows the restore batch (the parent asks for it with
|
|
509
|
+
// report-unanchored, and the bridge answers after its next complete
|
|
510
|
+
// pass, empty set included). Nothing is delivered before it: a host
|
|
511
|
+
// that acknowledges "the first report after a reload" must get the
|
|
512
|
+
// post-restore set, never a prop-side set computed at mount.
|
|
513
|
+
const bridgeReportedRef = useRef(false);
|
|
514
|
+
// An in-place document swap (rawHtml or src changes on one instance):
|
|
515
|
+
// the old document may still emit through the same contentWindow before
|
|
516
|
+
// the new one is ready. From the swap until the next ready, bridge
|
|
517
|
+
// reports belong to the old document and are dropped, and the
|
|
518
|
+
// last-delivered key is reset so the new document's first answer is
|
|
519
|
+
// delivered even when it equals the old one. First mount is not a swap.
|
|
520
|
+
const awaitingReadyRef = useRef(false);
|
|
521
|
+
const documentIdentityRef = useRef<{ rawHtml: string; src: string | undefined } | null>(null);
|
|
522
|
+
const previousIdentity = documentIdentityRef.current;
|
|
523
|
+
if (previousIdentity && (previousIdentity.rawHtml !== rawHtml || previousIdentity.src !== src)) {
|
|
524
|
+
awaitingReadyRef.current = true;
|
|
525
|
+
bridgeReportedRef.current = false;
|
|
526
|
+
lastDeliveredUnanchoredRef.current = "[]";
|
|
527
|
+
bridgeUnanchoredRef.current = [];
|
|
528
|
+
}
|
|
529
|
+
documentIdentityRef.current = { rawHtml, src };
|
|
530
|
+
const deliverUnanchored = useCallback((ids: string[], onlyIfChanged: boolean) => {
|
|
531
|
+
const key = JSON.stringify(ids);
|
|
532
|
+
if (onlyIfChanged && key === lastDeliveredUnanchoredRef.current) return;
|
|
533
|
+
lastDeliveredUnanchoredRef.current = key;
|
|
534
|
+
onUnanchoredChangeRef.current?.(ids);
|
|
535
|
+
}, []);
|
|
536
|
+
const handleBridgeUnanchored = useCallback((ids: string[]) => {
|
|
537
|
+
if (awaitingReadyRef.current) return;
|
|
538
|
+
bridgeUnanchoredRef.current = ids;
|
|
539
|
+
bridgeReportedRef.current = true;
|
|
540
|
+
deliverUnanchored(
|
|
541
|
+
mergeUnanchoredIds({
|
|
542
|
+
bridgeIds: ids,
|
|
543
|
+
annotations: annotationsRef.current,
|
|
544
|
+
createdIds: createdIdsRef.current,
|
|
545
|
+
}),
|
|
546
|
+
false,
|
|
547
|
+
);
|
|
548
|
+
}, [deliverUnanchored]);
|
|
549
|
+
|
|
361
550
|
const hook = useHtmlAnnotation({
|
|
362
551
|
iframeRef,
|
|
363
552
|
enabled: !readOnly,
|
|
@@ -370,8 +559,23 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
370
559
|
live: liveSession,
|
|
371
560
|
onPageChange,
|
|
372
561
|
onBridgePointer: handleBridgePointer,
|
|
373
|
-
onUnanchoredChange,
|
|
562
|
+
onUnanchoredChange: handleBridgeUnanchored,
|
|
563
|
+
maxAdditionalTargets,
|
|
564
|
+
scrollBehavior,
|
|
374
565
|
});
|
|
566
|
+
createdIdsRef.current = hook.createdAnnotationIds;
|
|
567
|
+
|
|
568
|
+
useEffect(() => {
|
|
569
|
+
if (!bridgeReportedRef.current) return;
|
|
570
|
+
deliverUnanchored(
|
|
571
|
+
mergeUnanchoredIds({
|
|
572
|
+
bridgeIds: bridgeUnanchoredRef.current,
|
|
573
|
+
annotations,
|
|
574
|
+
createdIds: hook.createdAnnotationIds,
|
|
575
|
+
}),
|
|
576
|
+
true,
|
|
577
|
+
);
|
|
578
|
+
}, [annotations, hook.createdAnnotationIds, deliverUnanchored]);
|
|
375
579
|
|
|
376
580
|
const multiSelectActive = !readOnly && !!hook.commentPopover && hook.draftTargets.length > 0;
|
|
377
581
|
|
|
@@ -427,6 +631,33 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
427
631
|
const live = liveSessionRef.current;
|
|
428
632
|
if (live && rejectsLiveMessage(live, e.origin, e.data)) return;
|
|
429
633
|
if (isBridgeReadyMessage(e.data)) {
|
|
634
|
+
// Protocol stamp: one console warning on drift, naming both
|
|
635
|
+
// versions. The ready is still honored (an older bridge answers
|
|
636
|
+
// every message shape it knows); on the bridgeScriptUrl path the
|
|
637
|
+
// surface additionally shows its error banner, because there the
|
|
638
|
+
// drift is a real deployment state (a cached asset from a previous
|
|
639
|
+
// package version) rather than a forged message.
|
|
640
|
+
const verdict = checkBridgeProtocolVersion(e.data);
|
|
641
|
+
const url = bridgeUrlRef.current;
|
|
642
|
+
if (!verdict.ok) {
|
|
643
|
+
console.warn(formatBridgeProtocolWarning(verdict, url));
|
|
644
|
+
}
|
|
645
|
+
if (url) {
|
|
646
|
+
if (readyTimerRef.current !== null) {
|
|
647
|
+
clearTimeout(readyTimerRef.current);
|
|
648
|
+
readyTimerRef.current = null;
|
|
649
|
+
}
|
|
650
|
+
setBridgeError(
|
|
651
|
+
verdict.ok
|
|
652
|
+
? null
|
|
653
|
+
: {
|
|
654
|
+
kind: "version-mismatch",
|
|
655
|
+
url,
|
|
656
|
+
expectedVersion: verdict.expected,
|
|
657
|
+
reportedVersion: verdict.reported,
|
|
658
|
+
},
|
|
659
|
+
);
|
|
660
|
+
}
|
|
430
661
|
setIframeReadyVersion((version) => version + 1);
|
|
431
662
|
setVimBridgePhase("inactive");
|
|
432
663
|
setVimHudCommand(null);
|
|
@@ -547,6 +778,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
547
778
|
if (restorable.length > 0) {
|
|
548
779
|
hook.applyAnnotations(restorable);
|
|
549
780
|
}
|
|
781
|
+
// A fresh document: the bridge starts from an empty set and would stay
|
|
782
|
+
// silent when everything restores. Ask for one complete report after
|
|
783
|
+
// this restore batch (posted after it, so the answering pass sees it),
|
|
784
|
+
// which becomes this document's first delivery, empty set included.
|
|
785
|
+
awaitingReadyRef.current = false;
|
|
786
|
+
bridgeReportedRef.current = false;
|
|
787
|
+
postToBridge({ type: `${PREFIX}report-unanchored` });
|
|
550
788
|
}, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
551
789
|
|
|
552
790
|
// Live page navigation with a ready iframe: explicitly clear the previous
|
|
@@ -569,6 +807,11 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
569
807
|
type: `${PREFIX}sync-annotations`,
|
|
570
808
|
annotations: buildSyncNumbering(annotations),
|
|
571
809
|
});
|
|
810
|
+
// A new page is a new restore batch: report its complete set once, and
|
|
811
|
+
// deliver nothing computed against the previous page's report until
|
|
812
|
+
// that answer arrives.
|
|
813
|
+
bridgeReportedRef.current = false;
|
|
814
|
+
postToBridge({ type: `${PREFIX}report-unanchored` });
|
|
572
815
|
}, [currentPageUrl, iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
573
816
|
|
|
574
817
|
// Placed-marker numbering is parent-authoritative and matches the
|
|
@@ -815,6 +1058,37 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
815
1058
|
{actionButtons}
|
|
816
1059
|
</div>
|
|
817
1060
|
)}
|
|
1061
|
+
{/* bridgeScriptUrl path only: the bridge did not come up (no
|
|
1062
|
+
ready within the timeout, or a stale asset's version). Floated
|
|
1063
|
+
over the top of the iframe so it never changes the layout the
|
|
1064
|
+
page renders in; the page itself stays visible. */}
|
|
1065
|
+
{bridgeError && !bridgeErrorDismissed && (
|
|
1066
|
+
<div
|
|
1067
|
+
role="alert"
|
|
1068
|
+
data-print-hide
|
|
1069
|
+
data-bridge-error={bridgeError.kind}
|
|
1070
|
+
className="absolute inset-x-0 top-0 z-20 border-b border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive backdrop-blur-sm"
|
|
1071
|
+
style={{ display: "flex", alignItems: "flex-start", gap: 8 }}
|
|
1072
|
+
>
|
|
1073
|
+
<span style={{ flex: 1 }}>{formatBridgeUnavailableMessage(bridgeError)}</span>
|
|
1074
|
+
{/* Only the mismatch state is dismissible: the older bridge
|
|
1075
|
+
still works there. A timeout leaves a dead surface, so
|
|
1076
|
+
that banner stays. Inline styles on purpose: hosts that
|
|
1077
|
+
build the guides.show viewer scan this file for utility
|
|
1078
|
+
classes, and this banner must not grow that stylesheet. */}
|
|
1079
|
+
{bridgeError.kind === "version-mismatch" && (
|
|
1080
|
+
<button
|
|
1081
|
+
type="button"
|
|
1082
|
+
data-bridge-error-dismiss
|
|
1083
|
+
aria-label="Dismiss"
|
|
1084
|
+
style={{ flexShrink: 0, borderRadius: 4, padding: "2px 6px", fontWeight: 500, cursor: "pointer", background: "transparent", border: "1px solid currentColor", color: "inherit" }}
|
|
1085
|
+
onClick={() => setBridgeErrorDismissed(true)}
|
|
1086
|
+
>
|
|
1087
|
+
Dismiss
|
|
1088
|
+
</button>
|
|
1089
|
+
)}
|
|
1090
|
+
</div>
|
|
1091
|
+
)}
|
|
818
1092
|
{/* Live proxied-app mode navigates a real loopback origin: no
|
|
819
1093
|
sandbox (the user's own app needs cookies, storage, and
|
|
820
1094
|
same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
|
|
@@ -877,10 +1151,15 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
877
1151
|
positionMode="center-above"
|
|
878
1152
|
element={hook.toolbarState.element}
|
|
879
1153
|
copyText={hook.toolbarState.selectionText}
|
|
880
|
-
// HTML/live surfaces are comment-only: no Delete, no
|
|
881
|
-
//
|
|
882
|
-
//
|
|
1154
|
+
// HTML/live surfaces are comment-only: no Delete, no label
|
|
1155
|
+
// picker, no Alt+digit labels (commentOnly). Exactly ONE label
|
|
1156
|
+
// affordance is restored: the hardcoded 👍 "Looks good". The
|
|
1157
|
+
// wrapper filters by id as defense in depth, so no present or
|
|
1158
|
+
// future toolbar path can emit an arbitrary label here.
|
|
883
1159
|
commentOnly
|
|
1160
|
+
onQuickLabel={(label) => {
|
|
1161
|
+
if (label.id === THUMBS_UP_LABEL.id) hook.handleQuickLabel(label);
|
|
1162
|
+
}}
|
|
884
1163
|
onAnnotate={hook.handleAnnotate}
|
|
885
1164
|
onRequestComment={hook.handleRequestComment}
|
|
886
1165
|
onClose={hook.handleToolbarClose}
|
|
@@ -898,6 +1177,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
898
1177
|
isGlobal={false}
|
|
899
1178
|
draftKey={`html:${hook.commentPopover.draftKey}`}
|
|
900
1179
|
onSubmit={hook.handleCommentSubmit}
|
|
1180
|
+
// Pinpoint clicks open this composer directly, so it carries
|
|
1181
|
+
// the surface's one-click "Looks good" (the global composer
|
|
1182
|
+
// does not: a document-wide thumbs-up is not a thing).
|
|
1183
|
+
onQuickLookGood={hook.handleCommentLooksGood}
|
|
901
1184
|
onClose={hook.handleCommentClose}
|
|
902
1185
|
skillReferences
|
|
903
1186
|
onAskAI={onAskAI}
|