@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.
@@ -1,38 +1,135 @@
1
1
  /**
2
- * Header controls for a raw-HTML or live-app annotation surface: the eye
3
- * (show/hide the floating tools over the page), the optional refresh, and
4
- * the pen (Annotate/Interact toggle). Presentation only; every state lives
5
- * in the host. Each control renders only when its handler is passed, so a
6
- * read-only document can show the eye without a pen.
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
- * The markup, data attributes (`data-html-tools-toggle`, `data-html-refresh`,
9
- * `data-html-annotate-toggle`), aria state and the pixel-stable pen border
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 title while Annotate is armed. */
108
+ /** Pen tooltip description while Annotate is armed. */
17
109
  annotateTitle?: string;
18
- /** Pen title while in Interact mode. */
110
+ /** Pen tooltip description while in Interact mode. */
19
111
  interactTitle?: string;
20
- /** Pen aria-label while armed. Default: none (the title carries the name). */
112
+ /** Pen aria-label while armed. Default: the armed description. */
21
113
  annotateLabel?: string;
22
- /** Pen aria-label while in Interact mode. Default: none. */
114
+ /** Pen aria-label while in Interact mode. Default: the interact description. */
23
115
  interactLabel?: string;
24
- /** Eye title and screen-reader text while the tools are visible. */
116
+ /** Eye tooltip description and screen-reader text while the tools are visible. */
25
117
  hideTools?: string;
26
- /** Eye title and screen-reader text while the tools are hidden. */
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 title and aria-label while idle. */
124
+ /** Refresh tooltip description and aria-label while idle. */
33
125
  refreshTitle?: string;
34
- /** Refresh title and aria-label while a refresh is in flight. */
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 penLabel = armed ? labels?.annotateLabel : labels?.interactLabel;
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
- {/* The refresh and the eye share one group, left of the pen. Each
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
- title={isRefreshing ? text.refreshingTitle : text.refreshTitle}
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">{toolsHidden ? text.showTools : text.hideTools}</span>
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
- title={armed ? text.annotateTitle : text.interactTitle}
170
- {...(penLabel !== undefined ? { 'aria-label': penLabel } : {})}
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: filled eagerly by
24
- * Plannotator (utils/mermaid-eager, imported by the editor App), loaded
25
- * lazily otherwise. See that module for the retry contract.
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 || !svg ? inlineSource : !isExpanded ? diagramBody : <div className="rounded-xl border border-border/30 bg-muted/10 h-[min(65vh,36rem)] min-h-[20rem]" />}
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
- if (tocItems.length === 0) {
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