@plannotator/ui 0.32.0 → 0.34.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 +697 -0
- package/README.md +17 -5
- package/components/MermaidBlock.tsx +16 -0
- package/components/html-viewer/HtmlViewer.tsx +187 -1
- 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 +13 -1
- package/components/html-viewer/index.ts +13 -1
- package/components/html-viewer/srcdoc.ts +70 -1
- package/components/html-viewer/useHtmlAnnotation.ts +37 -0
- package/package.json +6 -2
- package/styles.css +1 -1
- package/utils/math-default-loader.ts +24 -0
- package/utils/math.ts +45 -18
- package/utils/mermaid-math-slot.ts +56 -0
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ Building your own tooltip and removing the built-in double-click reset are host-
|
|
|
54
54
|
|
|
55
55
|
The Mermaid runtime, the Graphviz engine, KaTeX and the username dictionary are off the static import graph of `Viewer`, so a host that bundles by route does not download them for a plain markdown read. Graphviz needs nothing from you (the block imports the engine inside its render effect and shows the source fence until the SVG lands, as it always did). Mermaid, KaTeX and the dictionary sit behind synchronous slots:
|
|
56
56
|
|
|
57
|
-
- **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3).
|
|
57
|
+
- **Math.** Without registration, a math node renders its TeX as text in the same wrapper (same `data-math-tex` / `data-math-display` / `aria-label` / class names), loads KaTeX via `import('katex')`, and re-renders typeset. To keep math typeset on the very first commit, as Plannotator does, add one line to your entry: `import "@plannotator/ui/utils/math-eager";`. To put KaTeX and its stylesheet on one lazy chunk instead, pass `mathRendererLoader`. The stylesheet remains your job either way (see "Consuming it", step 3). The default `import('katex')` is the only runtime mention of `katex` in the package and lives in `utils/math-default-loader` (0.33.0), called only while no loader is registered; a registered loader is never backfilled by it, though a default load already in flight at registration still fills the slot (pre-existing), so register the loader before the first math render. Chunk emission is static, so a bundler still emits that chunk (never requested) unless you alias the module away; see HANDOFF.md "Lazy renderers and eager entries" for the two-line alias. The Mermaid runtime has its own `import("katex")` for `$$` labels, which leaves a second, shared KaTeX chunk in a host build even with the alias; since 0.34.0 a host redirects that one import (for importers inside the `mermaid` package only) to `@plannotator/ui/utils/mermaid-math-slot`, which typesets the labels through your registered renderer, so one KaTeX chunk remains and it is yours. Recipe and measurement in HANDOFF.md, same section. `resetMathRenderer()` empties the slot only and keeps a registered loader (0.34.0); `setMathRendererLoader(null)` is the explicit way back to the package default.
|
|
58
58
|
- **Mermaid.** Without registration, the first diagram on a page fetches the runtime through `import('mermaid')`; a failed import is dropped from the memo, re-attempted once after a short delay, and the error panel (with the source) offers Retry, which issues another fresh attempt. Plannotator keeps Mermaid eager by policy so it can never fail separately from the app: `import "@plannotator/ui/utils/mermaid-eager";` in your entry does the same for your bundle. Honest limit of any in-page retry: a browser records a failed module fetch in its module map for the page lifetime, so a fresh `import()` of the same chunk URL rejects without a request; the retry recovers failures after the fetch (engine instantiation, initialize) and hosts that version chunk URLs. A host that needs recovery from a failed first fetch uses versioned chunk URLs or a `vite:preloadError` reload at app level.
|
|
59
59
|
- **Identity.** With an `identityProvider` the generator is never called and the word lists stay out of your bundle. Without one, default names come from a small built-in pool of the same `adjective-noun-tater` shape; `import "@plannotator/ui/utils/identity-tater";` registers the full dictionary, or pass your own `identityGenerator`.
|
|
60
60
|
|
|
@@ -102,16 +102,28 @@ Requires `@plannotator/markdown-editor ^0.4.0` and `@plannotator/atomic-editor ^
|
|
|
102
102
|
|
|
103
103
|
Everything a host needs around `HtmlViewer` to match Plannotator's HTML annotation experience, all additive and all defaulting to today's behavior. Requires `@plannotator/core` 0.25.0 (the `html-anchor` subpath), so install and publish core before ui:
|
|
104
104
|
|
|
105
|
-
- **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names.
|
|
106
|
-
- **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side.
|
|
105
|
+
- **`projectHostThreads(threads, { openOnly?, documentLevel?, maxTargets? })`** and **`buildPersistedHtmlAnchor(source, { maxBytes?, maxTargets? })`** from `components/html-viewer` (pure, from `@plannotator/core/html-anchor`): project stored rows onto the `annotations` prop in the order that becomes the marker numbering, and trim a composed comment's anchor for persistence with cap drops and size drops reported separately. A row with nothing restorable projects as a document-level `GLOBAL_COMMENT` by default (`documentLevel: 'global'`, never reported as unanchored) or, with `documentLevel: 'unanchored'`, as a textless page `COMMENT` the unanchored report names. **HTML-only:** the projection carries `originalText`, `htmlAnchor` and `htmlAdditionalTargets`, and pins `blockId` to `""`, offsets to `0` and no `startMeta` / `endMeta`; on the markdown `Viewer` a projected `COMMENT` with quoted text still re-anchors by whole-document text search, but with `blockId` `""` and offsets `0` it loses export ordering (every such row sorts first and ties), the "lines N-M" location label, disambiguation when the same text repeats (first match wins), and the no-flash meta restore; a host that needs those carries `blockId`, the offsets and the web-highlighter metas in its own projection.
|
|
106
|
+
- **`onUnanchoredChange`** is keyed to the bridge's restore (one complete report per document after the restore batch, the empty set included) and complete over the `annotations` prop: textless page rows are reported without being posted, and a locally minted id the host swapped out of its list is not. It replaces a host's `mark-applied` bookkeeping for the unanchored set; the local-to-server mark swap itself stays host-side. **Nothing is delivered before the bridge's first post-restore report for a document (per reload generation):** a prop-side change before that point does not fire the callback, so do not gate host state on a prop-side delivery arriving first; treat the first call as the restore's verdict.
|
|
107
107
|
- **`hooks/useHtmlRefresh({ fetchSnapshot, onSnapshot, onUnanchored?, onResult? })`**: the refresh cycle with the stale-response and document-change guards, backend behind `fetchSnapshot`.
|
|
108
108
|
- **`components/HtmlSurfaceControls`**: the eye / refresh / pen header controls with Plannotator's markup and `labels` overrides.
|
|
109
109
|
- **`AnnotationPanel` `unanchoredIds`**: an "Unanchored" chip on the listed cards.
|
|
110
|
-
- **`HtmlViewer` `scrollBehavior`** (`'auto'` for reduced motion) and **`maxAdditionalTargets`** (a product cap the bridge honors too).
|
|
110
|
+
- **`HtmlViewer` `scrollBehavior`** (`'auto'` for reduced motion) and **`maxAdditionalTargets`** (a product cap the bridge honors too). With the cap enforced upstream (bridge toggle, parent trust boundary on submit and on restore, `projectHostThreads` `maxTargets` on read), a composed comment never reaches the host with more targets than the cap, so a host's own cap-dropped handling (`capDroppedTargets` from `buildPersistedHtmlAnchor`, or a message-counting listener) is unreachable in normal operation; keep it only as a backstop for rows written by an older host build or by another writer. Byte-budget drops (`sizeDroppedTargets`) are a separate path and remain reachable.
|
|
111
111
|
- An `ExternalAnnotationTransport` whose `subscribe` emits `snapshot` on a host push keeps `useExternalAnnotations` off its fallback poll.
|
|
112
112
|
|
|
113
113
|
See HANDOFF.md § "HTML annotation parity seams".
|
|
114
114
|
|
|
115
|
+
#### The bridge script as an asset (`bridgeScriptUrl`; 0.33.0)
|
|
116
|
+
|
|
117
|
+
By default `HtmlViewer` inlines its 185 KB in-page bridge script into every srcdoc document. A host that serves the package's generated `components/html-viewer/bridge-script.asset.js` as a static file can pass its URL instead:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-script.asset.js?url";
|
|
121
|
+
|
|
122
|
+
<HtmlViewer rawHtml={html} bridgeScriptUrl={bridgeScriptUrl} … />
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The srcdoc then carries one classic `<script src>` in the exact place the inline script sat (at the end of `<head>`, before the body), the browser caches the asset across documents, and the bridge's `ready` message carries `BRIDGE_PROTOCOL_VERSION`, which the viewer checks: a stale cached asset (no stamp, or another version) logs one console warning naming both versions and shows a dismissible error banner in the surface (`onBridgeUnavailable` fires too); no `ready` within `bridgeReadyTimeoutMs` (default 5000) shows a timeout banner. The package owns that banner by default (`bridgeErrorDisplay="banner"`); a host that renders its own notice from `onBridgeUnavailable` passes `bridgeErrorDisplay="none"` (0.34.0) and no strip is rendered, while the callback and the console warning are unchanged. The URL is resolved against your document's base (`document.baseURI`) before it is written into the srcdoc, never against the framed page, so a page's own `<base href>` cannot redirect it. Plannotator passes nothing and stays inline; none of this runs on the inline path. **CSP:** the package sets no CSP `<meta>` in the srcdoc document, and the frame is an opaque origin so the classic script needs no CORS (no `crossorigin` is set), but a CSP delivered as a header on your page is inherited by the frame: allow `script-src` for the asset's origin. Because the frame is an opaque origin, an asset served with `Cross-Origin-Resource-Policy: same-origin` (common alongside COEP) is blocked; serve it with a CORP that admits cross-origin loads, or without CORP. To also drop the inline literal from your viewer chunk, alias the package's relative `./bridge-script` import (match `/^\.\/bridge-script$/`, never a bare `/\/bridge-script$/`, which would also catch another package's `bridge-script` entry) to the generated `bridge-script.lite` module (see HANDOFF.md § "HTML viewer bridge as an asset").
|
|
126
|
+
|
|
115
127
|
#### Also blessed in 0.32.0: `shortcuts` and `utils/inputMethod`
|
|
116
128
|
|
|
117
129
|
- **`@plannotator/ui/shortcuts`**: the declarative keyboard-shortcut engine (`defineShortcutScope`, `useShortcutScope`) and the per-surface scopes, including `useHtmlAnnotateShortcuts` for the Mod+Shift+A Annotate/Interact chord on HTML surfaces. Pure React plus `utils/platform`; no backend.
|
|
@@ -152,7 +164,7 @@ npm install @plannotator/ui @plannotator/core
|
|
|
152
164
|
- `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
|
|
153
165
|
- `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published.
|
|
154
166
|
- `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
|
|
155
|
-
- Versioned
|
|
167
|
+
- Versioned together (currently `@plannotator/ui` 0.34.0 on `@plannotator/core` 0.25.0). `core` is bumped only when something under `packages/core` changed, so `ui` can advance alone: 0.33.0 and 0.34.0 are such releases, published on the already available core 0.25.0. When both change, publish `core` then `ui`: build each tarball with **`bun pm pack`** (resolves `workspace:*` to the exact version at pack time, from `bun.lock`, so run `bun install` after a bump), then **`npm publish *.tgz --provenance --access public`**, the repo's existing flow (`--provenance` needs CI OIDC; local publishes drop it, see HANDOFF.md "Publishing & versioning").
|
|
156
168
|
|
|
157
169
|
## The one rule
|
|
158
170
|
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
loadMermaidRuntime,
|
|
10
10
|
__setMermaidRuntimeLoaderForTests,
|
|
11
11
|
} from '../utils/mermaid';
|
|
12
|
+
import { loadMathRenderer } from '../utils/math';
|
|
13
|
+
import { hasMermaidMath } from '../utils/mermaid-math-slot';
|
|
12
14
|
|
|
13
15
|
// Re-exported: the config pin test and the lazy-retry test import them from here.
|
|
14
16
|
export { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests };
|
|
@@ -208,6 +210,20 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
208
210
|
return;
|
|
209
211
|
}
|
|
210
212
|
try {
|
|
213
|
+
// A `$$` label makes Mermaid render KaTeX. On a host that redirects
|
|
214
|
+
// Mermaid's `katex` import to `utils/mermaid-math-slot` the label is
|
|
215
|
+
// typeset through the math slot, which must be filled by then: warm
|
|
216
|
+
// it with the registered loader first. A filled slot (Plannotator's
|
|
217
|
+
// eager entry) resolves at once; a load failure is left to the
|
|
218
|
+
// render, whose error panel names it with the source.
|
|
219
|
+
if (hasMermaidMath(block.content)) {
|
|
220
|
+
try {
|
|
221
|
+
await loadMathRenderer();
|
|
222
|
+
} catch {
|
|
223
|
+
// Reported by the render below.
|
|
224
|
+
}
|
|
225
|
+
if (cancelled) return;
|
|
226
|
+
}
|
|
211
227
|
const id = `mermaid-${block.id}`;
|
|
212
228
|
const { svg: renderedSvg } = await mermaid.render(id, block.content);
|
|
213
229
|
if (!cancelled) {
|
|
@@ -41,6 +41,8 @@ import { buildSyncNumbering } from "./annotationNumbering";
|
|
|
41
41
|
import { mergeUnanchoredIds } from "./unanchored";
|
|
42
42
|
import {
|
|
43
43
|
MAX_PAGE_URL_LENGTH,
|
|
44
|
+
checkBridgeProtocolVersion,
|
|
45
|
+
formatBridgeProtocolWarning,
|
|
44
46
|
rejectsLiveMessage,
|
|
45
47
|
useHtmlAnnotation,
|
|
46
48
|
type HtmlLiveSession,
|
|
@@ -51,6 +53,7 @@ import {
|
|
|
51
53
|
buildThemeTokenPayload,
|
|
52
54
|
hasHostThemeOptIn,
|
|
53
55
|
injectIntoHead,
|
|
56
|
+
resolveBridgeScriptUrl,
|
|
54
57
|
} from "./srcdoc";
|
|
55
58
|
|
|
56
59
|
const PREFIX = "plannotator-bridge-";
|
|
@@ -131,6 +134,38 @@ function parseVimBridgeHelp(value: unknown): boolean | null {
|
|
|
131
134
|
|
|
132
135
|
const MAX_VIM_COPY_TEXT_LENGTH = 2 * 1024 * 1024;
|
|
133
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
|
+
|
|
134
169
|
function parseVimBridgeCopy(value: unknown): string | null {
|
|
135
170
|
return isRecord(value)
|
|
136
171
|
&& value.type === `${PREFIX}vim-copy`
|
|
@@ -221,6 +256,44 @@ export interface HtmlViewerProps {
|
|
|
221
256
|
scrollBehavior?: 'smooth' | 'auto';
|
|
222
257
|
/** Accessible iframe title. */
|
|
223
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 unless `bridgeErrorDisplay` is `'none'`;
|
|
285
|
+
* this lets the host react (telemetry, a retry affordance). Never called
|
|
286
|
+
* on the inline path. */
|
|
287
|
+
onBridgeUnavailable?: (info: BridgeUnavailableInfo) => void;
|
|
288
|
+
/**
|
|
289
|
+
* Who renders the bridge-failure strip on the `bridgeScriptUrl` path.
|
|
290
|
+
* `'banner'` (default): the package renders its `[data-bridge-error]`
|
|
291
|
+
* strip over the frame, as in 0.33.0. `'none'`: no strip is rendered and
|
|
292
|
+
* the host owns the display through `onBridgeUnavailable`, which fires
|
|
293
|
+
* exactly as before (and a version mismatch still logs its one console
|
|
294
|
+
* warning). Meaningless on the inline path, which never shows a strip.
|
|
295
|
+
*/
|
|
296
|
+
bridgeErrorDisplay?: "banner" | "none";
|
|
224
297
|
}
|
|
225
298
|
|
|
226
299
|
/**
|
|
@@ -263,6 +336,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
263
336
|
maxAdditionalTargets,
|
|
264
337
|
scrollBehavior,
|
|
265
338
|
title = "HTML Plan Viewer",
|
|
339
|
+
bridgeScriptUrl,
|
|
340
|
+
bridgeReadyTimeoutMs = DEFAULT_BRIDGE_READY_TIMEOUT_MS,
|
|
341
|
+
onBridgeUnavailable,
|
|
342
|
+
bridgeErrorDisplay = "banner",
|
|
266
343
|
},
|
|
267
344
|
ref,
|
|
268
345
|
) => {
|
|
@@ -320,6 +397,18 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
320
397
|
// themselves); arbitrary HTML renders untouched, like a standalone tab.
|
|
321
398
|
const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
|
|
322
399
|
|
|
400
|
+
// The URL path is srcdoc-only: live mode has the proxy inject the bridge.
|
|
401
|
+
// Resolved against THIS document's base before it is written into the
|
|
402
|
+
// srcdoc, so a framed page's own <base href> can never re-anchor it.
|
|
403
|
+
const bridgeUrl = useMemo(
|
|
404
|
+
() => (!liveMode && bridgeScriptUrl
|
|
405
|
+
? resolveBridgeScriptUrl(bridgeScriptUrl, document.baseURI)
|
|
406
|
+
: undefined),
|
|
407
|
+
[liveMode, bridgeScriptUrl],
|
|
408
|
+
);
|
|
409
|
+
const bridgeUrlRef = useRef(bridgeUrl);
|
|
410
|
+
bridgeUrlRef.current = bridgeUrl;
|
|
411
|
+
|
|
323
412
|
const srcdoc = useMemo(() => {
|
|
324
413
|
if (liveMode) return undefined; // src mode: the proxy injects the bridge
|
|
325
414
|
const injection = buildSrcdocInjection({
|
|
@@ -327,9 +416,46 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
327
416
|
isLight: isLightTheme(),
|
|
328
417
|
hostTheme,
|
|
329
418
|
diffActive: !!diffActive,
|
|
419
|
+
bridgeScriptUrl: bridgeUrl,
|
|
330
420
|
});
|
|
331
421
|
return injectIntoHead(rawHtml, injection);
|
|
332
|
-
}, [liveMode, rawHtml, hostTheme, diffActive]);
|
|
422
|
+
}, [liveMode, rawHtml, hostTheme, diffActive, bridgeUrl]);
|
|
423
|
+
|
|
424
|
+
// Error state for the bridgeScriptUrl path only: the inline path never
|
|
425
|
+
// sets it (no timer, and a version mismatch there can only be a forged
|
|
426
|
+
// message, which is warned about and otherwise ignored).
|
|
427
|
+
const [bridgeError, setBridgeError] = useState<BridgeUnavailableInfo | null>(null);
|
|
428
|
+
// A version-mismatch banner is dismissible (the older bridge keeps
|
|
429
|
+
// working); reset whenever the error itself changes.
|
|
430
|
+
const [bridgeErrorDismissed, setBridgeErrorDismissed] = useState(false);
|
|
431
|
+
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
432
|
+
const onBridgeUnavailableRef = useRef(onBridgeUnavailable);
|
|
433
|
+
onBridgeUnavailableRef.current = onBridgeUnavailable;
|
|
434
|
+
// The timeout is read through a ref at arming time: the timer is armed
|
|
435
|
+
// once per document load (URL or srcdoc change), never re-armed by a
|
|
436
|
+
// later prop change, so a host adjusting bridgeReadyTimeoutMs after the
|
|
437
|
+
// bridge is ready can never produce a false timeout.
|
|
438
|
+
const bridgeReadyTimeoutMsRef = useRef(bridgeReadyTimeoutMs);
|
|
439
|
+
bridgeReadyTimeoutMsRef.current = bridgeReadyTimeoutMs;
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
if (!bridgeUrl || srcdoc === undefined) return;
|
|
442
|
+
setBridgeError(null);
|
|
443
|
+
setBridgeErrorDismissed(false);
|
|
444
|
+
const url = bridgeUrl;
|
|
445
|
+
const timeoutMs = bridgeReadyTimeoutMsRef.current;
|
|
446
|
+
readyTimerRef.current = setTimeout(() => {
|
|
447
|
+
readyTimerRef.current = null;
|
|
448
|
+
setBridgeError({ kind: "timeout", url, timeoutMs });
|
|
449
|
+
}, timeoutMs);
|
|
450
|
+
return () => {
|
|
451
|
+
if (readyTimerRef.current !== null) clearTimeout(readyTimerRef.current);
|
|
452
|
+
readyTimerRef.current = null;
|
|
453
|
+
};
|
|
454
|
+
}, [bridgeUrl, srcdoc]);
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
setBridgeErrorDismissed(false);
|
|
457
|
+
if (bridgeError) onBridgeUnavailableRef.current?.(bridgeError);
|
|
458
|
+
}, [bridgeError]);
|
|
333
459
|
|
|
334
460
|
const handleResize = useCallback((height: number) => {
|
|
335
461
|
if (liveMode) return; // live surfaces are full-viewport; height is ignored
|
|
@@ -516,6 +642,33 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
516
642
|
const live = liveSessionRef.current;
|
|
517
643
|
if (live && rejectsLiveMessage(live, e.origin, e.data)) return;
|
|
518
644
|
if (isBridgeReadyMessage(e.data)) {
|
|
645
|
+
// Protocol stamp: one console warning on drift, naming both
|
|
646
|
+
// versions. The ready is still honored (an older bridge answers
|
|
647
|
+
// every message shape it knows); on the bridgeScriptUrl path the
|
|
648
|
+
// surface additionally shows its error banner, because there the
|
|
649
|
+
// drift is a real deployment state (a cached asset from a previous
|
|
650
|
+
// package version) rather than a forged message.
|
|
651
|
+
const verdict = checkBridgeProtocolVersion(e.data);
|
|
652
|
+
const url = bridgeUrlRef.current;
|
|
653
|
+
if (!verdict.ok) {
|
|
654
|
+
console.warn(formatBridgeProtocolWarning(verdict, url));
|
|
655
|
+
}
|
|
656
|
+
if (url) {
|
|
657
|
+
if (readyTimerRef.current !== null) {
|
|
658
|
+
clearTimeout(readyTimerRef.current);
|
|
659
|
+
readyTimerRef.current = null;
|
|
660
|
+
}
|
|
661
|
+
setBridgeError(
|
|
662
|
+
verdict.ok
|
|
663
|
+
? null
|
|
664
|
+
: {
|
|
665
|
+
kind: "version-mismatch",
|
|
666
|
+
url,
|
|
667
|
+
expectedVersion: verdict.expected,
|
|
668
|
+
reportedVersion: verdict.reported,
|
|
669
|
+
},
|
|
670
|
+
);
|
|
671
|
+
}
|
|
519
672
|
setIframeReadyVersion((version) => version + 1);
|
|
520
673
|
setVimBridgePhase("inactive");
|
|
521
674
|
setVimHudCommand(null);
|
|
@@ -916,6 +1069,39 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
|
|
|
916
1069
|
{actionButtons}
|
|
917
1070
|
</div>
|
|
918
1071
|
)}
|
|
1072
|
+
{/* bridgeScriptUrl path only: the bridge did not come up (no
|
|
1073
|
+
ready within the timeout, or a stale asset's version). Floated
|
|
1074
|
+
over the top of the iframe so it never changes the layout the
|
|
1075
|
+
page renders in; the page itself stays visible. A host that
|
|
1076
|
+
renders its own notice from onBridgeUnavailable passes
|
|
1077
|
+
bridgeErrorDisplay="none" and no strip is rendered at all. */}
|
|
1078
|
+
{bridgeError && !bridgeErrorDismissed && bridgeErrorDisplay !== "none" && (
|
|
1079
|
+
<div
|
|
1080
|
+
role="alert"
|
|
1081
|
+
data-print-hide
|
|
1082
|
+
data-bridge-error={bridgeError.kind}
|
|
1083
|
+
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"
|
|
1084
|
+
style={{ display: "flex", alignItems: "flex-start", gap: 8 }}
|
|
1085
|
+
>
|
|
1086
|
+
<span style={{ flex: 1 }}>{formatBridgeUnavailableMessage(bridgeError)}</span>
|
|
1087
|
+
{/* Only the mismatch state is dismissible: the older bridge
|
|
1088
|
+
still works there. A timeout leaves a dead surface, so
|
|
1089
|
+
that banner stays. Inline styles on purpose: hosts that
|
|
1090
|
+
build the guides.show viewer scan this file for utility
|
|
1091
|
+
classes, and this banner must not grow that stylesheet. */}
|
|
1092
|
+
{bridgeError.kind === "version-mismatch" && (
|
|
1093
|
+
<button
|
|
1094
|
+
type="button"
|
|
1095
|
+
data-bridge-error-dismiss
|
|
1096
|
+
aria-label="Dismiss"
|
|
1097
|
+
style={{ flexShrink: 0, borderRadius: 4, padding: "2px 6px", fontWeight: 500, cursor: "pointer", background: "transparent", border: "1px solid currentColor", color: "inherit" }}
|
|
1098
|
+
onClick={() => setBridgeErrorDismissed(true)}
|
|
1099
|
+
>
|
|
1100
|
+
Dismiss
|
|
1101
|
+
</button>
|
|
1102
|
+
)}
|
|
1103
|
+
</div>
|
|
1104
|
+
)}
|
|
919
1105
|
{/* Live proxied-app mode navigates a real loopback origin: no
|
|
920
1106
|
sandbox (the user's own app needs cookies, storage, and
|
|
921
1107
|
same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
|