@tooee/view 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/components/subviews/diff-subview.d.ts.map +1 -1
  2. package/dist/components/subviews/diff-subview.js +16 -4
  3. package/dist/components/subviews/diff-subview.js.map +1 -1
  4. package/dist/components/subviews/image-subview.d.ts.map +1 -1
  5. package/dist/components/subviews/image-subview.js +2 -1
  6. package/dist/components/subviews/image-subview.js.map +1 -1
  7. package/dist/components/subviews/index.d.ts.map +1 -1
  8. package/dist/components/subviews/index.js.map +1 -1
  9. package/dist/components/subviews/markdown-subview.d.ts +3 -1
  10. package/dist/components/subviews/markdown-subview.d.ts.map +1 -1
  11. package/dist/components/subviews/markdown-subview.js +21 -6
  12. package/dist/components/subviews/markdown-subview.js.map +1 -1
  13. package/dist/components/subviews/table-subview.d.ts.map +1 -1
  14. package/dist/components/subviews/table-subview.js +4 -3
  15. package/dist/components/subviews/table-subview.js.map +1 -1
  16. package/dist/components/view-screen.d.ts +3 -1
  17. package/dist/components/view-screen.d.ts.map +1 -1
  18. package/dist/components/view-screen.js +13 -3
  19. package/dist/components/view-screen.js.map +1 -1
  20. package/dist/content-loader-store.d.ts.map +1 -1
  21. package/dist/content-loader-store.js.map +1 -1
  22. package/dist/default-provider.d.ts.map +1 -1
  23. package/dist/default-provider.js.map +1 -1
  24. package/dist/directory-provider.d.ts.map +1 -1
  25. package/dist/directory-provider.js.map +1 -1
  26. package/dist/directory-view.d.ts.map +1 -1
  27. package/dist/directory-view.js +5 -2
  28. package/dist/directory-view.js.map +1 -1
  29. package/dist/folds/fold-decorations.d.ts.map +1 -1
  30. package/dist/folds/fold-decorations.js.map +1 -1
  31. package/dist/folds/fold-model.d.ts +5 -0
  32. package/dist/folds/fold-model.d.ts.map +1 -1
  33. package/dist/folds/fold-model.js +11 -0
  34. package/dist/folds/fold-model.js.map +1 -1
  35. package/dist/folds/markdown-heading-folds.d.ts +3 -0
  36. package/dist/folds/markdown-heading-folds.d.ts.map +1 -1
  37. package/dist/folds/markdown-heading-folds.js +6 -2
  38. package/dist/folds/markdown-heading-folds.js.map +1 -1
  39. package/dist/folds/use-folds.d.ts +30 -5
  40. package/dist/folds/use-folds.d.ts.map +1 -1
  41. package/dist/folds/use-folds.js +65 -75
  42. package/dist/folds/use-folds.js.map +1 -1
  43. package/dist/hooks/use-content-document.d.ts.map +1 -1
  44. package/dist/hooks/use-content-document.js +3 -2
  45. package/dist/hooks/use-content-document.js.map +1 -1
  46. package/dist/hooks/use-content-loader.d.ts.map +1 -1
  47. package/dist/hooks/use-content-loader.js.map +1 -1
  48. package/dist/hooks/use-view-command-context.d.ts.map +1 -1
  49. package/dist/hooks/use-view-command-context.js.map +1 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js.map +1 -1
  52. package/dist/launch.d.ts +9 -1
  53. package/dist/launch.d.ts.map +1 -1
  54. package/dist/launch.js.map +1 -1
  55. package/dist/link-handlers.d.ts.map +1 -1
  56. package/dist/link-handlers.js.map +1 -1
  57. package/dist/markdown-links.d.ts.map +1 -1
  58. package/dist/markdown-links.js.map +1 -1
  59. package/dist/outline/markdown-outline.d.ts +24 -0
  60. package/dist/outline/markdown-outline.d.ts.map +1 -0
  61. package/dist/outline/markdown-outline.js +25 -0
  62. package/dist/outline/markdown-outline.js.map +1 -0
  63. package/dist/outline/outline-panel.d.ts +17 -0
  64. package/dist/outline/outline-panel.d.ts.map +1 -0
  65. package/dist/outline/outline-panel.js +106 -0
  66. package/dist/outline/outline-panel.js.map +1 -0
  67. package/dist/outline/use-outline.d.ts +53 -0
  68. package/dist/outline/use-outline.d.ts.map +1 -0
  69. package/dist/outline/use-outline.js +131 -0
  70. package/dist/outline/use-outline.js.map +1 -0
  71. package/dist/standalone-router.d.ts.map +1 -1
  72. package/dist/standalone-router.js +4 -1
  73. package/dist/standalone-router.js.map +1 -1
  74. package/dist/types.d.ts.map +1 -1
  75. package/dist/types.js.map +1 -1
  76. package/dist/view.d.ts +3 -1
  77. package/dist/view.d.ts.map +1 -1
  78. package/dist/view.js +2 -2
  79. package/dist/view.js.map +1 -1
  80. package/package.json +14 -13
  81. package/src/components/subviews/diff-subview.tsx +21 -4
  82. package/src/components/subviews/image-subview.tsx +2 -1
  83. package/src/components/subviews/index.ts +6 -0
  84. package/src/components/subviews/markdown-subview.tsx +33 -4
  85. package/src/components/subviews/table-subview.tsx +6 -3
  86. package/src/components/view-screen.tsx +15 -1
  87. package/src/content-loader-store.ts +23 -0
  88. package/src/default-provider.ts +18 -0
  89. package/src/directory-provider.ts +7 -0
  90. package/src/directory-view.tsx +7 -2
  91. package/src/folds/fold-decorations.ts +4 -0
  92. package/src/folds/fold-model.ts +21 -0
  93. package/src/folds/markdown-heading-folds.ts +14 -2
  94. package/src/folds/use-folds.ts +110 -78
  95. package/src/hooks/use-content-document.ts +5 -2
  96. package/src/hooks/use-content-loader.ts +7 -0
  97. package/src/hooks/use-view-command-context.ts +1 -0
  98. package/src/index.ts +17 -0
  99. package/src/launch.tsx +11 -1
  100. package/src/link-handlers.ts +10 -0
  101. package/src/markdown-links.ts +16 -0
  102. package/src/outline/markdown-outline.ts +48 -0
  103. package/src/outline/outline-panel.tsx +184 -0
  104. package/src/outline/use-outline.ts +212 -0
  105. package/src/standalone-router.tsx +24 -2
  106. package/src/types.ts +11 -0
  107. package/src/view.tsx +10 -0
