@plannotator/ui 0.33.0 → 0.35.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 +42 -8
- package/README.md +11 -3
- package/components/AnnotationPanel.tsx +37 -36
- package/components/AnnotationToolstrip.tsx +25 -15
- package/components/GraphvizBlock.tsx +15 -4
- package/components/HtmlSurfaceControls.tsx +16 -7
- package/components/ImageAnnotator/Toolbar.tsx +27 -0
- package/components/ImageAnnotator/index.tsx +51 -59
- package/components/ImageAnnotator/strokeHistory.ts +58 -0
- package/components/MermaidBlock.tsx +31 -4
- package/components/PlanHeaderMenu.tsx +1 -1
- package/components/Settings.tsx +16 -1
- package/components/StickyHeaderLane.tsx +4 -0
- package/components/html-viewer/HtmlViewer.tsx +17 -4
- package/components/html-viewer/useHtmlAnnotation.ts +20 -11
- package/config/index.ts +3 -0
- package/config/reviewView.ts +37 -0
- package/config/settings.ts +15 -0
- package/hooks/useCodeAnnotationDraft.ts +19 -2
- package/hooks/useHtmlRefresh.ts +19 -10
- package/hooks/useSharing.ts +57 -8
- package/hooks/useUndoHistory.ts +83 -0
- package/package.json +2 -2
- package/shortcuts/history.shortcuts.ts +25 -0
- package/shortcuts/index.ts +1 -0
- package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +10 -1
- package/utils/math.ts +30 -6
- package/utils/mermaid-math-slot.ts +56 -0
- package/utils/parser.ts +41 -14
- package/utils/runtimeRetry.ts +31 -0
- package/utils/undoHistory.ts +167 -0
- package/webmcp/changes.ts +24 -0
- package/webmcp/nudges.ts +22 -7
package/HANDOFF.md
CHANGED
|
@@ -92,7 +92,7 @@ Pass any subset of these to `configurePlannotatorUI({ ... })`. Anything omitted
|
|
|
92
92
|
| `aiTransport` | `AITransport` | The "Ask AI" chat session/query/abort/permission | `POST /api/ai/{session,query,abort,permission}` |
|
|
93
93
|
| `serverSync` | `ServerSyncFn` | Push a settings change back to the server | No-op-ish (Plannotator's local sync) |
|
|
94
94
|
| `loadSettingsFromBackend` | `boolean` | After install, re-hydrate settings from your `storageBackend` | off |
|
|
95
|
-
| `mathRendererLoader` | `() => Promise<MathRenderer>` | How KaTeX is loaded when no renderer is registered before the first math node renders (see "Lazy renderers and eager entries"). Once registered, the package default is never called, not even as a fallback after a rejected load; a default load already in flight at registration still fills the slot (pre-existing, see `setMathRendererLoader`), so register before the first math render | `utils/math-default-loader`'s `import('katex')`, JS only; CSS stays yours |
|
|
95
|
+
| `mathRendererLoader` | `() => Promise<MathRenderer>` | How KaTeX is loaded when no renderer is registered before the first math node renders (see "Lazy renderers and eager entries"). Once registered, the package default is never called, not even as a fallback after a rejected load, and `resetMathRenderer()` keeps the registration (0.34.0); a default load already in flight at registration still fills the slot (pre-existing, see `setMathRendererLoader`), so register before the first math render | `utils/math-default-loader`'s `import('katex')`, JS only; CSS stays yours |
|
|
96
96
|
| `identityGenerator` | `() => string` | The synchronous generator behind the default "tater" display name when no `identityProvider` is installed | A built-in 16 x 16 word pool of the same `adjective-noun-tater` shape; Plannotator registers the full dictionary via `utils/identity-tater` |
|
|
97
97
|
|
|
98
98
|
### Interface details worth knowing
|
|
@@ -195,6 +195,7 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
|
|
|
195
195
|
| `components/MarkdownDiff` | Theme-bridging wrapper over `@plannotator/markdown-editor`'s frozen two-revision diff. Same shim pattern as `components/MarkdownEditor` (ThemeProvider bridge, `extensions` passthrough, grid card chrome); never editable. See "Frozen markdown diff (0.28.0)". |
|
|
196
196
|
| `components/CommentPopover` | Anchor capture + comment entry. Ask-AI UI renders only if you pass `onAskAI`. |
|
|
197
197
|
| `components/AnnotationPanel` | Renders from your annotation state; no fetches of its own. |
|
|
198
|
+
| `components/AnnotationToolstrip` | The annotation mode toolstrip (Select / Pinpoint / Markup / Comment / Redline / Label). **Pass `showHelpLink={false}` in a host** — the default help modal embeds Plannotator's own YouTube walkthroughs. `hideQuickLabel` omits only the Label button (`StickyHeaderLane` forwards it, so the pinned scroll header matches); it hides the control, it does **not** clamp the mode — keep host mode state out of `'quickLabel'` (including preferences restored through `utils/editorMode`, which accepts it from storage) or text selection silently opens the quick-label picker with no visible cause. `hideInputMethodSwitch` likewise omits the pinpoint/drag switch. *(Blessed in 0.35.0.)* |
|
|
198
199
|
| `components/ThemeProvider` | Color-mode context. |
|
|
199
200
|
| `theme-modes` (`THEME_MODES`, `Mode`) | The supported Light/Dark/System catalog and mode type. `Mode` also remains exported from `components/ThemeProvider` for compatibility with existing consumers. |
|
|
200
201
|
| `components/ImageThumbnail` / `getImageSrc` | Routes through `imageSrcResolver`. |
|
|
@@ -212,7 +213,8 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
|
|
|
212
213
|
| `shortcuts` (`useHtmlAnnotateShortcuts`, `defineShortcutScope`, the scope registry) | The declarative keyboard-shortcut engine and the per-surface scopes, including the HTML annotate scope (Mod+Shift+A). Pure: React plus `utils/platform`; no backend. |
|
|
213
214
|
| `utils/inputMethod` (`getInputMethod`, `saveInputMethod`, `refreshInputMethodStamp`) | The per-surface pinpoint/drag input-method preference with its TTL. Persists through the `storageBackend` seam; no backend of its own. |
|
|
214
215
|
| `utils/codeHighlight` / `utils/codeBlockMark` / `utils/syntaxTheme` | The Shiki-based fence highlighter, swap-surviving annotation marks, and palette→Shiki theme mapping. Replaces all `.hljs` styling. *(Blessed in 0.29.0.)* |
|
|
215
|
-
| `utils/math` (`loadMathRenderer`, `getMathRenderer`, `getMathRendererSource`, `setMathRenderer`) and `utils/math-eager` | The math renderer slot and its eager KaTeX registration. Import `utils/math-eager` for synchronous typesetting on the first commit; call `loadMathRenderer()` to pre-warm the lazy path. See "Lazy renderers and eager entries". |
|
|
216
|
+
| `utils/math` (`loadMathRenderer`, `getMathRenderer`, `getMathRendererSource`, `setMathRenderer`, `setMathRendererLoader`, `getMathRendererLoader`, `resetMathRenderer`) and `utils/math-eager` | The math renderer slot and its eager KaTeX registration. Import `utils/math-eager` for synchronous typesetting on the first commit; call `loadMathRenderer()` to pre-warm the lazy path. `resetMathRenderer()` empties the slot and keeps the registered loader; `setMathRendererLoader(null)` drops it. See "Lazy renderers and eager entries". |
|
|
217
|
+
| `utils/mermaid-math-slot` | Alias target only: what a host redirects Mermaid's own `katex` import to, so `$$` labels in diagrams typeset through the math slot and the host build carries one KaTeX chunk. Never import it yourself. See "Lazy renderers and eager entries", item 2. |
|
|
216
218
|
| `utils/identity-tater` | Side-effect entry that registers the full username dictionary into the identity generator slot. Import it only if you rely on the default tater names and want the full dictionary; a host with `identityProvider` should not. |
|
|
217
219
|
| `utils/mermaid` (`loadMermaidRuntime`, `getMermaidRuntime`, `getMermaidRuntimeSource`, `setMermaidRuntime`, `MERMAID_CONFIG`) and `utils/mermaid-eager` | The Mermaid runtime slot and its eager registration. Import `utils/mermaid-eager` to keep Mermaid in your entry chunk as Plannotator does; omit it for the lazy path with retry. See "Lazy renderers and eager entries". |
|
|
218
220
|
|
|
@@ -478,13 +480,38 @@ Four modules that used to ride every document read for a host that bundles by ro
|
|
|
478
480
|
export function loadDefaultMathRenderer(): Promise<never> { return Promise.reject(new Error('default math loader aliased out')); }
|
|
479
481
|
```
|
|
480
482
|
|
|
481
|
-
With the alias the same consumer build emits zero chunks carrying the KaTeX body and the entry's only `import()` in that area is the host's own loader chunk. Do not alias without registering a loader: math would then render as TeX text forever. Plannotator's entries import `math-eager`, so the slot is filled before the first render and this branch is never reached there; the single-file builds inline the default through `inlineDynamicImports` as before (`tests/entry-assets.test.ts` pins the split: `utils/math` has no `import('katex')` site, `utils/math-default-loader` has the only one).
|
|
483
|
+
With the alias the same consumer build emits zero chunks carrying the KaTeX body out of the package and the entry's only `import()` in that area is the host's own loader chunk. Do not alias without registering a loader: math would then render as TeX text forever. Plannotator's entries import `math-eager`, so the slot is filled before the first render and this branch is never reached there; the single-file builds inline the default through `inlineDynamicImports` as before (`tests/entry-assets.test.ts` pins the split: `utils/math` has no `import('katex')` site, `utils/math-default-loader` has the only one).
|
|
484
|
+
|
|
485
|
+
**Mermaid's own KaTeX, and the last shared chunk (0.34.0, from 0.33.0 adoption feedback).** The alias above is not the whole story once a page can render a Mermaid diagram. The Mermaid runtime (11.15.0 in this checkout) typesets `$$...$$` labels through its own `import("katex")`, inside `renderKatexUnsanitized`, and it offers nothing to turn that off: `legacyMathML` / `forceLegacyMathML` only choose the output mode, the guard around the import is the `@mermaid-js/tiny` build marker, and there is no hook to hand it a renderer. The import only *runs* for a label that matches Mermaid's `$$` test, but it is *emitted* regardless, so a host that registered a loader and aliased the default still built a `katex-*.js` chunk, and because that chunk then had two dynamic importers (the host's loader module and the Mermaid runtime) Rollup kept it separate from the host's loader chunk: a math document fetched two files, the 57-byte loader chunk plus the shared 261 KB KaTeX chunk. Measured on the scratch Vite 6 consumer of this checkout (loader registered, default aliased, a document with inline math, a display block and a flowchart with a `$$` label): one chunk carried the KaTeX body before, `katex-*.js`, imported by `mermaid.core-*.js` and by the host's loader chunk; 367 JS chunks in all.
|
|
486
|
+
|
|
487
|
+
The fix is a bundler-facing redirect to a package module, `utils/mermaid-math-slot`, whose default export has the one method Mermaid calls (`renderToString`) and delegates to whatever fills the math slot, with Mermaid's own options (`throwOnError: true`, `displayMode: true`, the MathML `output` mode) passed through untouched, so a KaTeX renderer produces exactly the markup Mermaid produced from its direct import. Redirect the `katex` specifier for importers inside the `mermaid` package ONLY; a plain `resolve.alias` on `katex` would also rewrite your own loader's import and break math everywhere:
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
// vite.config.ts of a host that registers mathRendererLoader (beside the math-default-loader alias)
|
|
491
|
+
import { fileURLToPath } from 'node:url';
|
|
492
|
+
const configFile = fileURLToPath(import.meta.url);
|
|
493
|
+
const mermaidKatexToSlot: Plugin = {
|
|
494
|
+
name: 'mermaid-katex-to-plannotator-slot',
|
|
495
|
+
enforce: 'pre',
|
|
496
|
+
resolveId(source, importer) {
|
|
497
|
+
if (source !== 'katex' || !importer || !/[\\/]node_modules[\\/]mermaid[\\/]/.test(importer)) return null;
|
|
498
|
+
return this.resolve('@plannotator/ui/utils/mermaid-math-slot', configFile, { skipSelf: true });
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
// plugins: [mermaidKatexToSlot, react(), ...]
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Two details of that snippet are layout-proofing. The importer test is `node_modules/mermaid/` anywhere in the path, not a pattern for one install layout: a hoisted install puts the runtime at `node_modules/mermaid/`, Bun's isolated layout at `node_modules/.bun/mermaid@11.15.0/node_modules/mermaid/`, and pnpm's at `node_modules/.pnpm/mermaid@11.15.0/node_modules/mermaid/`; every one of them ends in that segment, and the trailing separator keeps `mermaid-something` packages out. The slot module is resolved from the host's own config file (`configFile`), not from the Mermaid importer: resolving from the importer walks up from Mermaid's location, which finds `@plannotator/ui` on hoisted and Bun-isolated installs but not under pnpm's strict `node_modules`, where the package is only visible from the host root. Resolving from the config file is the same lookup the host's own imports use; passing an absolute path to the file (`path.resolve(...)` of the installed `utils/mermaid-math-slot.ts`) works too.
|
|
505
|
+
|
|
506
|
+
With the redirect the same consumer build emits one chunk carrying the KaTeX body, the host's own loader chunk (`host-katex-*.js`, 261 KB, reached only by the entry's `import()`), `mermaid.core-*.js` has no KaTeX import left, and the chunk count drops to 366: one KaTeX chunk, owned by the host, one file fetched. The slot must be filled by the time Mermaid asks, so `MermaidBlock` awaits `loadMathRenderer()` before rendering a diagram whose source carries a `$$` label (`hasMermaidMath`, Mermaid's own regex); on a filled slot that resolves at once, on the lazy path it runs your loader, and if that load fails the label throws a message naming the cause (`MERMAID_MATH_SLOT_EMPTY_MESSAGE`) which the block's error panel shows with the source. Do not import the module yourself; it exists to be resolved to. Plannotator does not redirect: its Mermaid keeps its direct KaTeX, inlined by the single-file builds with everything else, and the pre-render wait is a resolved promise there because `math-eager` filled the slot at startup. No test in the repo renders a real Mermaid diagram with a math label (Mermaid does not render under happy-dom), and nothing in Plannotator's own documents exercises `$$` labels; the bridge is pinned by `utils/mermaid-math-slot.test.ts` (delegation with Mermaid's exact options, KaTeX parity, the empty-slot error, the label regex) and the pre-render warm by the "Mermaid math labels warm the math slot" cases in `components/DiagramBlock.lazyRetry.test.tsx`.
|
|
507
|
+
|
|
508
|
+
**`resetMathRenderer()` keeps the loader (0.34.0).** Through 0.33.0 the reset hook also nulled the registered loader, so a host test harness that reset the slot between cases silently fell back to the package default `import('katex')` on the next render. Reset now empties the renderer and its source, forgets a load in flight (its late result no longer fills the slot; the next `loadMathRenderer()` invokes the registered loader afresh) and leaves the loader registered. `setMathRendererLoader(null)` is the explicit way back to the package default, and `getMathRendererLoader()` reads the registration; `configurePlannotatorUI` cannot unregister a loader (a `null` or absent `mathRendererLoader` is a no-op there), so only a direct `setMathRendererLoader(null)` does. `setMathRendererLoader` itself is unchanged: a load already in flight at registration still fills the slot, because the component that started it is waiting on that result. The other reset-style helpers were reviewed and are consistent with their names: `resetIdentityProvider` and `resetIdentityGenerator` reset exactly the thing they name (the provider, the generator), and Mermaid's `__setMermaidRuntimeLoaderForTests` is a stand-in by name. Pinned in `utils/math.test.ts`.
|
|
482
509
|
|
|
483
510
|
3. **Identity: a generator slot, filled eagerly by Plannotator.** `utils/generateIdentity` no longer imports `unique-username-generator`. It holds a synchronous generator slot (`setIdentityGenerator`, `getIdentityGenerator`) with a built-in fallback that produces the same `adjective-noun-tater` shape from a 16 x 16 pool. `utils/identity-tater` registers the full dictionary as a side effect and is what Plannotator's entries import. A host with `identityProvider` never calls the generator and, with the static import gone, no longer ships the word lists; delete any dictionary shim. A host that wants the full dictionary without its own provider imports `@plannotator/ui/utils/identity-tater`, or passes its own `identityGenerator` to `configurePlannotatorUI`. The slot is synchronous on purpose: `configStore` persists the first generated name to the identity cookie during the first render-time settings read, so a name that arrived later would be a visible identity change.
|
|
484
511
|
|
|
485
|
-
4. **Scope, as of 0.
|
|
512
|
+
4. **Scope, as of 0.34.0.** 0.32.0 shipped items 1 to 3 and deliberately left two things out of the design record's list: the raw-HTML bridge script as a separately served asset, and a lazy table popout. 0.33.0 ships the first (see "HTML viewer bridge as an asset" below) and, from adoption feedback, the `utils/math-default-loader` split in item 2. 0.34.0 adds the Mermaid KaTeX redirect and the `resetMathRenderer` fix in item 2, both from 0.33.0 adoption feedback. The lazy table popout is still not shipped and stays tracked in the design record for a follow-up.
|
|
486
513
|
|
|
487
|
-
Pinned by `utils/math.test.ts`, `components/MathBlock.firstPaint.test.tsx`, `utils/generateIdentity.test.ts`, `components/MermaidBlock.test.ts`, and the eager-entry and built-HTML marker guards in `tests/entry-assets.test.ts`.
|
|
514
|
+
Pinned by `utils/math.test.ts`, `utils/mermaid-math-slot.test.ts`, `components/MathBlock.firstPaint.test.tsx`, `utils/generateIdentity.test.ts`, `components/MermaidBlock.test.ts`, `components/DiagramBlock.lazyRetry.test.tsx`, and the eager-entry and built-HTML marker guards in `tests/entry-assets.test.ts`.
|
|
488
515
|
|
|
489
516
|
---
|
|
490
517
|
|
|
@@ -519,16 +546,23 @@ With the prop set, `buildSrcdocInjection` emits `<script src="…"></script>` in
|
|
|
519
546
|
|
|
520
547
|
**Protocol version.** `BRIDGE_PROTOCOL_VERSION` (exported from `components/html-viewer/bridge-script` and re-exported from `components/html-viewer`) is embedded in the bridge text and stamped on its `ready` message as `protocolVersion`. Note for the design record's "current state": `BRIDGE_SCRIPT` now carries its first `${}` interpolation (that constant, evaluated at module load); it remains a plain string export with no per-session values, so the CLI, Pi and the live proxy consume it exactly as before. The parent (`checkBridgeProtocolVersion`, `HtmlViewer`'s ready branch) compares it: on the inline path and in live sessions the two sides come from one bundle and always match; on the URL path a cached asset from a previous package version answers with an older stamp, or none, and the viewer logs one console warning naming both versions, shows a dismissible error banner over the top of the frame (`[data-bridge-error="version-mismatch"]`, `role="alert"`, a `[data-bridge-error-dismiss]` button; the page stays visible) and calls `onBridgeUnavailable` once. The ready is still honored (an older bridge answers every message shape it knows), so this is a loud diagnostic, not a refusal. Bump the constant whenever a bridge message shape changes in a way an older bridge or parent would misread; a bump forces a warning against any not-yet-redeployed asset, which is the point.
|
|
521
548
|
|
|
549
|
+
**Who renders the strip (`bridgeErrorDisplay`; 0.34.0, from 0.33.0 adoption feedback).** The package owns the failure strip by default: `bridgeErrorDisplay="banner"` renders the `[data-bridge-error]` element for both the mismatch and the timeout states exactly as 0.33.0 did, so Plannotator and every existing host are unchanged. A host that renders its own notice from `onBridgeUnavailable` passes `bridgeErrorDisplay="none"`: no strip and no dismiss button are rendered for either state, while `onBridgeUnavailable` fires exactly as before and a version mismatch still logs its one console warning. Through 0.33.0 the strip could not be suppressed, so such a host showed two banners. The prop is meaningless on the inline path, which never shows a strip. Pinned by the two `bridgeErrorDisplay` cases in `components/html-viewer/HtmlViewer.bridgeAsset.test.tsx`.
|
|
550
|
+
|
|
522
551
|
**Ready timeout.** On the URL path only, `bridgeReadyTimeoutMs` (default 5000) is armed once per document load (URL or srcdoc change), read through a ref, so changing the prop after the bridge is ready never re-arms it; with no `ready` in time the surface shows `[data-bridge-error="timeout"]` naming the URL and the wait (not dismissible: the surface is dead), and `onBridgeUnavailable({ kind: 'timeout', url, timeoutMs })` fires. A late `ready` clears it. The inline path arms no timer and can never show a banner.
|
|
523
552
|
|
|
524
553
|
**Dropping the literal from the host chunk (optional).** The URL path alone leaves the inline string in the chunk unused, because `srcdoc.ts` imports it statically (the default must stay synchronous). To remove it, alias the package's `./bridge-script` resolution to the generated lite module in your bundler; with Vite:
|
|
525
554
|
|
|
526
555
|
```ts
|
|
527
556
|
resolve: {
|
|
528
|
-
alias: [{
|
|
557
|
+
alias: [{
|
|
558
|
+
find: /^\.\/bridge-script$/,
|
|
559
|
+
replacement: "@plannotator/ui/components/html-viewer/bridge-script.lite",
|
|
560
|
+
}],
|
|
529
561
|
}
|
|
530
562
|
```
|
|
531
563
|
|
|
564
|
+
The `find` is anchored on purpose (0.34.0; 0.33.0 documented `/\/bridge-script$/`). Every import of the module inside the package is the relative sibling form, `./bridge-script` (`srcdoc.ts`, `useHtmlAnnotation.ts`, `index.ts`), so `/^\.\/bridge-script$/` matches exactly those. The unanchored form matched any specifier ENDING in `/bridge-script`, which is also the shape of another package's entry point (`some-dependency/bridge-script`) or of a deeper import in your own tree (`../vendor/bridge-script`), and would have silently swapped those for the lite module too. If your own source has a sibling module named `bridge-script`, add an importer check (a `customResolver` on the alias entry, or a `resolveId` plugin that tests `importer` for `@plannotator/ui/components/html-viewer/`) rather than widening the pattern.
|
|
565
|
+
|
|
532
566
|
Under that alias an `HtmlViewer` rendered WITHOUT `bridgeScriptUrl` throws at render (`buildBridgeScriptTag` refuses to emit an empty inline script), so the misconfiguration cannot ship as a silently dead surface. Measured on the proof harness (PR #1398's description): the viewer chunk shrinks by the size of the literal, 557 kB to 371 kB (168 kB to 118 kB gzip).
|
|
533
567
|
|
|
534
568
|
**Live app annotation is unaffected.** `packages/shared/live-proxy-bridge-inline.test.ts` pins at source level that both proxy transports and both runtimes' composers still ship the inline bridge from the proxy route and never reference `bridgeScriptUrl` or the generated files.
|
|
@@ -641,8 +675,8 @@ Additive only, but required: `@plannotator/ui` 0.32.0 imports the new `@plannota
|
|
|
641
675
|
|
|
642
676
|
## Publishing & versioning
|
|
643
677
|
|
|
644
|
-
- The current pair is `@plannotator/ui` `0.
|
|
645
|
-
- Recent pairs, for the consumer's install matrix: ui 0.31.0 on core 0.24.0 (lockstep), ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 on core 0.25.0 (ui only, core unchanged).
|
|
678
|
+
- The current pair is `@plannotator/ui` `0.34.0` on `@plannotator/core` `0.25.0`. **No lockstep again**: nothing under `packages/core` changed since the 0.32.0 pair, so core is not republished and ui 0.34.0 pins the already published core 0.25.0 (only the ui tarball is built and published). 0.34.0 carries the 0.33.0 adoption feedback: the Mermaid KaTeX redirect target `utils/mermaid-math-slot`, `HtmlViewer` `bridgeErrorDisplay`, the anchored bridge-script alias, and `resetMathRenderer` keeping the registered loader (with `setMathRendererLoader(null)` and `getMathRendererLoader`). 0.33.0 carried the bridge-script asset (`bridge-script.asset.js`, `bridge-script.lite`, both generated by `prepack`), the `utils/math-default-loader` split, and `HANDOFF.md` inside the tarball so the README's section references resolve for a consumer.
|
|
679
|
+
- Recent pairs, for the consumer's install matrix: ui 0.31.0 on core 0.24.0 (lockstep), ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only, core unchanged).
|
|
646
680
|
- When a pair IS lockstep, **publish `core` first**: ui 0.32.0 imports the new `@plannotator/core/html-anchor` subpath, which no earlier published core (0.24.0 and before) has, just as ui 0.29.0 needed core 0.23.0 for `@plannotator/core/annotatable`. The ui→core dependency resolves exactly at pack time, from the lockfile: after a version bump, run `bun install` so `bun.lock` carries the new workspace versions, or `bun pm pack` will still stamp the previous core version into ui's tarball (the 0.31.0 lesson).
|
|
647
681
|
- The HTML annotation seams also changed the guides.show viewer **stylesheet** (five utility rules from `HtmlSurfaceControls`; the viewer JS is unchanged), so `packages/core/guide-viewer-manifest.ts` now pins a CSS hash that exists on guides.show only after the deploy workflow has published this build's `/v1/` assets. A guide exported from this build before that deploy would pin a stylesheet the host does not serve yet: **deploy guides.show before any release that ships this manifest.**
|
|
648
682
|
- They depend on each other via `workspace:*`. At publish time that must resolve to the **exact** version in the tarball, so publish with a tool that does that resolution (the repo's existing flow uses `bun pm pack` to build the tarball, then `npm publish *.tgz --access public`). Publish **`core` first, then `ui`**.
|
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). 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.
|
|
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
|
|
|
@@ -122,13 +122,21 @@ import bridgeScriptUrl from "@plannotator/ui/components/html-viewer/bridge-scrip
|
|
|
122
122
|
<HtmlViewer rawHtml={html} bridgeScriptUrl={bridgeScriptUrl} … />
|
|
123
123
|
```
|
|
124
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 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 `./bridge-script`
|
|
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
126
|
|
|
127
127
|
#### Also blessed in 0.32.0: `shortcuts` and `utils/inputMethod`
|
|
128
128
|
|
|
129
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.
|
|
130
130
|
- **`@plannotator/ui/utils/inputMethod`**: `getInputMethod(surface)` / `saveInputMethod(method, surface)` / `refreshInputMethodStamp(method)`, the per-surface pinpoint-or-drag preference with its TTL, persisted through the `storageBackend` seam.
|
|
131
131
|
|
|
132
|
+
#### Toolstrip host props (0.35.0)
|
|
133
|
+
|
|
134
|
+
`components/AnnotationToolstrip` is supported host surface: the annotation mode toolstrip with per-tool opt-outs, all defaulting to today's rendering.
|
|
135
|
+
|
|
136
|
+
- **`hideQuickLabel`** omits the Quick Label tool. `StickyHeaderLane` forwards it, so the pinned scroll header stays consistent. It hides the button only — it does not clamp the mode, so keep host mode state out of `'quickLabel'` (including preferences restored through `utils/editorMode`).
|
|
137
|
+
- **`showHelpLink={false}`** for hosts: the default help modal embeds Plannotator's own video walkthroughs.
|
|
138
|
+
- **`hideInputMethodSwitch`** omits the pinpoint/drag input-method switch.
|
|
139
|
+
|
|
132
140
|
### WebMCP provider (`@plannotator/ui/webmcp`; 0.32.0)
|
|
133
141
|
|
|
134
142
|
The engine that lets a browser-integrated agent (Chrome/Edge WebMCP, `document.modelContext`) call in-page tools on a document surface. Feature-detected once; a browser without the API sees no registration, no DOM, no network, no timers. Seam: `configurePlannotatorUI({ webmcp: { enabled, namePrefix } })`, default enabled with the `plannotator.` prefix; pass `enabled: false` to keep a host page tool-free, or your own prefix to namespace the tools beside your own. There is deliberately no confirmation seam: the catalog is read-and-comment only (no approve / submit / close tools), and the agent may only edit or remove comments stamped `source: "browser-agent"`.
|
|
@@ -164,7 +172,7 @@ npm install @plannotator/ui @plannotator/core
|
|
|
164
172
|
- `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
|
|
165
173
|
- `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on `@plannotator/core` (exact-version lockstep). Published.
|
|
166
174
|
- `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
|
|
167
|
-
- Versioned together (currently `@plannotator/ui` 0.
|
|
175
|
+
- 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").
|
|
168
176
|
|
|
169
177
|
## The one rule
|
|
170
178
|
|
|
@@ -7,6 +7,7 @@ import { useIsMobile } from '../hooks/useIsMobile';
|
|
|
7
7
|
import { OverlayScrollArea } from './OverlayScrollArea';
|
|
8
8
|
import { Button } from './ui/button';
|
|
9
9
|
import { cn } from '../lib/utils';
|
|
10
|
+
import { resolveReplyParents, resolveThreadRootTimestamps } from '@plannotator/core/annotation-threads';
|
|
10
11
|
|
|
11
12
|
// Card type-word colors. Deletion uses `destructive` (reliably red on every
|
|
12
13
|
// theme, matching the in-document .deletion highlight). Comment uses the
|
|
@@ -40,50 +41,47 @@ const TrashCardIcon = () => (
|
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
43
|
* Order annotations so every reply follows its parent (replies among
|
|
43
|
-
* themselves stay in creation order).
|
|
44
|
-
*
|
|
45
|
-
*
|
|
44
|
+
* themselves stay in creation order). The threading rule is the shared one
|
|
45
|
+
* (resolveReplyParents, also what the export applies): a reply whose parent
|
|
46
|
+
* is absent, a self-reference, and every member of an `inReplyTo` cycle
|
|
47
|
+
* render as top-level cards in input order, so nothing is ever dropped.
|
|
48
|
+
* Without any `inReplyTo` the input order is returned unchanged, so
|
|
49
|
+
* annotations without replies render exactly as before.
|
|
46
50
|
*/
|
|
47
51
|
export function threadReplies(sorted: Annotation[]): Array<{ annotation: Annotation; isReply: boolean }> {
|
|
48
52
|
if (!sorted.some((a) => a.inReplyTo)) return sorted.map((annotation) => ({ annotation, isReply: false }));
|
|
49
|
-
const
|
|
53
|
+
const parents = resolveReplyParents(sorted);
|
|
50
54
|
const byParent = new Map<string, Annotation[]>();
|
|
51
55
|
for (const a of sorted) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
const parent = parents.get(a.id);
|
|
57
|
+
if (!parent) continue;
|
|
58
|
+
const list = byParent.get(parent) ?? [];
|
|
59
|
+
list.push(a);
|
|
60
|
+
byParent.set(parent, list);
|
|
57
61
|
}
|
|
62
|
+
// Depth-first, iteratively: a 5,000-deep chain must not recurse 5,000
|
|
63
|
+
// frames deep. The stack holds each node's replies in reverse so they pop
|
|
64
|
+
// in creation order.
|
|
58
65
|
const out: Array<{ annotation: Annotation; isReply: boolean }> = [];
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
for (const reply of byParent.get(a.id) ?? []) emit(reply, true);
|
|
66
|
+
const stack: Array<{ annotation: Annotation; isReply: boolean }> = [];
|
|
67
|
+
const pushReplies = (a: Annotation) => {
|
|
68
|
+
const replies = byParent.get(a.id);
|
|
69
|
+
if (!replies) return;
|
|
70
|
+
for (let i = replies.length - 1; i >= 0; i--) stack.push({ annotation: replies[i], isReply: true });
|
|
65
71
|
};
|
|
66
72
|
for (const a of sorted) {
|
|
67
|
-
if (
|
|
68
|
-
|
|
73
|
+
if (parents.get(a.id)) continue;
|
|
74
|
+
out.push({ annotation: a, isReply: false });
|
|
75
|
+
pushReplies(a);
|
|
76
|
+
while (stack.length > 0) {
|
|
77
|
+
const next = stack.pop()!;
|
|
78
|
+
out.push(next);
|
|
79
|
+
pushReplies(next.annotation);
|
|
80
|
+
}
|
|
69
81
|
}
|
|
70
|
-
for (const a of sorted) emit(a, false);
|
|
71
82
|
return out;
|
|
72
83
|
}
|
|
73
84
|
|
|
74
|
-
/** Timeline position of an annotation: its own time, or its thread root's for replies. */
|
|
75
|
-
function threadTs(annotation: Annotation, all: Annotation[]): number {
|
|
76
|
-
let current = annotation;
|
|
77
|
-
const seen = new Set<string>();
|
|
78
|
-
while (current.inReplyTo && !seen.has(current.id)) {
|
|
79
|
-
seen.add(current.id);
|
|
80
|
-
const parent = all.find((a) => a.id === current.inReplyTo);
|
|
81
|
-
if (!parent) break;
|
|
82
|
-
current = parent;
|
|
83
|
-
}
|
|
84
|
-
return current.createdA;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
85
|
interface DirectEditsPanelItem {
|
|
88
86
|
id: string;
|
|
89
87
|
title?: string;
|
|
@@ -178,13 +176,16 @@ export const AnnotationPanel: React.FC<PanelProps> = ({
|
|
|
178
176
|
// sit right after its parent (and the parent's earlier replies) at the
|
|
179
177
|
// parent's timeline position. With no replies the order is untouched.
|
|
180
178
|
const threadedAnnotations = threadReplies(sortedAnnotations);
|
|
179
|
+
// Thread timestamps are resolved once per render, linearly (shared helper),
|
|
180
|
+
// and the comparator only reads the map: resolving each chain inside the
|
|
181
|
+
// comparator with a linear parent lookup was O(n^2 log n) and froze the
|
|
182
|
+
// tab on a few thousand threaded comments.
|
|
183
|
+
const threadRootTs = resolveThreadRootTimestamps(sortedAnnotations);
|
|
181
184
|
const timelineEntries = [
|
|
182
|
-
...threadedAnnotations.map(({ annotation, isReply }) => ({ kind: 'plan' as const, ts: annotation.createdA, annotation, isReply })),
|
|
183
|
-
...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, annotation, isReply: false })),
|
|
185
|
+
...threadedAnnotations.map(({ annotation, isReply }) => ({ kind: 'plan' as const, ts: annotation.createdA, threadTs: threadRootTs.get(annotation.id) ?? annotation.createdA, annotation, isReply })),
|
|
186
|
+
...sortedCodeAnnotations.map(annotation => ({ kind: 'code' as const, ts: annotation.createdAt, threadTs: annotation.createdAt, annotation, isReply: false })),
|
|
184
187
|
].sort((a, b) => {
|
|
185
|
-
|
|
186
|
-
const tb = b.kind === 'plan' ? threadTs(b.annotation, sortedAnnotations) : b.ts;
|
|
187
|
-
if (ta !== tb) return ta - tb;
|
|
188
|
+
if (a.threadTs !== b.threadTs) return a.threadTs - b.threadTs;
|
|
188
189
|
return a.ts - b.ts;
|
|
189
190
|
});
|
|
190
191
|
const totalCount = annotations.length + codeAnnotations.length + (editorAnnotations?.length ?? 0);
|
|
@@ -2,7 +2,8 @@ import React, { useState, useRef, useLayoutEffect, useEffect } from 'react';
|
|
|
2
2
|
import type { EditorMode, InputMethod } from '../types';
|
|
3
3
|
import { TaterSpritePullup } from './TaterSpritePullup';
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** Props for the shared annotation input and action mode toolstrip. */
|
|
6
|
+
export interface AnnotationToolstripProps {
|
|
6
7
|
inputMethod: InputMethod;
|
|
7
8
|
onInputMethodChange: (method: InputMethod) => void;
|
|
8
9
|
mode: EditorMode;
|
|
@@ -30,8 +31,14 @@ interface AnnotationToolstripProps {
|
|
|
30
31
|
* pinpoint-only, so the switch would be a dead control there.
|
|
31
32
|
*/
|
|
32
33
|
hideInputMethodSwitch?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Omit only the Quick Label action. Defaults to false so existing consumers
|
|
36
|
+
* retain the complete action-mode group.
|
|
37
|
+
*/
|
|
38
|
+
hideQuickLabel?: boolean;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
/** Render the shared input-method and annotation-mode controls. */
|
|
35
42
|
export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
|
|
36
43
|
inputMethod,
|
|
37
44
|
onInputMethodChange,
|
|
@@ -42,6 +49,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
|
|
|
42
49
|
showHelpLink = true,
|
|
43
50
|
iconOnly = false,
|
|
44
51
|
hideInputMethodSwitch = false,
|
|
52
|
+
hideQuickLabel = false,
|
|
45
53
|
}) => {
|
|
46
54
|
const [showHelp, setShowHelp] = useState(false);
|
|
47
55
|
const [helpTab, setHelpTab] = useState<'selection' | 'plannotator'>('selection');
|
|
@@ -142,20 +150,22 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
|
|
|
142
150
|
</svg>
|
|
143
151
|
}
|
|
144
152
|
/>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
153
|
+
{!hideQuickLabel && (
|
|
154
|
+
<ToolstripButton
|
|
155
|
+
active={mode === 'quickLabel'}
|
|
156
|
+
onClick={() => onModeChange('quickLabel')}
|
|
157
|
+
label="Label"
|
|
158
|
+
color="warning"
|
|
159
|
+
mounted={mounted}
|
|
160
|
+
compact={compact}
|
|
161
|
+
iconOnly={iconOnly}
|
|
162
|
+
icon={
|
|
163
|
+
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
164
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
165
|
+
</svg>
|
|
166
|
+
}
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
159
169
|
</div>
|
|
160
170
|
|
|
161
171
|
{/* Help */}
|
|
@@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect, useCallback } from 'react';
|
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
3
|
import type { Viz } from '@viz-js/viz';
|
|
4
4
|
import type { Block } from '../types';
|
|
5
|
+
import { createRuntimeRetryEpoch } from '../utils/runtimeRetry';
|
|
5
6
|
|
|
6
7
|
interface ViewBox {
|
|
7
8
|
x: number;
|
|
@@ -60,6 +61,9 @@ export function __setVizLoaderForTests(
|
|
|
60
61
|
|
|
61
62
|
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
62
63
|
|
|
64
|
+
/** One Retry re-attempts every block whose engine import failed (see utils/runtimeRetry). */
|
|
65
|
+
const vizRetryEpoch = createRuntimeRetryEpoch();
|
|
66
|
+
|
|
63
67
|
function parseViewBox(svgEl: SVGSVGElement): ViewBox | null {
|
|
64
68
|
const raw = svgEl.getAttribute('viewBox');
|
|
65
69
|
if (!raw) return null;
|
|
@@ -153,6 +157,16 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
153
157
|
const [retryToken, setRetryToken] = useState(0);
|
|
154
158
|
const [showSource, setShowSource] = useState(false);
|
|
155
159
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
160
|
+
// A sibling's Retry re-attempts this block too, but only while its own
|
|
161
|
+
// failure was the shared engine import; a healthy block or a dot syntax
|
|
162
|
+
// error is left alone.
|
|
163
|
+
const runtimeUnavailableRef = useRef(runtimeUnavailable);
|
|
164
|
+
runtimeUnavailableRef.current = runtimeUnavailable;
|
|
165
|
+
useEffect(() => vizRetryEpoch.subscribe(() => {
|
|
166
|
+
if (!runtimeUnavailableRef.current) return;
|
|
167
|
+
setError(null);
|
|
168
|
+
setRetryToken((token) => token + 1);
|
|
169
|
+
}), []);
|
|
156
170
|
|
|
157
171
|
const zoomLevelRef = useRef(1);
|
|
158
172
|
const isDraggingRef = useRef(false);
|
|
@@ -431,10 +445,7 @@ export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
431
445
|
{runtimeUnavailable && (
|
|
432
446
|
<button
|
|
433
447
|
type="button"
|
|
434
|
-
onClick={() =>
|
|
435
|
-
setError(null);
|
|
436
|
-
setRetryToken((token) => token + 1);
|
|
437
|
-
}}
|
|
448
|
+
onClick={() => vizRetryEpoch.bump()}
|
|
438
449
|
className="ml-auto rounded-md border border-destructive/30 px-2 py-0.5 text-xs text-destructive hover:bg-destructive/10"
|
|
439
450
|
title="Retry loading the diagram renderer"
|
|
440
451
|
>
|
|
@@ -55,9 +55,10 @@ export interface HtmlSurfaceControlsProps {
|
|
|
55
55
|
onToggleArmed?: () => void;
|
|
56
56
|
/** Whether the floating tools over the page are hidden (eye-off). */
|
|
57
57
|
toolsHidden?: boolean;
|
|
58
|
-
/** Flip the tools. The eye
|
|
58
|
+
/** Flip the tools. The eye renders only when provided. */
|
|
59
59
|
onToggleTools?: () => void;
|
|
60
|
-
/** Whether a refresh is offered for this document.
|
|
60
|
+
/** Whether a refresh is offered for this document. The refresh renders
|
|
61
|
+
* whenever this is true and `onRefresh` is passed, with or without the eye. */
|
|
61
62
|
canRefresh?: boolean;
|
|
62
63
|
onRefresh?: () => void;
|
|
63
64
|
isRefreshing?: boolean;
|
|
@@ -80,15 +81,21 @@ export function HtmlSurfaceControls({
|
|
|
80
81
|
if (compact) return null;
|
|
81
82
|
const text = { ...DEFAULT_HTML_SURFACE_CONTROL_LABELS, ...labels };
|
|
82
83
|
const penLabel = armed ? labels?.annotateLabel : labels?.interactLabel;
|
|
84
|
+
const showRefresh = canRefresh && !!onRefresh;
|
|
83
85
|
return (
|
|
84
86
|
<>
|
|
85
|
-
{/*
|
|
87
|
+
{/* The refresh and the eye share one group, left of the pen. Each
|
|
88
|
+
renders on its own terms: the refresh whenever it is offered
|
|
89
|
+
(canRefresh + onRefresh), the eye whenever onToggleTools is passed,
|
|
90
|
+
so a host without the tools toggle still gets its refresh.
|
|
91
|
+
|
|
92
|
+
Show/hide tools: removes ALL floating chrome (sidebar tongue tabs +
|
|
86
93
|
the comment/attachments cluster) from the DOM, leaving nothing over
|
|
87
|
-
the page.
|
|
88
|
-
|
|
89
|
-
{onToggleTools && (
|
|
94
|
+
the page. This button is the only way back, so it never hides
|
|
95
|
+
itself. Eye = tools visible, eye-off = hidden. */}
|
|
96
|
+
{(showRefresh || onToggleTools) && (
|
|
90
97
|
<div className="ml-1 flex items-center gap-0.5">
|
|
91
|
-
{
|
|
98
|
+
{showRefresh && (
|
|
92
99
|
<button
|
|
93
100
|
type="button"
|
|
94
101
|
data-html-refresh
|
|
@@ -117,6 +124,7 @@ export function HtmlSurfaceControls({
|
|
|
117
124
|
<span className="hidden sm:inline">{isRefreshing ? text.refreshing : text.refresh}</span>
|
|
118
125
|
</button>
|
|
119
126
|
)}
|
|
127
|
+
{onToggleTools && (
|
|
120
128
|
<button
|
|
121
129
|
type="button"
|
|
122
130
|
data-html-tools-toggle
|
|
@@ -137,6 +145,7 @@ export function HtmlSurfaceControls({
|
|
|
137
145
|
)}
|
|
138
146
|
<span className="sr-only">{toolsHidden ? text.showTools : text.hideTools}</span>
|
|
139
147
|
</button>
|
|
148
|
+
)}
|
|
140
149
|
</div>
|
|
141
150
|
)}
|
|
142
151
|
|
|
@@ -7,10 +7,12 @@ interface ToolbarProps {
|
|
|
7
7
|
color: string;
|
|
8
8
|
strokeSize: number;
|
|
9
9
|
canUndo: boolean;
|
|
10
|
+
canRedo?: boolean;
|
|
10
11
|
onToolChange: (tool: Tool) => void;
|
|
11
12
|
onColorChange: (color: string) => void;
|
|
12
13
|
onStrokeSizeChange: (size: number) => void;
|
|
13
14
|
onUndo: () => void;
|
|
15
|
+
onRedo?: () => void;
|
|
14
16
|
onClear: () => void;
|
|
15
17
|
onSave: () => void;
|
|
16
18
|
}
|
|
@@ -44,6 +46,13 @@ const UndoIcon = () => (
|
|
|
44
46
|
</svg>
|
|
45
47
|
);
|
|
46
48
|
|
|
49
|
+
const RedoIcon = () => (
|
|
50
|
+
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
51
|
+
<path d="M21 7v6h-6" />
|
|
52
|
+
<path d="M3 17a9 9 0 019-9 9 9 0 016 2.3L21 13" />
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
|
|
47
56
|
const ClearIcon = () => (
|
|
48
57
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
|
49
58
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
@@ -81,10 +90,12 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|
|
81
90
|
color,
|
|
82
91
|
strokeSize,
|
|
83
92
|
canUndo,
|
|
93
|
+
canRedo = false,
|
|
84
94
|
onToolChange,
|
|
85
95
|
onColorChange,
|
|
86
96
|
onStrokeSizeChange,
|
|
87
97
|
onUndo,
|
|
98
|
+
onRedo,
|
|
88
99
|
onClear,
|
|
89
100
|
onSave,
|
|
90
101
|
}) => {
|
|
@@ -192,6 +203,22 @@ export const Toolbar: React.FC<ToolbarProps> = ({
|
|
|
192
203
|
<UndoIcon />
|
|
193
204
|
</button>
|
|
194
205
|
|
|
206
|
+
{onRedo && (
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={onRedo}
|
|
210
|
+
disabled={!canRedo}
|
|
211
|
+
title="Redo (Cmd+Shift+Z)"
|
|
212
|
+
className={`p-1.5 rounded transition-colors ${
|
|
213
|
+
canRedo
|
|
214
|
+
? 'hover:bg-muted text-muted-foreground hover:text-foreground'
|
|
215
|
+
: 'text-muted-foreground/30 cursor-not-allowed'
|
|
216
|
+
}`}
|
|
217
|
+
>
|
|
218
|
+
<RedoIcon />
|
|
219
|
+
</button>
|
|
220
|
+
)}
|
|
221
|
+
|
|
195
222
|
{/* Clear all */}
|
|
196
223
|
<button
|
|
197
224
|
type="button"
|