@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.
@@ -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
- | { status: 'ok'; rawHtml: string }
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
- onSnapshot: (rawHtml: string) => void;
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
@@ -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
- setShortShareUrl(window.location.href);
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
- // Clear stale short URL when content changes (does NOT auto-regenerate —
298
- // the user must explicitly click "Create short link" again).
299
- // Skip on shared session load — the incoming short URL must survive.
300
- const isSharedRef = useRef(false);
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
- if (isSharedSession) { isSharedRef.current = true; return; }
303
- if (isSharedRef.current) { isSharedRef.current = false; return; }
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
- }, [markdown, annotations, globalAttachments, rawHtml, isSharedSession, contentRevision]);
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.33.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": "0.25.0",
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);
@@ -1,5 +1,6 @@
1
1
  export * from './core';
2
2
  export * from './runtime';
3
+ export { historyShortcuts, useHistoryShortcuts } from './history.shortcuts';
3
4
 
4
5
  // plan-review scopes
5
6
  export { annotationModeShortcuts, useAnnotationModeShortcuts } from './plan-review/annotationMode.shortcuts';
@@ -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: 50,
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; the new loader is used from the next `loadMathRenderer()`
88
- * call that finds the slot empty.
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
- setMathRenderer(loaded, 'loader');
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
- /** Test hook: clear the slot, the loader override and any pending load. */
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. Replies whose parent is
1214
- // not in the export render as ordinary entries. With no `inReplyTo`
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 exportedIds = new Set(sortedAnns.map((a: any) => a.id));
1217
- const isReply = (a: any) => typeof a.inReplyTo === 'string' && a.inReplyTo !== a.id && exportedIds.has(a.inReplyTo);
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
- const repliesOf = (parent: any): any[] =>
1220
- hasReplies ? sortedAnns.filter((a: any) => isReply(a) && a.inReplyTo === parent.id).sort((a: any, b: any) => a.createdA - b.createdA) : [];
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
- const replyBlock = (parent: any, depth = 0): string => {
1228
- let block = '';
1229
- for (const reply of repliesOf(parent)) {
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
- block += `${indent}- **Reply (${who}):** ${String(reply.text ?? '').replace(/\r?\n/g, `\n${indent} `)}\n`;
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
- block += `${indent} - [${img.name}] \`${img.path}\`\n`;
1262
+ parts.push(`${indent} - [${img.name}] \`${img.path}\`\n`);
1236
1263
  });
1237
1264
  }
1238
- block += replyBlock(reply, depth + 1);
1265
+ pushReplies(reply, depth + 1);
1239
1266
  }
1240
- return block;
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
+ }