@@ -0,0 +1,184 @@
1
+ import type { MouseEvent, ScrollBoxRenderable } from "@opentui/core";
2
+ import { useActions } from "@tooee/commands";
3
+ import type { ActionDefinition } from "@tooee/commands";
4
+ import { Panel, PanelGroup } from "@tooee/panels";
5
+ import { useTheme } from "@tooee/themes";
6
+ import { useEffect, useRef } from "react";
7
+ import type { ReactNode } from "react";
8
+
9
+ import { foldActionDefinitions } from "../folds/use-folds.js";
10
+ import type { FoldActions } from "../folds/use-folds.js";
11
+ import type { OutlineEntry } from "./markdown-outline.js";
12
+ import type { OutlineState } from "./use-outline.js";
13
+
14
+ const OUTLINE_PANEL_ID = "outline";
15
+
16
+ const INDENT = " ";
17
+
18
+ /** Columns the panel border and the list padding take from the panel width. */
19
+ const OUTLINE_CHROME_WIDTH = 4;
20
+
21
+ const entryId = (entry: OutlineEntry): string => `outline-entry-${entry.row}`;
22
+
23
+ /** One outline line: indent, current-heading marker and text, cut to `width` columns. */
24
+ export const outlineLabel = function outlineLabel(
25
+ entry: OutlineEntry,
26
+ current: boolean,
27
+ closed: boolean,
28
+ width: number
29
+ ): string {
30
+ const suffix = closed ? " ⋯" : "";
31
+ const label = `${INDENT.repeat(entry.level)}${current ? "▸ " : " "}${entry.text}`;
32
+ const room = Math.max(1, width - suffix.length);
33
+ const cut = label.length > room ? `${label.slice(0, Math.max(0, room - 1))}…` : label;
34
+
35
+ return `${cut}${suffix}`;
36
+ };
37
+
38
+ /**
39
+ * The panel-owned outline commands. They register on the outline panel's
40
+ * surface, so they only dispatch while the outline has focus, and they shadow
41
+ * the root commands on the same keys. `g o` closes the outline here, because
42
+ * the panel's `g g` holds the `g` chord. The fold keys act on the selected
43
+ * heading's section, so the panel registers the whole `z` set.
44
+ */
45
+ const OutlineCommands = function OutlineCommands({
46
+ outline,
47
+ foldActions,
48
+ }: {
49
+ outline: OutlineState;
50
+ foldActions: FoldActions;
51
+ }): null {
52
+ const next = (): void => {
53
+ outline.move(1);
54
+ };
55
+
56
+ const previous = (): void => {
57
+ outline.move(-1);
58
+ };
59
+
60
+ const first = (): void => {
61
+ outline.select(0);
62
+ };
63
+
64
+ const last = (): void => {
65
+ outline.select(outline.entries.length - 1);
66
+ };
67
+
68
+ const jump = (): void => {
69
+ outline.jump(outline.selectedIndex);
70
+ };
71
+
72
+ const commands: [hotkey: string, id: string, title: string, run: () => void][] = [
73
+ ["j", "outline.down", "Next heading", next],
74
+ ["down", "outline.down-arrow", "Next heading", next],
75
+ ["k", "outline.up", "Previous heading", previous],
76
+ ["up", "outline.up-arrow", "Previous heading", previous],
77
+ ["g g", "outline.first", "First heading", first],
78
+ ["shift+g", "outline.last", "Last heading", last],
79
+ ["enter", "outline.jump", "Jump to heading", jump],
80
+ ["escape", "outline.focus-content", "Return to document", outline.focusContent],
81
+ ["g o", "outline.close", "Close outline", outline.close],
82
+ ];
83
+
84
+ const selectedRow = (): number | undefined => outline.entries[outline.selectedIndex]?.row;
85
+ useActions([
86
+ ...commands.map(([hotkey, id, title, run]): ActionDefinition => ({
87
+ category: "Outline",
88
+ handler: run,
89
+ hotkey,
90
+ id,
91
+ modes: ["cursor"],
92
+ title,
93
+ })),
94
+ ...foldActionDefinitions(foldActions, selectedRow, "outline.fold", "No fold at heading"),
95
+ ]);
96
+
97
+ return null;
98
+ };
99
+
100
+ const OutlineList = function OutlineList({ outline }: { outline: OutlineState }): ReactNode {
101
+ const { theme } = useTheme();
102
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
103
+ const textWidth = Math.max(1, outline.width - OUTLINE_CHROME_WIDTH);
104
+ const selected = outline.entries[outline.selectedIndex];
105
+
106
+ useEffect(() => {
107
+ if (selected !== undefined) {
108
+ scrollRef.current?.scrollChildIntoView(entryId(selected));
109
+ }
110
+ }, [selected]);
111
+
112
+ if (outline.entries.length === 0) {
113
+ return <text content="No headings" fg={theme.textMuted} paddingLeft={1} />;
114
+ }
115
+
116
+ return (
117
+ <scrollbox
118
+ ref={scrollRef}
119
+ style={{ flexGrow: 1 }}
120
+ focused={false}
121
+ verticalScrollbarOptions={{ visible: false }}
122
+ >
123
+ {outline.entries.map((entry, index): ReactNode => {
124
+ const current = index === outline.currentIndex;
125
+ const highlighted = outline.focused && index === outline.selectedIndex;
126
+
127
+ return (
128
+ <box
129
+ key={entry.row}
130
+ id={entryId(entry)}
131
+ paddingLeft={1}
132
+ paddingRight={1}
133
+ backgroundColor={highlighted ? theme.cursorLine : undefined}
134
+ onMouseDown={(event: MouseEvent) => {
135
+ event.stopPropagation();
136
+ outline.jump(index);
137
+ }}
138
+ >
139
+ <text
140
+ content={outlineLabel(entry, current, outline.closedRows.has(entry.row), textWidth)}
141
+ fg={current ? theme.accent : theme.text}
142
+ />
143
+ </box>
144
+ );
145
+ })}
146
+ </scrollbox>
147
+ );
148
+ };
149
+
150
+ /**
151
+ * The outline as a side panel. A controlled group with one panel: the panel
152
+ * is active only while the outline has focus, so the document keeps the root
153
+ * surface the rest of the time. Built-in Tab switching is off, so Tab keeps
154
+ * its multi-select meaning in the document.
155
+ */
156
+ export const OutlinePanel = function OutlinePanel({
157
+ outline,
158
+ foldActions,
159
+ }: {
160
+ outline: OutlineState;
161
+ foldActions: FoldActions;
162
+ }): ReactNode {
163
+ if (!outline.visible) {
164
+ return null;
165
+ }
166
+
167
+ // The only panel asks for activation on a mouse-down inside it.
168
+ const handleActivePanelChange = (): void => {
169
+ outline.focus();
170
+ };
171
+
172
+ return (
173
+ <PanelGroup
174
+ activePanelId={outline.focused ? OUTLINE_PANEL_ID : null}
175
+ onActivePanelChange={handleActivePanelChange}
176
+ switchKeys={null}
177
+ >
178
+ <Panel id={OUTLINE_PANEL_ID} title="Outline" style={{ flexShrink: 0, width: outline.width }}>
179
+ <OutlineCommands outline={outline} foldActions={foldActions} />
180
+ <OutlineList outline={outline} />
181
+ </Panel>
182
+ </PanelGroup>
183
+ );
184
+ };
@@ -0,0 +1,212 @@
1
+ import { useTerminalDimensions } from "@opentui/react";
2
+ import { useCommand } from "@tooee/commands";
3
+ import type { FlatBlock } from "@tooee/renderers";
4
+ import type { DocumentController, NavigationState } from "@tooee/shell";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+
7
+ import { closedFoldsHiding } from "../folds/fold-model.js";
8
+ import type { FoldState } from "../folds/use-folds.js";
9
+ import { markdownOutline, outlineIndexAt } from "./markdown-outline.js";
10
+ import type { OutlineEntry } from "./markdown-outline.js";
11
+
12
+ /** Outline width in columns, border included. */
13
+ const OUTLINE_WIDTH = 32;
14
+
15
+ /** The outline never takes more than this share of the terminal width. */
16
+ const OUTLINE_MAX_SHARE = 0.4;
17
+
18
+ /** Below this terminal width the outline is not shown. */
19
+ export const OUTLINE_MIN_TERMINAL_WIDTH = 60;
20
+
21
+ /**
22
+ * Lines of the document kept above a heading after a jump. The heading lands
23
+ * near the top of the view, with a little of the text before it for context.
24
+ */
25
+ export const OUTLINE_JUMP_MARGIN = 3;
26
+
27
+ /** Columns for the outline at a terminal width, or 0 when the terminal is too narrow. */
28
+ export const outlineWidth = function outlineWidth(terminalWidth: number): number {
29
+ if (terminalWidth < OUTLINE_MIN_TERMINAL_WIDTH) {
30
+ return 0;
31
+ }
32
+
33
+ return Math.min(OUTLINE_WIDTH, Math.floor(terminalWidth * OUTLINE_MAX_SHARE));
34
+ };
35
+
36
+ /** State and actions of a document outline. */
37
+ export interface OutlineState {
38
+ entries: readonly OutlineEntry[];
39
+ /** Entry of the heading that contains the document cursor, or -1. */
40
+ currentIndex: number;
41
+ /** Entry the outline highlights as selected: the current heading unless the outline has focus. */
42
+ selectedIndex: number;
43
+ /** Full-array rows whose own heading fold is closed. */
44
+ closedRows: ReadonlySet<number>;
45
+ /** True when the outline is open and the terminal is wide enough to show it. */
46
+ visible: boolean;
47
+ /** True when the visible outline owns keyboard input. */
48
+ focused: boolean;
49
+ /** Outline width in columns, or 0 when the terminal is too narrow. */
50
+ width: number;
51
+ focus: () => void;
52
+ focusContent: () => void;
53
+ close: () => void;
54
+ move: (delta: number) => void;
55
+ select: (index: number) => void;
56
+ /** Move the document cursor to an entry's heading and give focus back to the content. */
57
+ jump: (index: number) => void;
58
+ }
59
+
60
+ interface UseOutlineOptions {
61
+ blocks: readonly FlatBlock[];
62
+ folds: FoldState<FlatBlock>;
63
+ navigation: Pick<NavigationState, "cursor" | "setCursor">;
64
+ /** Scrolls a jumped-to heading near the top of the document view. */
65
+ revealRow: DocumentController<FlatBlock>["revealRow"];
66
+ initialOpen?: boolean;
67
+ }
68
+
69
+ /**
70
+ * Outline state for a Markdown document, and the root `g o` command that
71
+ * opens, focuses and closes it. A jump to a heading inside closed folds opens
72
+ * those folds, then moves the cursor once the new rows are in place. Every
73
+ * jump scrolls the heading to near the top of the view.
74
+ */
75
+ export const useMarkdownOutline = function useMarkdownOutline({
76
+ blocks,
77
+ folds,
78
+ navigation,
79
+ revealRow,
80
+ initialOpen = false,
81
+ }: UseOutlineOptions): OutlineState {
82
+ const entries = useMemo(() => markdownOutline(blocks), [blocks]);
83
+ const { width: terminalWidth } = useTerminalDimensions();
84
+ const width = outlineWidth(terminalWidth);
85
+ const [open, setOpen] = useState(initialOpen);
86
+ const [focusRequested, setFocusRequested] = useState(false);
87
+ const [selected, setSelected] = useState(0);
88
+
89
+ const { closedKeys, ranges, setClosedKeys, view } = folds;
90
+ const { cursor, setCursor } = navigation;
91
+ const visible = open && width > 0;
92
+
93
+ const moveTo = useCallback(
94
+ (index: number) => {
95
+ setCursor(index);
96
+ revealRow(index, { align: "start", margin: OUTLINE_JUMP_MARGIN });
97
+ },
98
+ [setCursor, revealRow]
99
+ );
100
+
101
+ const focused = visible && focusRequested;
102
+ const sourceRow = cursor === null ? undefined : view.visibleToSource[cursor];
103
+ const currentIndex = sourceRow === undefined ? -1 : outlineIndexAt(entries, sourceRow);
104
+ const selectedIndex = focused ? Math.min(selected, entries.length - 1) : currentIndex;
105
+
106
+ const closedRows = useMemo(
107
+ () => new Set(ranges.filter((range) => closedKeys.has(range.key)).map((range) => range.start)),
108
+ [ranges, closedKeys]
109
+ );
110
+
111
+ // A jump that opens folds waits for the new fold view, then moves the
112
+ // cursor. This effect runs after the controller's own rows effect, so the
113
+ // store already holds the new rows.
114
+ const pendingRowRef = useRef<number | null>(null);
115
+ useEffect(() => {
116
+ const row = pendingRowRef.current;
117
+ const index = row === null ? -1 : (view.sourceToVisible[row] ?? -1);
118
+
119
+ if (index !== -1) {
120
+ pendingRowRef.current = null;
121
+ moveTo(index);
122
+ }
123
+ }, [view, moveTo]);
124
+
125
+ const focus = () => {
126
+ setSelected(Math.max(currentIndex, 0));
127
+ setFocusRequested(true);
128
+ };
129
+
130
+ const focusContent = () => {
131
+ setFocusRequested(false);
132
+ };
133
+
134
+ const close = () => {
135
+ setOpen(false);
136
+ setFocusRequested(false);
137
+ };
138
+
139
+ const select = (index: number) => {
140
+ setSelected(Math.max(0, Math.min(index, entries.length - 1)));
141
+ };
142
+
143
+ const move = (delta: number) => {
144
+ select(selectedIndex + delta);
145
+ };
146
+
147
+ const jump = (index: number) => {
148
+ const entry = entries[index];
149
+ setFocusRequested(false);
150
+
151
+ if (entry === undefined) {
152
+ return;
153
+ }
154
+
155
+ const hiding = closedFoldsHiding(ranges, closedKeys, entry.row);
156
+
157
+ if (hiding.length === 0) {
158
+ moveTo(view.sourceToVisible[entry.row] ?? 0);
159
+
160
+ return;
161
+ }
162
+
163
+ const next = new Set(closedKeys);
164
+
165
+ for (const key of hiding) {
166
+ next.delete(key);
167
+ }
168
+
169
+ pendingRowRef.current = entry.row;
170
+ setClosedKeys(next);
171
+ };
172
+
173
+ useCommand({
174
+ category: "Outline",
175
+ handler: (ctx) => {
176
+ if (width === 0) {
177
+ ctx.toast?.toast({ level: "info", message: "The outline needs a wider terminal" });
178
+
179
+ return;
180
+ }
181
+
182
+ if (open && focused) {
183
+ close();
184
+
185
+ return;
186
+ }
187
+
188
+ setOpen(true);
189
+ focus();
190
+ },
191
+ hotkey: "g o",
192
+ id: "outline.toggle",
193
+ modes: ["cursor"],
194
+ title: "Toggle outline",
195
+ });
196
+
197
+ return {
198
+ close,
199
+ closedRows,
200
+ currentIndex,
201
+ entries,
202
+ focus,
203
+ focusContent,
204
+ focused,
205
+ jump,
206
+ move,
207
+ select,
208
+ selectedIndex,
209
+ visible,
210
+ width,
211
+ };
212
+ };
@@ -2,7 +2,6 @@ import path from "node:path";
2
2
 
