@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/hooks/useHtmlRefresh.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* A successful snapshot. `Extra` lets a host carry document metadata read
|
|
5
|
+
* alongside the bytes (Plannotator: the root document's version diff) through
|
|
6
|
+
* to `onSnapshot`; the hook never reads anything but `rawHtml`.
|
|
7
|
+
*/
|
|
8
|
+
export type HtmlRefreshOkSnapshot<Extra extends object = object> = { status: 'ok'; rawHtml: string } & Extra;
|
|
9
|
+
|
|
3
10
|
/** What a host's `fetchSnapshot` resolves to. */
|
|
4
|
-
export type HtmlRefreshSnapshot =
|
|
5
|
-
|
|
|
11
|
+
export type HtmlRefreshSnapshot<Extra extends object = object> =
|
|
12
|
+
| HtmlRefreshOkSnapshot<Extra>
|
|
6
13
|
| { status: 'missing' }
|
|
7
14
|
| { status: 'unavailable' };
|
|
8
15
|
|
|
9
16
|
/** The outcome of one `refresh()` call, for host notifications (toasts). */
|
|
10
17
|
export type HtmlRefreshResult = 'refreshed' | 'missing' | 'unavailable';
|
|
11
18
|
|
|
12
|
-
export interface UseHtmlRefreshOptions {
|
|
19
|
+
export interface UseHtmlRefreshOptions<Extra extends object = object> {
|
|
13
20
|
/** Whether refresh is offered at all. Default true. */
|
|
14
21
|
enabled?: boolean;
|
|
15
22
|
/**
|
|
@@ -22,9 +29,11 @@ export interface UseHtmlRefreshOptions {
|
|
|
22
29
|
documentKey?: string | null;
|
|
23
30
|
/** Fetch the current bytes of the document. Called with `documentKey`.
|
|
24
31
|
* 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
|
-
|
|
32
|
+
fetchSnapshot: (documentKey: string | null) => Promise<HtmlRefreshSnapshot<Extra>>;
|
|
33
|
+
/** Apply the refreshed bytes (the host owns the viewer's `rawHtml`). The
|
|
34
|
+
* whole successful snapshot is the second argument, for hosts whose
|
|
35
|
+
* `fetchSnapshot` reads metadata alongside the bytes. */
|
|
36
|
+
onSnapshot: (rawHtml: string, snapshot: HtmlRefreshOkSnapshot<Extra>) => void;
|
|
28
37
|
/**
|
|
29
38
|
* Once per refresh: the ids the remounted viewer could not re-anchor,
|
|
30
39
|
* possibly empty. Wire the viewer's `onUnanchoredChange` to the returned
|
|
@@ -57,14 +66,14 @@ export interface UseHtmlRefreshReturn {
|
|
|
57
66
|
* restore acknowledgement is armed per reload generation and consumed by
|
|
58
67
|
* the first viewer report for that generation.
|
|
59
68
|
*/
|
|
60
|
-
export function useHtmlRefresh({
|
|
69
|
+
export function useHtmlRefresh<Extra extends object = object>({
|
|
61
70
|
enabled = true,
|
|
62
71
|
documentKey,
|
|
63
72
|
fetchSnapshot,
|
|
64
73
|
onSnapshot,
|
|
65
74
|
onUnanchored,
|
|
66
75
|
onResult,
|
|
67
|
-
}: UseHtmlRefreshOptions): UseHtmlRefreshReturn {
|
|
76
|
+
}: UseHtmlRefreshOptions<Extra>): UseHtmlRefreshReturn {
|
|
68
77
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
69
78
|
const [reloadGeneration, setReloadGeneration] = useState(0);
|
|
70
79
|
const keyed = documentKey !== undefined;
|
|
@@ -98,7 +107,7 @@ export function useHtmlRefresh({
|
|
|
98
107
|
// A rejecting fetch is an unavailable snapshot: the host hears it
|
|
99
108
|
// through onResult like any other outcome, never as an unhandled
|
|
100
109
|
// rejection out of refresh().
|
|
101
|
-
let result: HtmlRefreshSnapshot
|
|
110
|
+
let result: HtmlRefreshSnapshot<Extra>;
|
|
102
111
|
try {
|
|
103
112
|
result = await fetchSnapshot(requestKey);
|
|
104
113
|
} catch {
|
|
@@ -111,7 +120,7 @@ export function useHtmlRefresh({
|
|
|
111
120
|
return;
|
|
112
121
|
}
|
|
113
122
|
|
|
114
|
-
onSnapshot(result.rawHtml);
|
|
123
|
+
onSnapshot(result.rawHtml, result);
|
|
115
124
|
const nextGeneration = reloadGenerationRef.current + 1;
|
|
116
125
|
reloadGenerationRef.current = nextGeneration;
|
|
117
126
|
// Armed until the remounted viewer's bridge reports its restore. The
|
package/hooks/useSharing.ts
CHANGED
|
@@ -76,6 +76,12 @@ interface UseSharingResult {
|
|
|
76
76
|
clearShareLoadError: () => void;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
type ShortShareUrlLifecycle =
|
|
80
|
+
| { readonly _tag: 'none' }
|
|
81
|
+
| { readonly _tag: 'incoming-hydration' }
|
|
82
|
+
| { readonly _tag: 'generating'; readonly requestContext: object }
|
|
83
|
+
| { readonly _tag: 'associated'; readonly requestContext: object }
|
|
84
|
+
| { readonly _tag: 'failed'; readonly requestContext: object };
|
|
79
85
|
|
|
80
86
|
// Share payloads are base64url-encoded deflate output: charset [A-Za-z0-9_-],
|
|
81
87
|
// realistically >=30 chars, and virtually always mixed-case because deflate
|
|
@@ -133,6 +139,7 @@ export function useSharing(
|
|
|
133
139
|
]);
|
|
134
140
|
const latestShareRequestContextRef = useRef(shareRequestContext);
|
|
135
141
|
latestShareRequestContextRef.current = shareRequestContext;
|
|
142
|
+
const shortShareUrlLifecycleRef = useRef<ShortShareUrlLifecycle>({ _tag: 'none' });
|
|
136
143
|
|
|
137
144
|
const clearPendingSharedAnnotations = useCallback(() => {
|
|
138
145
|
setPendingSharedAnnotations(null);
|
|
@@ -148,6 +155,11 @@ export function useSharing(
|
|
|
148
155
|
const pathMatch = window.location.pathname.match(/^\/p\/([A-Za-z0-9]{6,16})$/);
|
|
149
156
|
if (pathMatch) {
|
|
150
157
|
const pasteId = pathMatch[1];
|
|
158
|
+
// Capture before the async fetch. Concurrent loads (including the
|
|
159
|
+
// development Strict Mode replay) can complete after an earlier load
|
|
160
|
+
// removes /p/<id> from history; every completion must preserve the
|
|
161
|
+
// original short URL.
|
|
162
|
+
const incomingShortUrl = window.location.href;
|
|
151
163
|
|
|
152
164
|
// Extract key and optional paste origin from fragment: #key=<k>&paste=<base64url>
|
|
153
165
|
const fragment = window.location.hash.slice(1);
|
|
@@ -180,7 +192,8 @@ export function useSharing(
|
|
|
180
192
|
|
|
181
193
|
setPendingSharedAnnotations(restoredAnnotations);
|
|
182
194
|
setIsSharedSession(true);
|
|
183
|
-
|
|
195
|
+
shortShareUrlLifecycleRef.current = { _tag: 'incoming-hydration' };
|
|
196
|
+
setShortShareUrl(incomingShortUrl);
|
|
184
197
|
onSharedLoad?.();
|
|
185
198
|
|
|
186
199
|
// Remove the /p/<id> path from browser history so a refresh doesn't
|
|
@@ -294,17 +307,43 @@ export function useSharing(
|
|
|
294
307
|
refreshShareUrl();
|
|
295
308
|
}, [refreshShareUrl]);
|
|
296
309
|
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
310
|
+
// An incoming short URL becomes associated with the fully hydrated share
|
|
311
|
+
// context on its first committed render. From then on it follows the same
|
|
312
|
+
// lifecycle as a locally generated URL: any shareable-content change makes
|
|
313
|
+
// the immutable paste stale, so discard the URL without auto-uploading a
|
|
314
|
+
// replacement. Markdown users can still use the fresh hash URL; creating a
|
|
315
|
+
// new short link remains an explicit action.
|
|
301
316
|
useEffect(() => {
|
|
302
|
-
|
|
303
|
-
if (
|
|
317
|
+
const lifecycle = shortShareUrlLifecycleRef.current;
|
|
318
|
+
if (lifecycle._tag === 'incoming-hydration') {
|
|
319
|
+
if (!shortShareUrl) return;
|
|
320
|
+
// Hydration writes fresh annotation and attachment arrays, so this first
|
|
321
|
+
// committed request context represents the loaded snapshot. If those
|
|
322
|
+
// setters ever preserve identity, replace this consume-on-next-effect
|
|
323
|
+
// handoff with an explicit post-hydration signal.
|
|
324
|
+
shortShareUrlLifecycleRef.current = {
|
|
325
|
+
_tag: 'associated',
|
|
326
|
+
requestContext: shareRequestContext,
|
|
327
|
+
};
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (
|
|
331
|
+
(
|
|
332
|
+
lifecycle._tag === 'generating'
|
|
333
|
+
|| lifecycle._tag === 'associated'
|
|
334
|
+
|| lifecycle._tag === 'failed'
|
|
335
|
+
)
|
|
336
|
+
&& lifecycle.requestContext === shareRequestContext
|
|
337
|
+
) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (lifecycle._tag === 'none' && !shortShareUrl) return;
|
|
341
|
+
|
|
342
|
+
shortShareUrlLifecycleRef.current = { _tag: 'none' };
|
|
304
343
|
setIsGeneratingShortUrl(false);
|
|
305
344
|
setShortShareUrl('');
|
|
306
345
|
setShortUrlError('');
|
|
307
|
-
}, [
|
|
346
|
+
}, [shareRequestContext, shortShareUrl]);
|
|
308
347
|
|
|
309
348
|
/**
|
|
310
349
|
* Generate a short URL via the paste service.
|
|
@@ -318,6 +357,10 @@ export function useSharing(
|
|
|
318
357
|
setIsGeneratingShortUrl(true);
|
|
319
358
|
setShortUrlError('');
|
|
320
359
|
const requestContext = shareRequestContext;
|
|
360
|
+
shortShareUrlLifecycleRef.current = {
|
|
361
|
+
_tag: 'generating',
|
|
362
|
+
requestContext,
|
|
363
|
+
};
|
|
321
364
|
|
|
322
365
|
try {
|
|
323
366
|
const htmlForShare = rawHtml
|
|
@@ -334,15 +377,21 @@ export function useSharing(
|
|
|
334
377
|
if (latestShareRequestContextRef.current !== requestContext) return null;
|
|
335
378
|
|
|
336
379
|
if (result) {
|
|
380
|
+
shortShareUrlLifecycleRef.current = {
|
|
381
|
+
_tag: 'associated',
|
|
382
|
+
requestContext,
|
|
383
|
+
};
|
|
337
384
|
setShortShareUrl(result.shortUrl);
|
|
338
385
|
return result.shortUrl;
|
|
339
386
|
} else {
|
|
387
|
+
shortShareUrlLifecycleRef.current = { _tag: 'failed', requestContext };
|
|
340
388
|
setShortShareUrl('');
|
|
341
389
|
setShortUrlError('Short URL service unavailable');
|
|
342
390
|
return null;
|
|
343
391
|
}
|
|
344
392
|
} catch (e) {
|
|
345
393
|
if (latestShareRequestContextRef.current !== requestContext) return null;
|
|
394
|
+
shortShareUrlLifecycleRef.current = { _tag: 'failed', requestContext };
|
|
346
395
|
setShortShareUrl('');
|
|
347
396
|
setShortUrlError(e instanceof Error ? e.message : 'Failed to generate short URL');
|
|
348
397
|
return null;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
createUndoHistoryState,
|
|
4
|
+
recordUndoAction,
|
|
5
|
+
takeRedoAction,
|
|
6
|
+
takeUndoAction,
|
|
7
|
+
type HistoryDirection,
|
|
8
|
+
type UndoHistoryState,
|
|
9
|
+
} from '../utils/undoHistory';
|
|
10
|
+
|
|
11
|
+
/** Imperative bounded history API used by surface-specific command adapters. */
|
|
12
|
+
export interface UndoHistoryApi<TAction> {
|
|
13
|
+
readonly canUndo: boolean;
|
|
14
|
+
readonly canRedo: boolean;
|
|
15
|
+
record: (action: TAction) => void;
|
|
16
|
+
undo: () => boolean;
|
|
17
|
+
redo: () => boolean;
|
|
18
|
+
clear: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface UndoHistoryOptions<TAction> {
|
|
22
|
+
context: string;
|
|
23
|
+
apply: (action: TAction, direction: HistoryDirection) => void;
|
|
24
|
+
capacity?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Keep one bounded stack for the active surface context while replaying
|
|
29
|
+
* actions through the latest adapter callbacks. A context change starts a
|
|
30
|
+
* fresh baseline synchronously, before any shortcut can reach the new view.
|
|
31
|
+
*/
|
|
32
|
+
export function useUndoHistory<TAction>({
|
|
33
|
+
context,
|
|
34
|
+
apply,
|
|
35
|
+
capacity = 50,
|
|
36
|
+
}: UndoHistoryOptions<TAction>): UndoHistoryApi<TAction> {
|
|
37
|
+
const optionsRef = useRef({ apply, capacity });
|
|
38
|
+
optionsRef.current = { apply, capacity };
|
|
39
|
+
const historyRef = useRef<{
|
|
40
|
+
context: string;
|
|
41
|
+
state: UndoHistoryState<TAction>;
|
|
42
|
+
}>({ context, state: createUndoHistoryState<TAction>() });
|
|
43
|
+
if (historyRef.current.context !== context) {
|
|
44
|
+
historyRef.current = { context, state: createUndoHistoryState<TAction>() };
|
|
45
|
+
}
|
|
46
|
+
const apiRef = useRef<UndoHistoryApi<TAction> | null>(null);
|
|
47
|
+
|
|
48
|
+
if (!apiRef.current) {
|
|
49
|
+
apiRef.current = {
|
|
50
|
+
get canUndo() {
|
|
51
|
+
return historyRef.current.state.past.length > 0;
|
|
52
|
+
},
|
|
53
|
+
get canRedo() {
|
|
54
|
+
return historyRef.current.state.future.length > 0;
|
|
55
|
+
},
|
|
56
|
+
record(action) {
|
|
57
|
+
const history = historyRef.current;
|
|
58
|
+
history.state = recordUndoAction(history.state, action, optionsRef.current.capacity);
|
|
59
|
+
},
|
|
60
|
+
undo() {
|
|
61
|
+
const history = historyRef.current;
|
|
62
|
+
const step = takeUndoAction(history.state);
|
|
63
|
+
if (step.action === null) return false;
|
|
64
|
+
history.state = step.state;
|
|
65
|
+
optionsRef.current.apply(step.action, 'undo');
|
|
66
|
+
return true;
|
|
67
|
+
},
|
|
68
|
+
redo() {
|
|
69
|
+
const history = historyRef.current;
|
|
70
|
+
const step = takeRedoAction(history.state, optionsRef.current.capacity);
|
|
71
|
+
if (step.action === null) return false;
|
|
72
|
+
history.state = step.state;
|
|
73
|
+
optionsRef.current.apply(step.action, 'redo');
|
|
74
|
+
return true;
|
|
75
|
+
},
|
|
76
|
+
clear() {
|
|
77
|
+
historyRef.current.state = createUndoHistoryState<TAction>();
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return apiRef.current;
|
|
83
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plannotator/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./components/*": "./components/*.tsx",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"@lezer/highlight": "^1.2.3",
|
|
75
75
|
"@pierre/diffs": "1.3.2",
|
|
76
76
|
"@plannotator/atomic-editor": "^0.8.0",
|
|
77
|
-
"@plannotator/core": "
|
|
77
|
+
"@plannotator/core": "workspace:*",
|
|
78
78
|
"@plannotator/markdown-editor": "^0.4.0",
|
|
79
79
|
"@plannotator/web-highlighter": "^0.8.1",
|
|
80
80
|
"@tanstack/react-table": "^8.21.3",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineShortcutScope } from './core';
|
|
2
|
+
import { createShortcutScopeHook } from './runtime';
|
|
3
|
+
|
|
4
|
+
export const historyShortcuts = defineShortcutScope({
|
|
5
|
+
id: 'history',
|
|
6
|
+
title: 'History',
|
|
7
|
+
shortcuts: {
|
|
8
|
+
undo: {
|
|
9
|
+
description: 'Undo annotation change',
|
|
10
|
+
bindings: ['Mod+Z'],
|
|
11
|
+
section: 'History',
|
|
12
|
+
preventDefault: true,
|
|
13
|
+
displayOrder: 10,
|
|
14
|
+
},
|
|
15
|
+
redo: {
|
|
16
|
+
description: 'Redo annotation change',
|
|
17
|
+
bindings: ['Mod+Shift+Z', 'Mod+Y'],
|
|
18
|
+
section: 'History',
|
|
19
|
+
preventDefault: true,
|
|
20
|
+
displayOrder: 20,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const useHistoryShortcuts = createShortcutScopeHook(historyShortcuts);
|
package/shortcuts/index.ts
CHANGED
|
@@ -27,14 +27,23 @@ export const imageAnnotatorShortcuts = defineShortcutScope({
|
|
|
27
27
|
description: 'Undo',
|
|
28
28
|
bindings: ['Mod+Z'],
|
|
29
29
|
section: 'Image Annotator',
|
|
30
|
+
preventDefault: true,
|
|
30
31
|
displayOrder: 40,
|
|
31
32
|
},
|
|
33
|
+
redo: {
|
|
34
|
+
description: 'Redo',
|
|
35
|
+
bindings: ['Mod+Shift+Z', 'Mod+Y'],
|
|
36
|
+
section: 'Image Annotator',
|
|
37
|
+
preventDefault: true,
|
|
38
|
+
displayOrder: 50,
|
|
39
|
+
},
|
|
32
40
|
save: {
|
|
33
41
|
description: 'Save and close annotator',
|
|
34
42
|
bindings: ['Enter', 'Escape'],
|
|
35
43
|
section: 'Image Annotator',
|
|
44
|
+
preventDefault: true,
|
|
36
45
|
hint: 'When the image name field is focused, Escape blurs it first and Enter confirms the name; both close the annotator otherwise.',
|
|
37
|
-
displayOrder:
|
|
46
|
+
displayOrder: 60,
|
|
38
47
|
},
|
|
39
48
|
},
|
|
40
49
|
});
|
package/utils/math.ts
CHANGED
|
@@ -48,6 +48,12 @@ let rendererSource: MathRendererSource | null = null;
|
|
|
48
48
|
*/
|
|
49
49
|
let loader: MathRendererLoader | null = null;
|
|
50
50
|
let pending: Promise<MathRenderer> | null = null;
|
|
51
|
+
/**
|
|
52
|
+
* Bumped by `resetMathRenderer()`. A load in flight across a reset must not
|
|
53
|
+
* fill the slot when it lands: the reset promised an empty slot, and the next
|
|
54
|
+
* `loadMathRenderer()` re-invokes the registered loader instead.
|
|
55
|
+
*/
|
|
56
|
+
let resetEpoch = 0;
|
|
51
57
|
const listeners = new Set<() => void>();
|
|
52
58
|
|
|
53
59
|
function notify(): void {
|
|
@@ -84,14 +90,22 @@ export function subscribeMathRenderer(listener: () => void): () => void {
|
|
|
84
90
|
* Swap the loader `loadMathRenderer()` uses. Host seam
|
|
85
91
|
* (`configurePlannotatorUI({ mathRendererLoader })`): a host may return a
|
|
86
92
|
* module that imports katex AND its stylesheet in one chunk. A load already in
|
|
87
|
-
* flight keeps going
|
|
88
|
-
*
|
|
93
|
+
* flight keeps going and still fills the slot when it lands (the component
|
|
94
|
+
* that started it is waiting on that result and would otherwise never
|
|
95
|
+
* typeset); the new loader is used from the next `loadMathRenderer()` call
|
|
96
|
+
* that finds the slot empty. Passing `null` unregisters the host loader and
|
|
97
|
+
* restores the package default.
|
|
89
98
|
*/
|
|
90
|
-
export function setMathRendererLoader(next: MathRendererLoader): void {
|
|
99
|
+
export function setMathRendererLoader(next: MathRendererLoader | null): void {
|
|
91
100
|
loader = next;
|
|
92
101
|
pending = null;
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
/** The registered host loader, or `null` while the package default applies. */
|
|
105
|
+
export function getMathRendererLoader(): MathRendererLoader | null {
|
|
106
|
+
return loader;
|
|
107
|
+
}
|
|
108
|
+
|
|
95
109
|
/**
|
|
96
110
|
* Load and register the renderer. Idempotent: a filled slot resolves at once,
|
|
97
111
|
* a load in flight is shared, and a rejected load is dropped so the next call
|
|
@@ -100,9 +114,12 @@ export function setMathRendererLoader(next: MathRendererLoader): void {
|
|
|
100
114
|
export function loadMathRenderer(): Promise<MathRenderer> {
|
|
101
115
|
if (renderer) return Promise.resolve(renderer);
|
|
102
116
|
if (!pending) {
|
|
117
|
+
const epoch = resetEpoch;
|
|
103
118
|
const attempt = (loader ? loader() : loadDefaultMathRenderer()).then(
|
|
104
119
|
(loaded) => {
|
|
105
|
-
|
|
120
|
+
// A reset since this load started wants the slot empty: hand the
|
|
121
|
+
// result to the caller that awaited it, but do not register it.
|
|
122
|
+
if (epoch === resetEpoch) setMathRenderer(loaded, 'loader');
|
|
106
123
|
return loaded;
|
|
107
124
|
},
|
|
108
125
|
(err: unknown) => {
|
|
@@ -115,12 +132,19 @@ export function loadMathRenderer(): Promise<MathRenderer> {
|
|
|
115
132
|
return pending;
|
|
116
133
|
}
|
|
117
134
|
|
|
118
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* Test hook: empty the slot (renderer and source) and forget any load in
|
|
137
|
+
* flight, so the next `loadMathRenderer()` invokes the loader afresh and a
|
|
138
|
+
* stale in-flight result cannot fill the slot after the reset. The registered
|
|
139
|
+
* loader is KEPT: resetting the renderer is not unregistering the host seam
|
|
140
|
+
* (a host's `configurePlannotatorUI` runs once, before any reset a test issues
|
|
141
|
+
* later). To drop the loader too, call `setMathRendererLoader(null)`.
|
|
142
|
+
*/
|
|
119
143
|
export function resetMathRenderer(): void {
|
|
120
144
|
renderer = null;
|
|
121
145
|
rendererSource = null;
|
|
122
|
-
loader = null;
|
|
123
146
|
pending = null;
|
|
147
|
+
resetEpoch += 1;
|
|
124
148
|
notify();
|
|
125
149
|
}
|
|
126
150
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid's KaTeX, served from the math renderer slot.
|
|
3
|
+
*
|
|
4
|
+
* Mermaid renders `$$...$$` labels through its own `import("katex")`
|
|
5
|
+
* (`renderKatexUnsanitized` in the runtime; there is no config flag and no
|
|
6
|
+
* hook for it, and chunk emission is static), so a host that registers a
|
|
7
|
+
* `mathRendererLoader` and aliases `./math-default-loader` away still gets a
|
|
8
|
+
* shared `katex-*.js` chunk out of the Mermaid runtime, and a math document
|
|
9
|
+
* fetches two files: the host's loader chunk plus that shared chunk.
|
|
10
|
+
*
|
|
11
|
+
* This module is the alias target that removes it. A host redirects the
|
|
12
|
+
* `katex` specifier, for importers inside the `mermaid` package ONLY, to
|
|
13
|
+
* `@plannotator/ui/utils/mermaid-math-slot` (HANDOFF.md "Lazy renderers and
|
|
14
|
+
* eager entries", item 2). Its default export has the one method Mermaid
|
|
15
|
+
* calls, `renderToString`, and delegates to whatever renderer fills the slot
|
|
16
|
+
* in `./math`: the host's loader result, or the eager KaTeX registration.
|
|
17
|
+
* Mermaid's own options (`throwOnError: true`, `displayMode: true`, the
|
|
18
|
+
* MathML `output` mode) are passed through untouched, so a KaTeX renderer
|
|
19
|
+
* produces exactly the markup Mermaid produced from its direct import.
|
|
20
|
+
*
|
|
21
|
+
* The slot must be filled by the time Mermaid asks: `MermaidBlock` awaits
|
|
22
|
+
* `loadMathRenderer()` before rendering a diagram whose source carries a
|
|
23
|
+
* `$$` label (`hasMermaidMath`), which is a no-op resolve on a filled slot
|
|
24
|
+
* and the host's loader otherwise. An empty slot here means that load
|
|
25
|
+
* failed, and the error below surfaces in the block's error panel with the
|
|
26
|
+
* source, instead of a silently unlabeled node.
|
|
27
|
+
*
|
|
28
|
+
* Nothing imports this module by default: Plannotator never aliases, so its
|
|
29
|
+
* Mermaid keeps its direct KaTeX (inlined by the single-file builds), and
|
|
30
|
+
* this file must never import `katex` itself, or the redirect would re-create
|
|
31
|
+
* the chunk it exists to remove (`tests/entry-assets.test.ts` pins that).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { getMathRenderer, type MathRenderer } from './math';
|
|
35
|
+
|
|
36
|
+
/** Mermaid's own test for a math label (`katexRegex` in the runtime). */
|
|
37
|
+
const MERMAID_MATH_REGEX = /\$\$(.*)\$\$/;
|
|
38
|
+
|
|
39
|
+
/** True when a diagram source carries at least one `$$...$$` label. */
|
|
40
|
+
export function hasMermaidMath(source: string): boolean {
|
|
41
|
+
return MERMAID_MATH_REGEX.test(source);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Thrown when Mermaid asks for KaTeX while the math slot is still empty. */
|
|
45
|
+
export const MERMAID_MATH_SLOT_EMPTY_MESSAGE =
|
|
46
|
+
'Math label: no math renderer is registered (the mathRendererLoader did not resolve before the diagram rendered)';
|
|
47
|
+
|
|
48
|
+
const slotRenderer: MathRenderer = {
|
|
49
|
+
renderToString(tex, options) {
|
|
50
|
+
const renderer = getMathRenderer();
|
|
51
|
+
if (!renderer) throw new Error(MERMAID_MATH_SLOT_EMPTY_MESSAGE);
|
|
52
|
+
return renderer.renderToString(tex, options);
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export default slotRenderer;
|
package/utils/parser.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Block, Annotation, CodeAnnotation, EditorAnnotation, ImageAttachment } from '../types';
|
|
2
2
|
import { planDenyFeedback } from '@plannotator/core/feedback-templates';
|
|
3
|
+
import { resolveReplyParents } from '@plannotator/core/annotation-threads';
|
|
3
4
|
import { skillReferenceExportBlock } from './skillReferences';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -1210,34 +1211,60 @@ export const exportAnnotations = (
|
|
|
1210
1211
|
|
|
1211
1212
|
// Threaded replies (`inReplyTo`): a reply is emitted as a nested exchange
|
|
1212
1213
|
// under its parent's entry rather than as its own numbered entry, so the
|
|
1213
|
-
// coding agent reads the conversation in order.
|
|
1214
|
-
//
|
|
1214
|
+
// coding agent reads the conversation in order. The threading rule is the
|
|
1215
|
+
// shared one (resolveReplyParents): a reply whose parent is not in the
|
|
1216
|
+
// export, a self-reference, and every member of an inReplyTo cycle render
|
|
1217
|
+
// as ordinary entries in original order, so no annotation is ever dropped
|
|
1218
|
+
// and the header count always equals what is emitted. With no `inReplyTo`
|
|
1215
1219
|
// anywhere the output is byte-identical to the ungrouped export.
|
|
1216
|
-
const
|
|
1217
|
-
const isReply = (a: any) =>
|
|
1220
|
+
const replyParents = resolveReplyParents(sortedAnns as any[]);
|
|
1221
|
+
const isReply = (a: any) => replyParents.get(a.id) != null;
|
|
1218
1222
|
const hasReplies = sortedAnns.some(isReply);
|
|
1219
|
-
|
|
1220
|
-
|
|
1223
|
+
// Children are grouped once (creation order within a parent); the old
|
|
1224
|
+
// per-level re-filter and re-sort of the whole list made a long thread
|
|
1225
|
+
// quadratic in both time and output size.
|
|
1226
|
+
const repliesByParent = new Map<string, any[]>();
|
|
1221
1227
|
if (hasReplies) {
|
|
1228
|
+
for (const a of sortedAnns as any[]) {
|
|
1229
|
+
const parent = replyParents.get(a.id);
|
|
1230
|
+
if (!parent) continue;
|
|
1231
|
+
const list = repliesByParent.get(parent) ?? [];
|
|
1232
|
+
list.push(a);
|
|
1233
|
+
repliesByParent.set(parent, list);
|
|
1234
|
+
}
|
|
1235
|
+
for (const list of repliesByParent.values()) list.sort((a: any, b: any) => a.createdA - b.createdA);
|
|
1222
1236
|
emitOrder = emitOrder.filter((a) => !isReply(a));
|
|
1223
1237
|
// Numbers stay consecutive over the entries that are actually emitted.
|
|
1224
1238
|
annotationNumbers.clear();
|
|
1225
1239
|
emitOrder.forEach((ann, index) => annotationNumbers.set(ann, index + 1));
|
|
1226
1240
|
}
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1241
|
+
// Nesting indent is capped so the export stays linear in the thread size
|
|
1242
|
+
// (an uncapped indent on a 5,000-deep chain is 25 MB of whitespace) and
|
|
1243
|
+
// the emission is an explicit stack rather than recursion, so a deep chain
|
|
1244
|
+
// costs neither stack frames nor repeated string copies.
|
|
1245
|
+
const MAX_REPLY_INDENT_DEPTH = 8;
|
|
1246
|
+
const replyBlock = (parent: any): string => {
|
|
1247
|
+
const parts: string[] = [];
|
|
1248
|
+
const stack: Array<{ reply: any; depth: number }> = [];
|
|
1249
|
+
const pushReplies = (of: any, depth: number) => {
|
|
1250
|
+
const replies = repliesByParent.get(of.id);
|
|
1251
|
+
if (!replies) return;
|
|
1252
|
+
for (let i = replies.length - 1; i >= 0; i--) stack.push({ reply: replies[i], depth });
|
|
1253
|
+
};
|
|
1254
|
+
pushReplies(parent, 0);
|
|
1255
|
+
while (stack.length > 0) {
|
|
1256
|
+
const { reply, depth } = stack.pop()!;
|
|
1230
1257
|
const who = reply.author ? `${reply.author}` : 'reply';
|
|
1231
|
-
const indent = ' '.repeat(depth);
|
|
1232
|
-
|
|
1258
|
+
const indent = ' '.repeat(Math.min(depth, MAX_REPLY_INDENT_DEPTH));
|
|
1259
|
+
parts.push(`${indent}- **Reply (${who}):** ${String(reply.text ?? '').replace(/\r?\n/g, `\n${indent} `)}\n`);
|
|
1233
1260
|
if (reply.images && reply.images.length > 0) {
|
|
1234
1261
|
reply.images.forEach((img: ImageAttachment) => {
|
|
1235
|
-
|
|
1262
|
+
parts.push(`${indent} - [${img.name}] \`${img.path}\`\n`);
|
|
1236
1263
|
});
|
|
1237
1264
|
}
|
|
1238
|
-
|
|
1265
|
+
pushReplies(reply, depth + 1);
|
|
1239
1266
|
}
|
|
1240
|
-
return
|
|
1267
|
+
return parts.join('');
|
|
1241
1268
|
};
|
|
1242
1269
|
|
|
1243
1270
|
let lastEmittedPage: string | null = null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A shared retry epoch for lazily loaded diagram runtimes.
|
|
3
|
+
*
|
|
4
|
+
* Mermaid and Graphviz memoize one runtime per module, so when a chunk
|
|
5
|
+
* import fails every block on the page fails together. Each block's Retry
|
|
6
|
+
* button used to bump only that block's own token, leaving its siblings on
|
|
7
|
+
* their error panels after the shared runtime had recovered. Bumping the
|
|
8
|
+
* epoch notifies every subscribed block, so one Retry re-attempts all of
|
|
9
|
+
* them (the memoized loader still issues a single import for the batch).
|
|
10
|
+
*/
|
|
11
|
+
export interface RuntimeRetryEpoch {
|
|
12
|
+
/** Ask every subscriber to re-attempt. */
|
|
13
|
+
bump(): void;
|
|
14
|
+
/** Subscribe; returns the unsubscribe. */
|
|
15
|
+
subscribe(listener: () => void): () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createRuntimeRetryEpoch(): RuntimeRetryEpoch {
|
|
19
|
+
const listeners = new Set<() => void>();
|
|
20
|
+
return {
|
|
21
|
+
bump() {
|
|
22
|
+
for (const listener of [...listeners]) listener();
|
|
23
|
+
},
|
|
24
|
+
subscribe(listener) {
|
|
25
|
+
listeners.add(listener);
|
|
26
|
+
return () => {
|
|
27
|
+
listeners.delete(listener);
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|