@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.
Files changed (46) hide show
  1. package/HANDOFF.md +664 -0
  2. package/README.md +57 -1
  3. package/components/AnnotationPanel.tsx +96 -4
  4. package/components/AnnotationToolbar.tsx +25 -25
  5. package/components/CommentPopover.tsx +26 -0
  6. package/components/GraphvizBlock.tsx +86 -7
  7. package/components/HtmlSurfaceControls.tsx +170 -0
  8. package/components/InlineMarkdown.tsx +22 -2
  9. package/components/MermaidBlock.tsx +60 -26
  10. package/components/Settings.tsx +40 -1
  11. package/components/blocks/MathBlock.tsx +26 -14
  12. package/components/html-viewer/HtmlViewer.tsx +289 -6
  13. package/components/html-viewer/bridge-script.asset.js +4392 -0
  14. package/components/html-viewer/bridge-script.lite.ts +9 -0
  15. package/components/html-viewer/bridge-script.ts +54 -8
  16. package/components/html-viewer/hostThreads.ts +37 -0
  17. package/components/html-viewer/index.ts +22 -1
  18. package/components/html-viewer/srcdoc.ts +70 -1
  19. package/components/html-viewer/unanchored.ts +47 -0
  20. package/components/html-viewer/useHtmlAnnotation.ts +132 -5
  21. package/configure.ts +32 -0
  22. package/hooks/useHtmlRefresh.ts +149 -0
  23. package/hooks/useMathRenderer.ts +30 -0
  24. package/hooks/useSharing.ts +31 -5
  25. package/package.json +9 -3
  26. package/styles.css +1 -1
  27. package/types.ts +1 -0
  28. package/utils/generateIdentity.ts +64 -14
  29. package/utils/identity-tater.ts +36 -0
  30. package/utils/math-default-loader.ts +24 -0
  31. package/utils/math-eager.ts +25 -0
  32. package/utils/math.ts +149 -0
  33. package/utils/mermaid-eager.ts +28 -0
  34. package/utils/mermaid.ts +132 -0
  35. package/utils/parser.ts +38 -0
  36. package/utils/quickLabels.ts +13 -0
  37. package/webmcp/activity.ts +46 -0
  38. package/webmcp/changes.ts +227 -0
  39. package/webmcp/index.ts +72 -0
  40. package/webmcp/modelContext.ts +103 -0
  41. package/webmcp/nudges.ts +174 -0
  42. package/webmcp/policy.ts +50 -0
  43. package/webmcp/preference.ts +50 -0
  44. package/webmcp/schema.ts +81 -0
  45. package/webmcp/toolset.ts +337 -0
  46. package/webmcp/useToolset.ts +74 -0
package/configure.ts CHANGED
@@ -8,6 +8,9 @@ import { setDraftTransport, type DraftTransport } from './hooks/useAnnotationDra
8
8
  import { setExternalAnnotationTransport, type ExternalAnnotationTransport } from './hooks/useExternalAnnotations';
9
9
  import { setAITransport, type AITransport } from './hooks/useAIChat';
10
10
  import { setSkillCatalogTransport, setSkillContentTransport, type SkillCatalogTransport, type SkillContentTransport } from './utils/skillCatalog';
11
+ import { setWebMcpPolicy, type WebMcpPolicy } from './webmcp/policy';
12
+ import { setMathRendererLoader, type MathRenderer, type MathRendererLoader } from './utils/math';
13
+ import { setIdentityGenerator, type IdentityGenerator } from './utils/generateIdentity';
11
14
  import { configStore } from './config';
12
15
  import type { ServerSyncFn } from './config/configStore';
13
16
  import type { ExternalAnnotationEvent, VaultNode } from './types';
@@ -31,6 +34,10 @@ export type {
31
34
  SkillCatalogTransport,
32
35
  SkillContentTransport,
33
36
  ServerSyncFn,
37
+ WebMcpPolicy,
38
+ MathRenderer,
39
+ MathRendererLoader,
40
+ IdentityGenerator,
34
41
  };
