@plannotator/ui 0.39.0 → 0.40.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 +99 -10
- package/README.md +3 -3
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +39 -5
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +25 -1
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +96 -9
- package/components/html-viewer/bridge-script.ts +96 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +464 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +3 -3
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +21 -6
- package/utils/terminalToolsAnnouncement.ts +76 -0
|
@@ -1,38 +1,135 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Header controls for a raw-HTML or live-app annotation surface: the
|
|
3
|
-
* (show/hide the floating tools over
|
|
4
|
-
* the pen (Annotate/Interact toggle).
|
|
5
|
-
* in the host. Each control renders only
|
|
6
|
-
* read-only document can show the eye
|
|
2
|
+
* Header controls for a raw-HTML or live-app annotation surface: the optional
|
|
3
|
+
* back (leave a linked document), the eye (show/hide the floating tools over
|
|
4
|
+
* the page), the optional refresh, and the pen (Annotate/Interact toggle).
|
|
5
|
+
* Presentation only; every state lives in the host. Each control renders only
|
|
6
|
+
* when its handler is passed, so a read-only document can show the eye
|
|
7
|
+
* without a pen.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
9
|
+
* Back exists because the sidebar's "Viewing / Back to …" header is not a
|
|
10
|
+
* dependable way out of a raw-HTML linked document: an HTML surface opens
|
|
11
|
+
* with the sidebar closed and navigating between HTML documents deliberately
|
|
12
|
+
* leaves it closed, so the way back has to live in the header. It carries no
|
|
13
|
+
* keyboard shortcut — `Alt`+`Left` and the browser's own Back are the user's,
|
|
14
|
+
* not ours.
|
|
15
|
+
*
|
|
16
|
+
* The markup, data attributes (`data-html-back`, `data-html-tools-toggle`,
|
|
17
|
+
* `data-html-refresh`, `data-html-annotate-toggle`), aria state and the pixel-stable pen border
|
|
10
18
|
* are the exact ones Plannotator's header shipped with; hosts get the same
|
|
11
19
|
* control, and `labels` overrides the strings without touching the DOM.
|
|
20
|
+
*
|
|
21
|
+
* Each control's description rides the app's `Tooltip` (hover AND
|
|
22
|
+
* focus-visible, theme tokens, portal) instead of a native `title`, with the
|
|
23
|
+
* control's keyboard shortcut rendered under it as keycaps through the
|
|
24
|
+
* shortcut registry's platform-aware formatter — never a hardcoded "Cmd".
|
|
25
|
+
* The strings are the same `labels` values the titles used, so a host's
|
|
26
|
+
* overrides keep working; because `title` no longer supplies the accessible
|
|
27
|
+
* name, each button carries it explicitly (the pen an `aria-label`, the eye
|
|
28
|
+
* its sr-only text, the refresh its existing `aria-label`), and the shortcut
|
|
29
|
+
* is also attached as a persistent `aria-describedby` so it is announced
|
|
30
|
+
* rather than only drawn.
|
|
12
31
|
*/
|
|
32
|
+
import React from 'react';
|
|
33
|
+
import { Tooltip } from './Tooltip';
|
|
34
|
+
import { formatShortcutBindingText, formatShortcutBindingTokens } from '../shortcuts/core';
|
|
35
|
+
import { htmlAnnotateShortcuts } from '../shortcuts/plan-review/htmlAnnotate.shortcuts';
|
|
36
|
+
|
|
37
|
+
/** Binding overrides, in the registry's normalized syntax (`Mod+Shift+X`).
|
|
38
|
+
* `null` renders no shortcut row for that control. Defaults come from the
|
|
39
|
+
* `html-annotate` scope, so the tooltips can never drift from the chords the
|
|
40
|
+
* app actually dispatches; `refresh` has no chord and defaults to none. */
|
|
41
|
+
export interface HtmlSurfaceControlShortcuts {
|
|
42
|
+
annotate?: string | null;
|
|
43
|
+
tools?: string | null;
|
|
44
|
+
refresh?: string | null;
|
|
45
|
+
back?: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DEFAULT_HTML_SURFACE_CONTROL_SHORTCUTS: Required<HtmlSurfaceControlShortcuts> = {
|
|
49
|
+
annotate: htmlAnnotateShortcuts.shortcuts.toggleAnnotateMode.bindings[0] ?? null,
|
|
50
|
+
tools: htmlAnnotateShortcuts.shortcuts.toggleTools.bindings[0] ?? null,
|
|
51
|
+
refresh: null,
|
|
52
|
+
// Deliberately none: Alt+Left and the browser's Back belong to the user.
|
|
53
|
+
back: null,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Description + keycaps. Two lines, so the tooltip answers both "what does
|
|
57
|
+
* this do" and "what do I press" without a second hover. */
|
|
58
|
+
function ControlTooltip({
|
|
59
|
+
description,
|
|
60
|
+
binding,
|
|
61
|
+
children,
|
|
62
|
+
}: {
|
|
63
|
+
description: string;
|
|
64
|
+
binding: string | null;
|
|
65
|
+
children: React.ReactElement;
|
|
66
|
+
}) {
|
|
67
|
+
const keys = binding ? formatShortcutBindingTokens(binding) : null;
|
|
68
|
+
return (
|
|
69
|
+
<Tooltip
|
|
70
|
+
side="bottom"
|
|
71
|
+
wide
|
|
72
|
+
content={(
|
|
73
|
+
<span className="flex flex-col gap-1">
|
|
74
|
+
<span>{description}</span>
|
|
75
|
+
{keys && keys.length > 0 && (
|
|
76
|
+
<span className="flex items-center gap-1">
|
|
77
|
+
{keys.map((key, index) => (
|
|
78
|
+
<kbd
|
|
79
|
+
key={`${key}-${index}`}
|
|
80
|
+
className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded border border-border/60 bg-muted px-1 font-mono text-[10px] leading-none text-foreground/80"
|
|
81
|
+
>
|
|
82
|
+
{key}
|
|
83
|
+
</kbd>
|
|
84
|
+
))}
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
{children}
|
|
91
|
+
</Tooltip>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The shortcut as prose for assistive tech ("Cmd+Shift+X"), which keycaps
|
|
96
|
+
* alone would not announce. */
|
|
97
|
+
function ShortcutDescription({ id, binding }: { id: string; binding: string | null }) {
|
|
98
|
+
if (!binding) return null;
|
|
99
|
+
return (
|
|
100
|
+
<span id={id} hidden>
|
|
101
|
+
{`Shortcut: ${formatShortcutBindingText(binding)}`}
|
|
102
|
+
</span>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
13
105
|
|
|
14
106
|
/** String overrides. Every key optional; defaults are Plannotator's strings. */
|
|
15
107
|
export interface HtmlSurfaceControlLabels {
|
|
16
|
-
/** Pen
|
|
108
|
+
/** Pen tooltip description while Annotate is armed. */
|
|
17
109
|
annotateTitle?: string;
|
|
18
|
-
/** Pen
|
|
110
|
+
/** Pen tooltip description while in Interact mode. */
|
|
19
111
|
interactTitle?: string;
|
|
20
|
-
/** Pen aria-label while armed. Default:
|
|
112
|
+
/** Pen aria-label while armed. Default: the armed description. */
|
|
21
113
|
annotateLabel?: string;
|
|
22
|
-
/** Pen aria-label while in Interact mode. Default:
|
|
114
|
+
/** Pen aria-label while in Interact mode. Default: the interact description. */
|
|
23
115
|
interactLabel?: string;
|
|
24
|
-
/** Eye
|
|
116
|
+
/** Eye tooltip description and screen-reader text while the tools are visible. */
|
|
25
117
|
hideTools?: string;
|
|
26
|
-
/** Eye
|
|
118
|
+
/** Eye tooltip description and screen-reader text while the tools are hidden. */
|
|
27
119
|
showTools?: string;
|
|
28
120
|
/** Refresh visible text while idle. */
|
|
29
121
|
refresh?: string;
|
|
30
122
|
/** Refresh visible text while a refresh is in flight. */
|
|
31
123
|
refreshing?: string;
|
|
32
|
-
/** Refresh
|
|
124
|
+
/** Refresh tooltip description and aria-label while idle. */
|
|
33
125
|
refreshTitle?: string;
|
|
34
|
-
/** Refresh
|
|
126
|
+
/** Refresh tooltip description and aria-label while a refresh is in flight. */
|
|
35
127
|
refreshingTitle?: string;
|
|
128
|
+
/** Back visible text. */
|
|
129
|
+
back?: string;
|
|
130
|
+
/** Back tooltip description and aria-label. `backDescription` overrides it
|
|
131
|
+
* per render, which is how a host names the document Back returns to. */
|
|
132
|
+
backTitle?: string;
|
|
36
133
|
}
|
|
37
134
|
|
|
38
135
|
export const DEFAULT_HTML_SURFACE_CONTROL_LABELS: Required<
|
|
@@ -46,8 +143,16 @@ export const DEFAULT_HTML_SURFACE_CONTROL_LABELS: Required<
|
|
|
46
143
|
refreshing: 'Refreshing',
|
|
47
144
|
refreshTitle: 'Refresh document',
|
|
48
145
|
refreshingTitle: 'Refreshing document',
|
|
146
|
+
back: 'Back',
|
|
147
|
+
backTitle: 'Back to the document this one was opened from',
|
|
49
148
|
};
|
|
50
149
|
|
|
150
|
+
// Stable ids: one HTML surface renders at most one of each control.
|
|
151
|
+
const ANNOTATE_SHORTCUT_ID = 'pn-html-annotate-shortcut';
|
|
152
|
+
const TOOLS_SHORTCUT_ID = 'pn-html-tools-shortcut';
|
|
153
|
+
const REFRESH_SHORTCUT_ID = 'pn-html-refresh-shortcut';
|
|
154
|
+
const BACK_SHORTCUT_ID = 'pn-html-back-shortcut';
|
|
155
|
+
|
|
51
156
|
export interface HtmlSurfaceControlsProps {
|
|
52
157
|
/** Whether Annotate is armed (pen pressed). */
|
|
53
158
|
armed: boolean;
|
|
@@ -62,9 +167,17 @@ export interface HtmlSurfaceControlsProps {
|
|
|
62
167
|
canRefresh?: boolean;
|
|
63
168
|
onRefresh?: () => void;
|
|
64
169
|
isRefreshing?: boolean;
|
|
170
|
+
/** Leave the linked document for the one the session opened from. The back
|
|
171
|
+
* control renders only when provided — i.e. while a linked document is open. */
|
|
172
|
+
onBack?: () => void;
|
|
173
|
+
/** Tooltip description and aria-label for back, e.g. "Back to index.html".
|
|
174
|
+
* Default: `labels.backTitle`. */
|
|
175
|
+
backDescription?: string;
|
|
65
176
|
/** Compact touch shells put these actions in a menu instead: render nothing. */
|
|
66
177
|
compact?: boolean;
|
|
67
178
|
labels?: HtmlSurfaceControlLabels;
|
|
179
|
+
/** Per-control keyboard shortcuts shown in the tooltips. */
|
|
180
|
+
shortcuts?: HtmlSurfaceControlShortcuts;
|
|
68
181
|
}
|
|
69
182
|
|
|
70
183
|
export function HtmlSurfaceControls({
|
|
@@ -75,16 +188,59 @@ export function HtmlSurfaceControls({
|
|
|
75
188
|
canRefresh = false,
|
|
76
189
|
onRefresh,
|
|
77
190
|
isRefreshing = false,
|
|
191
|
+
onBack,
|
|
192
|
+
backDescription,
|
|
78
193
|
compact = false,
|
|
79
194
|
labels,
|
|
195
|
+
shortcuts,
|
|
80
196
|
}: HtmlSurfaceControlsProps) {
|
|
81
197
|
if (compact) return null;
|
|
82
198
|
const text = { ...DEFAULT_HTML_SURFACE_CONTROL_LABELS, ...labels };
|
|
83
|
-
const
|
|
199
|
+
const keys = { ...DEFAULT_HTML_SURFACE_CONTROL_SHORTCUTS, ...shortcuts };
|
|
200
|
+
const penDescription = armed ? text.annotateTitle : text.interactTitle;
|
|
201
|
+
// `title` used to carry the pen's accessible name; with the tooltip in its
|
|
202
|
+
// place the name has to be stated, or the pen becomes an unnamed button.
|
|
203
|
+
const penLabel = (armed ? labels?.annotateLabel : labels?.interactLabel) ?? penDescription;
|
|
204
|
+
const toolsDescription = toolsHidden ? text.showTools : text.hideTools;
|
|
205
|
+
const refreshDescription = isRefreshing ? text.refreshingTitle : text.refreshTitle;
|
|
84
206
|
const showRefresh = canRefresh && !!onRefresh;
|
|
207
|
+
const backLabel = backDescription ?? text.backTitle;
|
|
85
208
|
return (
|
|
86
209
|
<>
|
|
87
|
-
{/*
|
|
210
|
+
{/* Back, leftmost: the way out of a linked document that does not
|
|
211
|
+
depend on the sidebar being open. */}
|
|
212
|
+
{onBack && (
|
|
213
|
+
<>
|
|
214
|
+
<ControlTooltip description={backLabel} binding={keys.back}>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
data-html-back
|
|
218
|
+
onClick={onBack}
|
|
219
|
+
{...(keys.back ? { 'aria-describedby': BACK_SHORTCUT_ID } : {})}
|
|
220
|
+
className="ml-1 flex items-center gap-1 rounded px-1.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
|
|
221
|
+
aria-label={backLabel}
|
|
222
|
+
>
|
|
223
|
+
<svg
|
|
224
|
+
aria-hidden="true"
|
|
225
|
+
viewBox="0 0 24 24"
|
|
226
|
+
fill="none"
|
|
227
|
+
stroke="currentColor"
|
|
228
|
+
strokeWidth="2"
|
|
229
|
+
strokeLinecap="round"
|
|
230
|
+
strokeLinejoin="round"
|
|
231
|
+
className="h-3.5 w-3.5"
|
|
232
|
+
>
|
|
233
|
+
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
234
|
+
</svg>
|
|
235
|
+
<span className="hidden sm:inline">{text.back}</span>
|
|
236
|
+
</button>
|
|
237
|
+
</ControlTooltip>
|
|
238
|
+
<ShortcutDescription id={BACK_SHORTCUT_ID} binding={keys.back} />
|
|
239
|
+
</>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{/* The refresh and the eye share one group, left of the pen and right
|
|
243
|
+
of back. Each
|
|
88
244
|
renders on its own terms: the refresh whenever it is offered
|
|
89
245
|
(canRefresh + onRefresh), the eye whenever onToggleTools is passed,
|
|
90
246
|
so a host without the tools toggle still gets its refresh.
|
|
@@ -96,6 +252,7 @@ export function HtmlSurfaceControls({
|
|
|
96
252
|
{(showRefresh || onToggleTools) && (
|
|
97
253
|
<div className="ml-1 flex items-center gap-0.5">
|
|
98
254
|
{showRefresh && (
|
|
255
|
+
<ControlTooltip description={refreshDescription} binding={keys.refresh}>
|
|
99
256
|
<button
|
|
100
257
|
type="button"
|
|
101
258
|
data-html-refresh
|
|
@@ -104,9 +261,9 @@ export function HtmlSurfaceControls({
|
|
|
104
261
|
// dedups in-flight requests, so an extra click is harmless.
|
|
105
262
|
onClick={isRefreshing ? undefined : onRefresh}
|
|
106
263
|
aria-disabled={isRefreshing}
|
|
264
|
+
{...(keys.refresh ? { 'aria-describedby': REFRESH_SHORTCUT_ID } : {})}
|
|
107
265
|
className="flex items-center gap-1.5 rounded px-1.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground aria-disabled:cursor-wait aria-disabled:opacity-70"
|
|
108
|
-
|
|
109
|
-
aria-label={isRefreshing ? text.refreshingTitle : text.refreshTitle}
|
|
266
|
+
aria-label={refreshDescription}
|
|
110
267
|
>
|
|
111
268
|
<svg
|
|
112
269
|
aria-hidden="true"
|
|
@@ -123,15 +280,18 @@ export function HtmlSurfaceControls({
|
|
|
123
280
|
</svg>
|
|
124
281
|
<span className="hidden sm:inline">{isRefreshing ? text.refreshing : text.refresh}</span>
|
|
125
282
|
</button>
|
|
283
|
+
</ControlTooltip>
|
|
126
284
|
)}
|
|
285
|
+
{showRefresh && <ShortcutDescription id={REFRESH_SHORTCUT_ID} binding={keys.refresh} />}
|
|
127
286
|
{onToggleTools && (
|
|
287
|
+
<ControlTooltip description={toolsDescription} binding={keys.tools}>
|
|
128
288
|
<button
|
|
129
289
|
type="button"
|
|
130
290
|
data-html-tools-toggle
|
|
131
291
|
onClick={onToggleTools}
|
|
132
292
|
aria-pressed={toolsHidden}
|
|
293
|
+
{...(keys.tools ? { 'aria-describedby': TOOLS_SHORTCUT_ID } : {})}
|
|
133
294
|
className="cursor-pointer rounded-md border border-transparent p-1.5 text-xs font-medium text-muted-foreground transition-all hover:bg-muted hover:text-foreground"
|
|
134
|
-
title={toolsHidden ? text.showTools : text.hideTools}
|
|
135
295
|
>
|
|
136
296
|
{toolsHidden ? (
|
|
137
297
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
@@ -143,9 +303,11 @@ export function HtmlSurfaceControls({
|
|
|
143
303
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
|
144
304
|
</svg>
|
|
145
305
|
)}
|
|
146
|
-
<span className="sr-only">{
|
|
306
|
+
<span className="sr-only">{toolsDescription}</span>
|
|
147
307
|
</button>
|
|
308
|
+
</ControlTooltip>
|
|
148
309
|
)}
|
|
310
|
+
{onToggleTools && <ShortcutDescription id={TOOLS_SHORTCUT_ID} binding={keys.tools} />}
|
|
149
311
|
</div>
|
|
150
312
|
)}
|
|
151
313
|
|
|
@@ -156,6 +318,7 @@ export function HtmlSurfaceControls({
|
|
|
156
318
|
unarmed is muted with a TRANSPARENT border of the same width, so
|
|
157
319
|
the button's box is pixel-identical in both states. */}
|
|
158
320
|
{onToggleArmed && (
|
|
321
|
+
<ControlTooltip description={penDescription} binding={keys.annotate}>
|
|
159
322
|
<button
|
|
160
323
|
type="button"
|
|
161
324
|
data-html-annotate-toggle
|
|
@@ -166,14 +329,16 @@ export function HtmlSurfaceControls({
|
|
|
166
329
|
? 'border-primary/60 bg-primary/15 text-primary'
|
|
167
330
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
168
331
|
}`}
|
|
169
|
-
|
|
170
|
-
{...(
|
|
332
|
+
aria-label={penLabel}
|
|
333
|
+
{...(keys.annotate ? { 'aria-describedby': ANNOTATE_SHORTCUT_ID } : {})}
|
|
171
334
|
>
|
|
172
335
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
173
336
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.862 4.487zm0 0L19.5 7.125" />
|
|
174
337
|
</svg>
|
|
175
338
|
</button>
|
|
339
|
+
</ControlTooltip>
|
|
176
340
|
)}
|
|
341
|
+
{onToggleArmed && <ShortcutDescription id={ANNOTATE_SHORTCUT_ID} binding={keys.annotate} />}
|
|
177
342
|
</>
|
|
178
343
|
);
|
|
179
344
|
}
|
|
@@ -15,6 +15,15 @@ import React from 'react';
|
|
|
15
15
|
*
|
|
16
16
|
* Interactivity is opt-in: the Viewer passes `interactive` + `onToggle`
|
|
17
17
|
* for click-to-toggle checkboxes; the diff view leaves both undefined.
|
|
18
|
+
*
|
|
19
|
+
* The marker carries `annotation-exclude` because it is `select-none`: the
|
|
20
|
+
* browser leaves the bullet/numeral out of every selection string, so it is
|
|
21
|
+
* out of the quote an annotation stores. Without the class the highlighter
|
|
22
|
+
* still PAINTS it (a selection crossing two list items wraps the second item's
|
|
23
|
+
* bullet on the way), and the restore verification then compares a painted
|
|
24
|
+
* "a•b" against a stored "a\nb", rejects a perfectly good restore, and
|
|
25
|
+
* the annotation comes back from a reload with no highlight at all. Excluding
|
|
26
|
+
* it makes the two agree by construction.
|
|
18
27
|
*/
|
|
19
28
|
interface ListMarkerProps {
|
|
20
29
|
level: number;
|
|
@@ -45,7 +54,7 @@ export const ListMarker: React.FC<ListMarkerProps> = ({
|
|
|
45
54
|
|
|
46
55
|
return (
|
|
47
56
|
<span
|
|
48
|
-
className={`select-none shrink-0 self-start flex items-center gap-1${interactive ? ' cursor-pointer' : ''}`}
|
|
57
|
+
className={`annotation-exclude select-none shrink-0 self-start flex items-center gap-1${interactive ? ' cursor-pointer' : ''}`}
|
|
49
58
|
onClick={handleClick}
|
|
50
59
|
role={interactive ? 'checkbox' : undefined}
|
|
51
60
|
aria-checked={interactive ? checked : undefined}
|
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
} from '../utils/mermaid';
|
|
12
12
|
import { loadMathRenderer } from '../utils/math';
|
|
13
13
|
import { hasMermaidMath } from '../utils/mermaid-math-slot';
|
|
14
|
+
import { applyMermaidTheme, mermaidThemeKey } from '../utils/mermaidTheme';
|
|
14
15
|
import { createRuntimeRetryEpoch } from '../utils/runtimeRetry';
|
|
16
|
+
import { useTheme } from './ThemeProvider';
|
|
15
17
|
|
|
16
18
|
/** One Retry re-attempts every block whose runtime import failed (see utils/runtimeRetry). */
|
|
17
19
|
const mermaidRetryEpoch = createRuntimeRetryEpoch();
|
|
@@ -20,9 +22,12 @@ const mermaidRetryEpoch = createRuntimeRetryEpoch();
|
|
|
20
22
|
export { MERMAID_CONFIG, __setMermaidRuntimeLoaderForTests };
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
|
-
* The runtime comes from the slot in utils/mermaid:
|
|
24
|
-
*
|
|
25
|
-
*
|
|
25
|
+
* The runtime comes from the slot in utils/mermaid: loaded lazily on the
|
|
26
|
+
* first diagram (Plannotator's own path since Mermaid 12), or already filled
|
|
27
|
+
* by a host that imported utils/mermaid-eager. See that module for the retry
|
|
28
|
+
* contract. Until the first render lands the block shows the source fence
|
|
29
|
+
* under a "Rendering diagram" status; the error panel appears only for a
|
|
30
|
+
* failure, never as a placeholder.
|
|
26
31
|
*/
|
|
27
32
|
const getMermaid = loadMermaidRuntime;
|
|
28
33
|
|
|
@@ -139,6 +144,14 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
139
144
|
const [retryToken, setRetryToken] = useState(0);
|
|
140
145
|
const [showSource, setShowSource] = useState(false);
|
|
141
146
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
147
|
+
// The (palette, mode) the diagram must follow: the same resolution the
|
|
148
|
+
// code fences use (see useFenceTheme). Outside a ThemeProvider the default
|
|
149
|
+
// context yields the Plannotator dark pair, and with no theme tokens on the
|
|
150
|
+
// document `applyMermaidTheme` keeps the static config, so a host without
|
|
151
|
+
// the provider renders exactly as before. A key change re-runs the render
|
|
152
|
+
// effect below, which is what re-themes an already rendered diagram.
|
|
153
|
+
const { colorTheme, resolvedMode } = useTheme();
|
|
154
|
+
const themeKey = mermaidThemeKey(colorTheme, resolvedMode === 'light' ? 'light' : 'dark');
|
|
142
155
|
// A sibling's Retry re-attempts this block too, but only while its own
|
|
143
156
|
// failure was the shared runtime import; a healthy block or a diagram
|
|
144
157
|
// syntax error is left alone.
|
|
@@ -238,6 +251,8 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
238
251
|
}
|
|
239
252
|
if (cancelled) return;
|
|
240
253
|
}
|
|
254
|
+
// Global initialize, once per (palette, mode) change, before render.
|
|
255
|
+
applyMermaidTheme(mermaid, themeKey);
|
|
241
256
|
const id = `mermaid-${block.id}`;
|
|
242
257
|
const { svg: renderedSvg } = await mermaid.render(id, block.content);
|
|
243
258
|
if (!cancelled) {
|
|
@@ -261,7 +276,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
261
276
|
return () => {
|
|
262
277
|
cancelled = true;
|
|
263
278
|
};
|
|
264
|
-
}, [block.content, block.id, retryToken]);
|
|
279
|
+
}, [block.content, block.id, retryToken, themeKey]);
|
|
265
280
|
|
|
266
281
|
// Reset zoom and pan when content changes
|
|
267
282
|
useEffect(() => {
|
|
@@ -588,6 +603,25 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
588
603
|
</pre>
|
|
589
604
|
);
|
|
590
605
|
|
|
606
|
+
// First render still in flight (the runtime import on the lazy path, then
|
|
607
|
+
// the render itself): the source stays readable under a quiet status line.
|
|
608
|
+
// A re-render for a theme change keeps the previous SVG, so this shows only
|
|
609
|
+
// before the first diagram lands.
|
|
610
|
+
const pendingSource = (
|
|
611
|
+
<>
|
|
612
|
+
<div
|
|
613
|
+
role="status"
|
|
614
|
+
aria-live="polite"
|
|
615
|
+
data-mermaid-pending=""
|
|
616
|
+
className="mb-1.5 flex items-center gap-1.5 text-xs text-muted-foreground"
|
|
617
|
+
>
|
|
618
|
+
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-muted-foreground/70" aria-hidden="true" />
|
|
619
|
+
Rendering diagram…
|
|
620
|
+
</div>
|
|
621
|
+
{inlineSource}
|
|
622
|
+
</>
|
|
623
|
+
);
|
|
624
|
+
|
|
591
625
|
const diagramBody = (
|
|
592
626
|
<div
|
|
593
627
|
ref={containerRef}
|
|
@@ -605,7 +639,7 @@ const MermaidBlockImpl: React.FC<{ block: Block }> = ({ block }) => {
|
|
|
605
639
|
<>
|
|
606
640
|
<div className="my-5 group relative" data-block-id={block.id}>
|
|
607
641
|
{!isExpanded && controls}
|
|
608
|
-
{showSource
|
|
642
|
+
{showSource ? inlineSource : !svg ? pendingSource : !isExpanded ? diagramBody : <div className="rounded-xl border border-border/30 bg-muted/10 h-[min(65vh,36rem)] min-h-[20rem]" />}
|
|
609
643
|
</div>
|
|
610
644
|
|
|
611
645
|
{!showSource && svg && isExpanded && typeof document !== 'undefined' && createPortal(
|
|
@@ -97,7 +97,11 @@ export function TableOfContents({
|
|
|
97
97
|
[onNavigate, scrollViewport]
|
|
98
98
|
);
|
|
99
99
|
|
|
100
|
-
|
|
100
|
+
// A linked document with no headings of its own — most obviously a raw-HTML
|
|
101
|
+
// one, which is never parsed into blocks — still needs the "Viewing / Back
|
|
102
|
+
// to …" header, which lives here. Without it, opening such a document from
|
|
103
|
+
// a link leaves no visible way back.
|
|
104
|
+
if (tocItems.length === 0 && !linkedDocFilepath) {
|
|
101
105
|
return null;
|
|
102
106
|
}
|
|
103
107
|
|