3
3
  import { useChooseDialog } from "@tooee/choose";
4
4
  import { useCommand } from "@tooee/commands";
5
- import type { CommandContext } from "@tooee/commands";
6
5
  import {
7
6
  createRoute,
8
7
  createRouter,
@@ -22,6 +21,7 @@ import { runLinkHandlers } from "./link-handlers.js";
22
21
  import { markdownLinks } from "./markdown-links.js";
23
22
  import type { MarkdownLink } from "./markdown-links.js";
24
23
  import { getTextContent } from "./types.js";
24
+ import type { MarkdownLinkActivateHandler } from "./types.js";
25
25
  import { View } from "./view.js";
26
26
 
27
27
  const documentParams: Codec<{ path?: string }> = {
@@ -30,12 +30,15 @@ const documentParams: Codec<{ path?: string }> = {
30
30
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
31
31
  throw new TypeError("Expected document params");
32
32
  }
33
+
33
34
  if (!("path" in value) || value.path === undefined) {
34
35
  return {};
35
36
  }
37
+
36
38
  if (typeof value.path !== "string" || !path.isAbsolute(value.path)) {
37
39
  throw new TypeError("Expected an absolute document path");
38
40
  }
41
+
39
42
  /* oxlint-enable anti-slop/no-runtime-typeof */
40
43
  return { path: value.path };
41
44
  },
@@ -55,32 +58,45 @@ export const createStandaloneRouter = function createStandaloneRouter(
55
58
  const navigate = useNavigate();
56
59
  const { stack } = useRouter();
57
60
  const chooseLink = useChooseDialog<MarkdownLink>();
61
+
58
62
  const provider = useMemo(
59
63
  () =>
60
64
  documentPath === undefined ? options.contentProvider : createFileProvider(documentPath),
61
65
  [documentPath]
62
66
  );
63
- const activate = (href: string, command: CommandContext): boolean => {
67
+
68
+ const activate: MarkdownLinkActivateHandler = (href, command) => {
69
+ if (options.onMarkdownLinkActivate) {
70
+ return options.onMarkdownLinkActivate(href, command);
71
+ }
72
+
64
73
  if (options.filePath === undefined) {
65
74
  command.toast?.toast({ level: "info", message: "Links need a source file" });
75
+
66
76
  return true;
67
77
  }
78
+
68
79
  const currentPath = path.resolve(documentPath ?? options.filePath);
80
+
69
81
  return runLinkHandlers(
70
82
  options.linkHandlers ?? [],
71
83
  { baseDir: path.dirname(currentPath), currentPath, href },
72
84
  { command, documentRoute, navigate }
73
85
  );
74
86
  };
87
+
75
88
  useCommand({
76
89
  handler: async (context) => {
77
90
  // Source positions are zero-based, unlike the displayed line numbers.
78
91
  const line = context.document?.activeAnchor?.source?.primary?.start.line;
79
92
  const content = context.view?.content;
93
+
80
94
  if (line === undefined || content === undefined) {
81
95
  return;
82
96
  }
97
+
83
98
  const links = markdownLinks(getTextContent(content).split("\n")[line] ?? "");
99
+
84
100
  // One link follows directly; several open a chooser so none is silently preferred.
85
101
  const link =
86
102
  links.length > 1
@@ -90,6 +106,7 @@ export const createStandaloneRouter = function createStandaloneRouter(
90
106
  toItem: (item) => ({ description: item.href, text: item.text }),
91
107
  })
92
108
  : (links[0] ?? null);
109
+
93
110
  if (link !== null) {
94
111
  activate(link.href, context);
95
112
  }
@@ -100,6 +117,7 @@ export const createStandaloneRouter = function createStandaloneRouter(
100
117
  title: "Follow Markdown link",
101
118
  when: (context) => context.view?.format === "markdown",
102
119
  });
120
+
103
121
  return (
104
122
  <View
105
123
  key={`${stack.length}:${documentPath ?? ""}`}
@@ -107,18 +125,22 @@ export const createStandaloneRouter = function createStandaloneRouter(
107
125
  codeBlockRenderers={options.codeBlockRenderers}
108
126
  contentProvider={provider}
109
127
  onMarkdownLinkActivate={activate}
128
+ outline={options.outline}
110
129
  renderers={options.renderers}
111
130
  />
112
131
  );
113
132
  };
133
+
114
134
  const documentRoute = createRoute({
115
135
  component: DocumentScreen,
116
136
  id: "view.document",
117
137
  params: documentParams,
118
138
  });
139
+
119
140
  const router = createRouter({
120
141
  initial: { routeId: documentRoute.id },
121
142
  routes: [documentRoute],
122
143
  });
144
+
123
145
  return { documentRoute, router };
124
146
  };
package/src/types.ts CHANGED
@@ -137,7 +137,9 @@ export interface ContentProvider {
137
137
  // === Compat aliases ===
138
138
 
139
139
  export type ViewContent = Content;
140
+
140
141
  export type ViewContentProvider = ContentProvider;
142
+
141
143
  export type { ColumnDef, TableRow } from "@tooee/renderers";
142
144
 
143
145
  // === Utilities ===
@@ -167,29 +169,38 @@ export const getTextContent = function getTextContent(content: AnyContent): stri
167
169
  case "markdown": {
168
170
  return content.markdown;
169
171
  }
172
+
170
173
  case "code": {
171
174
  return content.code;
172
175
  }
176
+
173
177
  case "diff": {
174
178
  return content.patch;
175
179
  }
180
+
176
181
  case "text": {
177
182
  return content.text;
178
183
  }
184
+
179
185
  case "image": {
180
186
  // oxlint-disable-next-line anti-slop/no-runtime-typeof -- ImageSource is OpenTUI's public string-or-binary union; only its string and URL variants have text content
181
187
  if (typeof content.src === "string") {
182
188
  return content.src;
183
189
  }
190
+
184
191
  return content.src instanceof URL ? content.src.href : "";
185
192
  }
193
+
186
194
  case "table": {
187
195
  const headers = content.columns.map((column) => column.header ?? column.key);
196
+
188
197
  const rowLines = content.rows.map((row) =>
189
198
  content.columns.map((column) => formatTableCell(row[column.key])).join("\t")
190
199
  );
200
+
191
201
  return [headers.join("\t"), ...rowLines].join("\n");
192
202
  }
203
+
193
204
  default: {
194
205
  return "";
195
206
  }
package/src/view.tsx CHANGED
@@ -30,6 +30,8 @@ interface ViewProps {
30
30
  codeBlockRenderers?: Record<string, CodeBlockRenderer>;
31
31
  /** Handles primary-button activation of inline Markdown links. */
32
32
  onMarkdownLinkActivate?: MarkdownLinkActivateHandler;
33
+ /** Open the heading outline beside Markdown content at start. Other formats ignore it. */
34
+ outline?: boolean;
33
35
  }
34
36
 
35
37
  export const View = function View({
@@ -38,6 +40,7 @@ export const View = function View({
38
40
  renderers,
39
41
  codeBlockRenderers,
40
42
  onMarkdownLinkActivate,
43
+ outline,
41
44
  }: ViewProps): ReactNode {
42
45
  const { theme } = useTheme();
43
46
  const { content, streaming, error, providerMarks, reload } = useContentLoader(contentProvider);
@@ -47,6 +50,7 @@ export const View = function View({
47
50
  const setMarkSet = useCallback((set: MarkSet) => {
48
51
  setUserMarks((prev) => {
49
52
  const filtered = prev.filter((s) => s.namespace !== set.namespace);
53
+
50
54
  return [...filtered, set];
51
55
  });
52
56
  }, []);
@@ -101,23 +105,29 @@ export const View = function View({
101
105
  content={content}
102
106
  codeBlockRenderers={mergedCodeBlockRenderers}
103
107
  onLinkActivate={onMarkdownLinkActivate}
108
+ outline={outline}
104
109
  {...shared}
105
110
  />
106
111
  );
107
112
  }
113
+
108
114
  case "code":
109
115
  case "text": {
110
116
  return <CodeSubview content={content} {...shared} />;
111
117
  }
118
+
112
119
  case "image": {
113
120
  return <ImageSubview content={content} {...shared} />;
114
121
  }
122
+
115
123
  case "table": {
116
124
  return <TableSubview content={content} {...shared} />;
117
125
  }
126
+
118
127
  case "diff": {
119
128
  return <DiffSubview content={content} {...shared} />;
120
129
  }
130
+
121
131
  default: {
122
132
  return null;
123
133
  }