35
42
 
36
43
  type ExternalAnnotationBase = { id: string; source?: string };
@@ -56,6 +63,28 @@ export interface PlannotatorUIConfig {
56
63
  /** Human-only skill contents request for feedback injection. Default: `GET /api/skills/content?name=` on the page origin. */
57
64
  skillContentTransport?: SkillContentTransport;
58
65
  serverSync?: ServerSyncFn;
66
+ /**
67
+ * WebMCP provider policy: `{ enabled, namePrefix }`. Default: enabled
68
+ * whenever the browser exposes `document.modelContext`, with the
69
+ * `plannotator.` prefix. There is no confirmation seam because the catalog
70
+ * exposes nothing consequential: no tool decides, submits or closes.
71
+ */
72
+ webmcp?: WebMcpPolicy;
73
+ /**
74
+ * How the math renderer is loaded when no renderer is registered before the
75
+ * first math node renders. Default: `import('katex')` (JS only; the
76
+ * stylesheet stays the host's job). A host that wants KaTeX and its CSS on
77
+ * one lazy chunk passes a loader that imports both. Hosts that want math
78
+ * typeset on the first commit instead import `@plannotator/ui/utils/math-eager`.
79
+ */
80
+ mathRendererLoader?: MathRendererLoader;
81
+ /**
82
+ * Synchronous generator for the default "tater" display name, used only when
83
+ * no `identityProvider` is installed. Default: a small built-in pool of the
84
+ * same `adjective-noun-tater` shape. Plannotator registers the full
85
+ * dictionary by importing `@plannotator/ui/utils/identity-tater`.
86
+ */
87
+ identityGenerator?: IdentityGenerator;
59
88
  /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
60
89
  loadSettingsFromBackend?: boolean;
61
90
  }
@@ -73,6 +102,9 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
73
102
  if (config.skillCatalogTransport) setSkillCatalogTransport(config.skillCatalogTransport);
74
103
  if (config.skillContentTransport) setSkillContentTransport(config.skillContentTransport);
75
104
  if (config.serverSync) configStore.setServerSync(config.serverSync);
105
+ if (config.webmcp) setWebMcpPolicy(config.webmcp);
106
+ if (config.mathRendererLoader) setMathRendererLoader(config.mathRendererLoader);
107
+ if (config.identityGenerator) setIdentityGenerator(config.identityGenerator);
76
108
  // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
77
109
  if (config.loadSettingsFromBackend) configStore.loadFromBackend();
78
110
  }
@@ -0,0 +1,149 @@
1
+ import { useCallback, useLayoutEffect, useRef, useState } from 'react';
2
+
3
+ /** What a host's `fetchSnapshot` resolves to. */
4
+ export type HtmlRefreshSnapshot =
5
+ | { status: 'ok'; rawHtml: string }
6
+ | { status: 'missing' }
7
+ | { status: 'unavailable' };
8
+
9
+ /** The outcome of one `refresh()` call, for host notifications (toasts). */
10
+ export type HtmlRefreshResult = 'refreshed' | 'missing' | 'unavailable';
11
+
12
+ export interface UseHtmlRefreshOptions {
13
+ /** Whether refresh is offered at all. Default true. */
14
+ enabled?: boolean;
15
+ /**
16
+ * Identity of the document under refresh (a path, an id). A change
17
+ * cancels any in-flight fetch and any pending restore acknowledgement, so
18
+ * a snapshot for the previous document can never land on the next one.
19
+ * `null` means no document: `canRefresh` is false. Omit it when the host
20
+ * has a single document.
21
+ */
22
+ documentKey?: string | null;
23
+ /** Fetch the current bytes of the document. Called with `documentKey`.
24
+ * A rejection is treated as `{ status: 'unavailable' }`. */
25
+ fetchSnapshot: (documentKey: string | null) => Promise<HtmlRefreshSnapshot>;
26
+ /** Apply the refreshed bytes (the host owns the viewer's `rawHtml`). */
27
+ onSnapshot: (rawHtml: string) => void;
28
+ /**
29
+ * Once per refresh: the ids the remounted viewer could not re-anchor,
30
+ * possibly empty. Wire the viewer's `onUnanchoredChange` to the returned
31
+ * `reportAnnotationRestore`; only the first report after a refresh is
32
+ * forwarded, and only while the document and reload generation match.
33
+ */
34
+ onUnanchored?: (ids: string[]) => void;
35
+ /** The outcome of each `refresh()` call that reached a decision. */
36
+ onResult?: (result: HtmlRefreshResult) => void;
37
+ }
38
+
39
+ export interface UseHtmlRefreshReturn {
40
+ canRefresh: boolean;
41
+ isRefreshing: boolean;
42
+ /** Bumps after every applied snapshot. Key the viewer on it to remount. */
43
+ reloadGeneration: number;
44
+ refresh: () => Promise<void>;
45
+ /** Feed the viewer's `onUnanchoredChange` report here. */
46
+ reportAnnotationRestore: (missingIds: string[]) => void;
47
+ }
48
+
49
+ /**
50
+ * Re-fetch a rendered HTML document from the host's source and remount the
51
+ * viewer on it, keeping the annotations the viewer can still anchor.
52
+ *
53
+ * Backend-agnostic: the host supplies `fetchSnapshot` (Plannotator wraps its
54
+ * `/api/doc` read; a host with a document store passes its own read). The
55
+ * hook owns the guards: an in-flight fetch that is superseded by a newer
56
+ * refresh, or by a document change, is dropped before `onSnapshot`; the
57
+ * restore acknowledgement is armed per reload generation and consumed by
58
+ * the first viewer report for that generation.
59
+ */
60
+ export function useHtmlRefresh({
61
+ enabled = true,
62
+ documentKey,
63
+ fetchSnapshot,
64
+ onSnapshot,
65
+ onUnanchored,
66
+ onResult,
67
+ }: UseHtmlRefreshOptions): UseHtmlRefreshReturn {
68
+ const [isRefreshing, setIsRefreshing] = useState(false);
69
+ const [reloadGeneration, setReloadGeneration] = useState(0);
70
+ const keyed = documentKey !== undefined;
71
+ const activeKey = keyed ? documentKey : null;
72
+ const activeKeyRef = useRef(activeKey);
73
+ const requestRef = useRef(0);
74
+ const reloadGenerationRef = useRef(0);
75
+ const restorePendingRef = useRef<{ key: string | null; generation: number } | null>(null);
76
+ const onUnanchoredRef = useRef(onUnanchored);
77
+ onUnanchoredRef.current = onUnanchored;
78
+ const onResultRef = useRef(onResult);
79
+ onResultRef.current = onResult;
80
+ const canRefresh = enabled && (!keyed || !!documentKey);
81
+
82
+ useLayoutEffect(() => {
83
+ if (activeKeyRef.current !== activeKey) {
84
+ requestRef.current += 1;
85
+ restorePendingRef.current = null;
86
+ setIsRefreshing(false);
87
+ }
88
+ activeKeyRef.current = activeKey;
89
+ }, [activeKey]);
90
+
91
+ const refresh = useCallback(async () => {
92
+ if (!canRefresh) return;
93
+
94
+ const requestKey = activeKey;
95
+ const requestId = ++requestRef.current;
96
+ setIsRefreshing(true);
97
+ try {
98
+ // A rejecting fetch is an unavailable snapshot: the host hears it
99
+ // through onResult like any other outcome, never as an unhandled
100
+ // rejection out of refresh().
101
+ let result: HtmlRefreshSnapshot;
102
+ try {
103
+ result = await fetchSnapshot(requestKey);
104
+ } catch {
105
+ result = { status: 'unavailable' };
106
+ }
107
+ if (requestId !== requestRef.current || activeKeyRef.current !== requestKey) return;
108
+
109
+ if (result.status === 'missing' || result.status === 'unavailable') {
110
+ onResultRef.current?.(result.status);
111
+ return;
112
+ }
113
+
114
+ onSnapshot(result.rawHtml);
115
+ const nextGeneration = reloadGenerationRef.current + 1;
116
+ reloadGenerationRef.current = nextGeneration;
117
+ // Armed until the remounted viewer's bridge reports its restore. The
118
+ // bridge emits "unanchored" only when the set CHANGES from its initial
119
+ // empty state, so a pass that restores everything never posts and this
120
+ // stays armed; that is harmless because the next refresh replaces it
121
+ // and a document change clears it.
122
+ restorePendingRef.current = { key: requestKey, generation: nextGeneration };
123
+ setReloadGeneration(nextGeneration);
124
+ onResultRef.current?.('refreshed');
125
+ } finally {
126
+ if (requestId === requestRef.current) setIsRefreshing(false);
127
+ }
128
+ }, [activeKey, canRefresh, fetchSnapshot, onSnapshot]);
129
+
130
+ const reportAnnotationRestore = useCallback((missingIds: string[]) => {
131
+ const pending = restorePendingRef.current;
132
+ if (
133
+ !pending ||
134
+ pending.key !== activeKeyRef.current ||
135
+ pending.generation !== reloadGenerationRef.current
136
+ ) return;
137
+
138
+ restorePendingRef.current = null;
139
+ onUnanchoredRef.current?.(missingIds);
140
+ }, []);
141
+
142
+ return {
143
+ canRefresh,
144
+ isRefreshing,
145
+ reloadGeneration,
146
+ refresh,
147
+ reportAnnotationRestore,
148
+ };
149
+ }
@@ -0,0 +1,30 @@
1
+ import { useEffect, useSyncExternalStore } from 'react';
2
+ import {
3
+ getMathRenderer,
4
+ loadMathRenderer,
5
+ subscribeMathRenderer,
6
+ type MathRenderer,
7
+ } from '../utils/math';
8
+
9
+ /**
10
+ * The registered math renderer, read synchronously during render.
11
+ *
12
+ * With the slot filled before mount (Plannotator: `utils/math-eager`) this
13
+ * returns KaTeX on the first render and the effect below is a no-op, so the
14
+ * typeset HTML is in the first commit. With the slot empty it returns `null`,
15
+ * kicks off `loadMathRenderer()` from an effect, and the subscription
16
+ * re-renders the caller once the renderer lands. A rejected load is left to
17
+ * the loader's retry contract; the caller keeps showing the TeX placeholder.
18
+ */
19
+ export function useMathRenderer(): MathRenderer | null {
20
+ const renderer = useSyncExternalStore(subscribeMathRenderer, getMathRenderer, getMathRenderer);
21
+
22
+ useEffect(() => {
23
+ if (renderer) return;
24
+ loadMathRenderer().catch(() => {
25
+ /* placeholder stays; the next mount retries */
26
+ });
27
+ }, [renderer]);
28
+
29
+ return renderer;
30
+ }
@@ -8,7 +8,7 @@
8
8
  * - Tracking whether current session is from a shared link
9
9
  */
10
10
 
11
- import React, { useState, useEffect, useCallback, useRef } from 'react';
11
+ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
12
12
  import { Annotation, type ImageAttachment } from '../types';
13
13
  import {
14
14
  type SharePayload,
@@ -104,6 +104,7 @@ export function useSharing(
104
104
  setRawHtml?: (h: string) => void,
105
105
  setShareHtml?: (h: string) => void,
106
106
  setRenderAs?: (m: 'markdown' | 'html') => void,
107
+ contentRevision = 0,
107
108
  ): UseSharingResult {
108
109
  const [isSharedSession, setIsSharedSession] = useState(false);
109
110
  const [isLoadingShared, setIsLoadingShared] = useState(true);
@@ -115,6 +116,23 @@ export function useSharing(
115
116
  const [pendingSharedAnnotations, setPendingSharedAnnotations] = useState<Annotation[] | null>(null);
116
117
  const [sharedGlobalAttachments, setSharedGlobalAttachments] = useState<ImageAttachment[] | null>(null);
117
118
  const [shareLoadError, setShareLoadError] = useState('');
119
+ // Identity token for in-flight share requests: a new token invalidates any
120
+ // response still on the wire. Content inputs only — resolveRawHtmlForShare
121
+ // is deliberately NOT a dependency: its identity changes when it caches the
122
+ // portable HTML mid-request (App memoizes it on shareHtml), and listing it
123
+ // would make the first short-link generation on an HTML session discard its
124
+ // own result. HTML refreshes invalidate through contentRevision instead.
125
+ const shareRequestContext = useMemo(() => ({}), [
126
+ markdown,
127
+ annotations,
128
+ globalAttachments,
129
+ shareBaseUrl,
130
+ pasteApiUrl,
131
+ rawHtml,
132
+ contentRevision,
133
+ ]);
134
+ const latestShareRequestContextRef = useRef(shareRequestContext);
135
+ latestShareRequestContextRef.current = shareRequestContext;
118
136
 
119
137
  const clearPendingSharedAnnotations = useCallback(() => {
120
138
  setPendingSharedAnnotations(null);
@@ -257,16 +275,19 @@ export function useSharing(
257
275
 
258
276
  // Generate share URL when markdown or annotations change
259
277
  const refreshShareUrl = useCallback(async () => {
278
+ const requestContext = shareRequestContext;
260
279
  try {
261
280
  const url = await generateShareUrl(markdown, annotations, globalAttachments, shareBaseUrl, rawHtml);
281
+ if (latestShareRequestContextRef.current !== requestContext) return;
262
282
  setShareUrl(url ?? '');
263
283
  setShareUrlSize(url ? formatUrlSize(url) : '');
264
284
  } catch (e) {
285
+ if (latestShareRequestContextRef.current !== requestContext) return;
265
286
  console.error('Failed to generate share URL:', e);
266
287
  setShareUrl('');
267
288
  setShareUrlSize('');
268
289
  }
269
- }, [markdown, annotations, globalAttachments, shareBaseUrl, rawHtml]);
290
+ }, [markdown, annotations, globalAttachments, shareBaseUrl, rawHtml, shareRequestContext]);
270
291
 
271
292
  // Auto-refresh share URL when dependencies change
272
293
  useEffect(() => {
@@ -280,9 +301,10 @@ export function useSharing(
280
301
  useEffect(() => {
281
302
  if (isSharedSession) { isSharedRef.current = true; return; }
282
303
  if (isSharedRef.current) { isSharedRef.current = false; return; }
304
+ setIsGeneratingShortUrl(false);
283
305
  setShortShareUrl('');
284
306
  setShortUrlError('');
285
- }, [markdown, annotations, globalAttachments, rawHtml, isSharedSession]);
307
+ }, [markdown, annotations, globalAttachments, rawHtml, isSharedSession, contentRevision]);
286
308
 
287
309
  /**
288
310
  * Generate a short URL via the paste service.
@@ -295,6 +317,7 @@ export function useSharing(
295
317
 
296
318
  setIsGeneratingShortUrl(true);
297
319
  setShortUrlError('');
320
+ const requestContext = shareRequestContext;
298
321
 
299
322
  try {
300
323
  const htmlForShare = rawHtml
@@ -308,6 +331,8 @@ export function useSharing(
308
331
  htmlForShare,
309
332
  );
310
333
 
334
+ if (latestShareRequestContextRef.current !== requestContext) return null;
335
+
311
336
  if (result) {
312
337
  setShortShareUrl(result.shortUrl);
313
338
  return result.shortUrl;
@@ -317,13 +342,14 @@ export function useSharing(
317
342
  return null;
318
343
  }
319
344
  } catch (e) {
345
+ if (latestShareRequestContextRef.current !== requestContext) return null;
320
346
  setShortShareUrl('');
321
347
  setShortUrlError(e instanceof Error ? e.message : 'Failed to generate short URL');
322
348
  return null;
323
349
  } finally {
324
- setIsGeneratingShortUrl(false);
350
+ if (latestShareRequestContextRef.current === requestContext) setIsGeneratingShortUrl(false);
325
351
  }
326
- }, [markdown, annotations, globalAttachments, shareBaseUrl, pasteApiUrl, rawHtml, resolveRawHtmlForShare]);
352
+ }, [markdown, annotations, globalAttachments, shareBaseUrl, pasteApiUrl, rawHtml, resolveRawHtmlForShare, shareRequestContext]);
327
353
 
328
354
  // Import annotations from a teammate's share URL (supports both hash-based and short /p/<id> URLs)
329
355
  const importFromShareUrl = useCallback(async (url: string): Promise<ImportResult> => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.31.0",
3
+ "version": "0.33.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",
@@ -11,12 +11,15 @@
11
11
  "./components/ImageAnnotator": "./components/ImageAnnotator/index.tsx",
12
12
  "./components/html-viewer": "./components/html-viewer/index.ts",
13
13
  "./components/html-viewer/bridge-script": "./components/html-viewer/bridge-script.ts",
14
+ "./components/html-viewer/bridge-script.asset.js": "./components/html-viewer/bridge-script.asset.js",
15
+ "./components/html-viewer/bridge-script.lite": "./components/html-viewer/bridge-script.lite.ts",
14
16
  "./components/sidebar/*": "./components/sidebar/*.tsx",
15
17
  "./components/plan-diff/*": "./components/plan-diff/*.tsx",
16
18
  "./utils/*": "./utils/*.ts",
17
19
  "./lib/*": "./lib/*.ts",
18
20
  "./hooks/*": "./hooks/*.ts",
19
21
  "./shortcuts": "./shortcuts/index.ts",
22
+ "./webmcp": "./webmcp/index.ts",
20
23
  "./config": "./config/index.ts",
21
24
  "./configure": "./configure.ts",
22
25
  "./theme-modes": "./components/themeModes.tsx",
@@ -32,6 +35,7 @@
32
35
  "lib",
33
36
  "shortcuts",
34
37
  "utils",
38
+ "webmcp",
35
39
  "assets",
36
40
  "themes",
37
41
  "sprite_package_additional",
@@ -44,6 +48,7 @@
44
48
  "print.css",
45
49
  "styles.css",
46
50
  "plannotator.webp",
51
+ "HANDOFF.md",
47
52
  "!**/*.test.ts",
48
53
  "!**/*.test.tsx",
49
54
  "!test-setup"
@@ -69,7 +74,7 @@
69
74
  "@lezer/highlight": "^1.2.3",
70
75
  "@pierre/diffs": "1.3.2",
71
76
  "@plannotator/atomic-editor": "^0.8.0",
72
- "@plannotator/core": "0.24.0",
77
+ "@plannotator/core": "0.25.0",
73
78
  "@plannotator/markdown-editor": "^0.4.0",
74
79
  "@plannotator/web-highlighter": "^0.8.1",
75
80
  "@tanstack/react-table": "^8.21.3",
@@ -107,6 +112,7 @@
107
112
  "scripts": {
108
113
  "typecheck": "tsc --noEmit -p tsconfig.json",
109
114
  "build:css": "vite build --config vite.css.config.ts && rm -f styles.js",
110
- "prepack": "bun run build:css"
115
+ "build:bridge-assets": "bun run scripts/build-bridge-assets.ts",
116
+ "prepack": "bun run build:css && bun run build:bridge-assets"
111
117
  }
112
118
  }