@tooee/view 0.11.0 → 0.12.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/dist/components/subviews/markdown-subview.d.ts +3 -1
- package/dist/components/subviews/markdown-subview.d.ts.map +1 -1
- package/dist/components/subviews/markdown-subview.js +18 -4
- package/dist/components/subviews/markdown-subview.js.map +1 -1
- package/dist/components/view-screen.d.ts +3 -1
- package/dist/components/view-screen.d.ts.map +1 -1
- package/dist/components/view-screen.js +2 -2
- package/dist/components/view-screen.js.map +1 -1
- package/dist/folds/fold-model.d.ts +5 -0
- package/dist/folds/fold-model.d.ts.map +1 -1
- package/dist/folds/fold-model.js +9 -0
- package/dist/folds/fold-model.js.map +1 -1
- package/dist/folds/markdown-heading-folds.d.ts +3 -0
- package/dist/folds/markdown-heading-folds.d.ts.map +1 -1
- package/dist/folds/markdown-heading-folds.js +6 -2
- package/dist/folds/markdown-heading-folds.js.map +1 -1
- package/dist/folds/use-folds.d.ts +30 -5
- package/dist/folds/use-folds.d.ts.map +1 -1
- package/dist/folds/use-folds.js +65 -75
- package/dist/folds/use-folds.js.map +1 -1
- package/dist/launch.d.ts +2 -0
- package/dist/launch.d.ts.map +1 -1
- package/dist/launch.js.map +1 -1
- package/dist/outline/markdown-outline.d.ts +24 -0
- package/dist/outline/markdown-outline.d.ts.map +1 -0
- package/dist/outline/markdown-outline.js +25 -0
- package/dist/outline/markdown-outline.js.map +1 -0
- package/dist/outline/outline-panel.d.ts +17 -0
- package/dist/outline/outline-panel.d.ts.map +1 -0
- package/dist/outline/outline-panel.js +106 -0
- package/dist/outline/outline-panel.js.map +1 -0
- package/dist/outline/use-outline.d.ts +53 -0
- package/dist/outline/use-outline.d.ts.map +1 -0
- package/dist/outline/use-outline.js +131 -0
- package/dist/outline/use-outline.js.map +1 -0
- package/dist/standalone-router.d.ts.map +1 -1
- package/dist/standalone-router.js +1 -1
- package/dist/standalone-router.js.map +1 -1
- package/dist/view.d.ts +3 -1
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +2 -2
- package/dist/view.js.map +1 -1
- package/package.json +14 -13
- package/src/components/subviews/markdown-subview.tsx +21 -2
- package/src/components/view-screen.tsx +4 -0
- package/src/folds/fold-model.ts +14 -0
- package/src/folds/markdown-heading-folds.ts +7 -2
- package/src/folds/use-folds.ts +95 -81
- package/src/launch.tsx +2 -0
- package/src/outline/markdown-outline.ts +45 -0
- package/src/outline/outline-panel.tsx +171 -0
- package/src/outline/use-outline.ts +188 -0
- package/src/standalone-router.tsx +1 -0
- package/src/view.tsx +4 -0
package/src/folds/use-folds.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { CommandContext } from "@tooee/commands";
|
|
1
|
+
import { useActions, useCommandGroup } from "@tooee/commands";
|
|
2
|
+
import type { ActionDefinition, CommandContext, CommandHandler } from "@tooee/commands";
|
|
3
3
|
import type { DecorationLayer } from "@tooee/renderers";
|
|
4
4
|
import type { NavigationState } from "@tooee/shell";
|
|
5
5
|
import { useMemo, useState } from "react";
|
|
@@ -76,36 +76,49 @@ export const useFoldState = function useFoldState<T>(
|
|
|
76
76
|
};
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
/** Fold operations. Row arguments are full-array row indices. */
|
|
80
|
+
export interface FoldActions {
|
|
81
|
+
/** Toggle the innermost fold that contains `row`. Returns false when no fold contains it. */
|
|
82
|
+
toggle: (row: number) => boolean;
|
|
83
|
+
/** Close the innermost open fold that contains `row`. Returns false when there is none. */
|
|
84
|
+
close: (row: number) => boolean;
|
|
85
|
+
/** Open the innermost closed fold that contains `row`. Returns false when there is none. */
|
|
86
|
+
open: (row: number) => boolean;
|
|
87
|
+
/** Open a fold at `row` when one is closed, else close the innermost open fold. */
|
|
88
|
+
toggleBlock: (row: number) => boolean;
|
|
89
|
+
closeAll: () => void;
|
|
90
|
+
openAll: () => void;
|
|
91
|
+
}
|
|
82
92
|
|
|
83
93
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
94
|
+
* Fold operations over a fold state. Any change that hides the document
|
|
95
|
+
* cursor row first moves the cursor to the header of the outermost closed
|
|
96
|
+
* fold that hides it, and key preservation keeps it there. This holds for
|
|
97
|
+
* every target row, so a fold closed from the outline also keeps the cursor
|
|
98
|
+
* visible.
|
|
88
99
|
*/
|
|
89
|
-
export const
|
|
100
|
+
export const useFoldActions = function useFoldActions<T>(
|
|
90
101
|
folds: FoldState<T>,
|
|
91
102
|
navigation: Pick<NavigationState, "cursor" | "setCursor">
|
|
92
|
-
):
|
|
103
|
+
): FoldActions {
|
|
93
104
|
const { closedKeys, ranges, rowCount, setClosedKeys, view } = folds;
|
|
94
105
|
const { cursor, setCursor } = navigation;
|
|
95
|
-
const
|
|
96
|
-
const atCursor = sourceRow === undefined ? [] : foldsAt(ranges, sourceRow);
|
|
106
|
+
const cursorRow = cursor === null ? undefined : view.visibleToSource[cursor];
|
|
97
107
|
|
|
98
108
|
const apply = (next: ReadonlySet<Key>) => {
|
|
99
|
-
if (
|
|
100
|
-
const anchor = visibleAnchorRow(computeFoldView(rowCount, ranges, next),
|
|
109
|
+
if (cursorRow !== undefined) {
|
|
110
|
+
const anchor = visibleAnchorRow(computeFoldView(rowCount, ranges, next), cursorRow);
|
|
101
111
|
const anchorIndex = view.sourceToVisible[anchor] ?? -1;
|
|
102
|
-
if (anchor !==
|
|
112
|
+
if (anchor !== cursorRow && anchorIndex !== -1) {
|
|
103
113
|
setCursor(anchorIndex);
|
|
104
114
|
}
|
|
105
115
|
}
|
|
106
116
|
setClosedKeys(next);
|
|
107
117
|
};
|
|
108
|
-
const withKey = (key: Key, closed: boolean) => {
|
|
118
|
+
const withKey = (key: Key | undefined, closed: boolean): boolean => {
|
|
119
|
+
if (key === undefined) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
109
122
|
const next = new Set(closedKeys);
|
|
110
123
|
if (closed) {
|
|
111
124
|
next.add(key);
|
|
@@ -113,77 +126,78 @@ export const useFoldCommands = function useFoldCommands<T>(
|
|
|
113
126
|
next.delete(key);
|
|
114
127
|
}
|
|
115
128
|
apply(next);
|
|
129
|
+
return true;
|
|
116
130
|
};
|
|
131
|
+
const close = (row: number) =>
|
|
132
|
+
withKey(foldsAt(ranges, row).find((range) => !closedKeys.has(range.key))?.key, true);
|
|
133
|
+
const open = (row: number) =>
|
|
134
|
+
withKey(foldsAt(ranges, row).find((range) => closedKeys.has(range.key))?.key, false);
|
|
117
135
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
handler: (ctx) => {
|
|
123
|
-
const [innermost] = atCursor;
|
|
124
|
-
if (innermost === undefined) {
|
|
125
|
-
warnNoFold(ctx);
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
withKey(innermost.key, !closedKeys.has(innermost.key));
|
|
136
|
+
return {
|
|
137
|
+
close,
|
|
138
|
+
closeAll: () => {
|
|
139
|
+
apply(new Set(ranges.map((range) => range.key)));
|
|
129
140
|
},
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
title: "Toggle fold",
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
useCommand({
|
|
137
|
-
category: "Fold",
|
|
138
|
-
handler: (ctx) => {
|
|
139
|
-
const target = atCursor.find((range) => !closedKeys.has(range.key));
|
|
140
|
-
if (target === undefined) {
|
|
141
|
-
warnNoFold(ctx);
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
withKey(target.key, true);
|
|
141
|
+
open,
|
|
142
|
+
openAll: () => {
|
|
143
|
+
apply(EMPTY_KEYS);
|
|
145
144
|
},
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
title: "Close fold",
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
useCommand({
|
|
153
|
-
category: "Fold",
|
|
154
|
-
handler: (ctx) => {
|
|
155
|
-
const target = atCursor.find((range) => closedKeys.has(range.key));
|
|
156
|
-
if (target === undefined) {
|
|
157
|
-
warnNoFold(ctx);
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
withKey(target.key, false);
|
|
145
|
+
toggle: (row) => {
|
|
146
|
+
const [innermost] = foldsAt(ranges, row);
|
|
147
|
+
return withKey(innermost?.key, innermost !== undefined && !closedKeys.has(innermost.key));
|
|
161
148
|
},
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
});
|
|
149
|
+
toggleBlock: (row) =>
|
|
150
|
+
foldsAt(ranges, row).some((range) => closedKeys.has(range.key)) ? open(row) : close(row),
|
|
151
|
+
};
|
|
152
|
+
};
|
|
167
153
|
|
|
168
|
-
|
|
154
|
+
/**
|
|
155
|
+
* The Vim fold commands (`z a`, `z c`, `z o`, `z z`, `z shift+m`,
|
|
156
|
+
* `z shift+r`) as action definitions. The row commands act on `targetRow()`:
|
|
157
|
+
* the document cursor row on the root surface, or the selected heading in the
|
|
158
|
+
* outline. `idPrefix` keeps the ids apart on each surface, and `noFoldMessage`
|
|
159
|
+
* is the warning when no fold contains the target row.
|
|
160
|
+
*/
|
|
161
|
+
export const foldActionDefinitions = function foldActionDefinitions(
|
|
162
|
+
actions: FoldActions,
|
|
163
|
+
targetRow: () => number | undefined,
|
|
164
|
+
idPrefix: string,
|
|
165
|
+
noFoldMessage: string
|
|
166
|
+
): ActionDefinition[] {
|
|
167
|
+
const atTarget =
|
|
168
|
+
(run: (row: number) => boolean) =>
|
|
169
|
+
(ctx: CommandContext): void => {
|
|
170
|
+
const row = targetRow();
|
|
171
|
+
if (row === undefined || !run(row)) {
|
|
172
|
+
ctx.toast?.toast({ level: "warning", message: noFoldMessage });
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const definitions: [hotkey: string, id: string, title: string, handler: CommandHandler][] = [
|
|
176
|
+
["z a", "toggle", "Toggle fold", atTarget(actions.toggle)],
|
|
177
|
+
["z c", "close", "Close fold", atTarget(actions.close)],
|
|
178
|
+
["z o", "open", "Open fold", atTarget(actions.open)],
|
|
179
|
+
["z z", "toggle-block", "Open or close fold", atTarget(actions.toggleBlock)],
|
|
180
|
+
["z shift+m", "close-all", "Close all folds", actions.closeAll],
|
|
181
|
+
["z shift+r", "open-all", "Open all folds", actions.openAll],
|
|
182
|
+
];
|
|
183
|
+
return definitions.map(([hotkey, id, title, handler]) => ({
|
|
169
184
|
category: "Fold",
|
|
170
|
-
handler
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
hotkey: "z shift+m",
|
|
174
|
-
id: "fold.close-all",
|
|
185
|
+
handler,
|
|
186
|
+
hotkey,
|
|
187
|
+
id: `${idPrefix}.${id}`,
|
|
175
188
|
modes: ["cursor"],
|
|
176
|
-
title
|
|
177
|
-
});
|
|
189
|
+
title,
|
|
190
|
+
}));
|
|
191
|
+
};
|
|
178
192
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
193
|
+
/** Register the fold commands on the current surface, acting on the document cursor row. */
|
|
194
|
+
export const useFoldCommands = function useFoldCommands<T>(
|
|
195
|
+
folds: FoldState<T>,
|
|
196
|
+
actions: FoldActions,
|
|
197
|
+
navigation: Pick<NavigationState, "cursor">
|
|
198
|
+
): void {
|
|
199
|
+
const { cursor } = navigation;
|
|
200
|
+
const cursorRow = cursor === null ? undefined : folds.view.visibleToSource[cursor];
|
|
201
|
+
useCommandGroup({ id: "fold", prefix: "z", title: "Fold" });
|
|
202
|
+
useActions(foldActionDefinitions(actions, () => cursorRow, "fold", "No fold at cursor"));
|
|
189
203
|
};
|
package/src/launch.tsx
CHANGED
|
@@ -23,6 +23,8 @@ export interface ViewLaunchOptions {
|
|
|
23
23
|
* Unmatched types fall back to the default syntax-highlighted code block.
|
|
24
24
|
*/
|
|
25
25
|
codeBlockRenderers?: Record<string, CodeBlockRenderer>;
|
|
26
|
+
/** Open the heading outline beside Markdown content at start. */
|
|
27
|
+
outline?: boolean;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export interface DirectoryLaunchOptions {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getPlainText } from "@tooee/renderers";
|
|
2
|
+
import type { FlatBlock } from "@tooee/renderers";
|
|
3
|
+
|
|
4
|
+
import { headingToken } from "../folds/markdown-heading-folds.js";
|
|
5
|
+
|
|
6
|
+
/** One heading in a document outline. */
|
|
7
|
+
export interface OutlineEntry {
|
|
8
|
+
/** The heading's index in the full row array, the same key folds use. */
|
|
9
|
+
row: number;
|
|
10
|
+
/** Heading level, 1 for `#`. */
|
|
11
|
+
depth: number;
|
|
12
|
+
/** Indent level in the outline: depth relative to the shallowest heading. */
|
|
13
|
+
level: number;
|
|
14
|
+
/** Unstyled heading text. */
|
|
15
|
+
text: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The headings of flattened Markdown blocks, in document order. Levels start
|
|
20
|
+
* at 0 for the shallowest heading in the document, so a document that starts
|
|
21
|
+
* at `##` is not indented.
|
|
22
|
+
*/
|
|
23
|
+
export const markdownOutline = function markdownOutline(
|
|
24
|
+
blocks: readonly FlatBlock[]
|
|
25
|
+
): OutlineEntry[] {
|
|
26
|
+
const headings = blocks.flatMap((block, row) => {
|
|
27
|
+
const heading = headingToken(block);
|
|
28
|
+
return heading === null
|
|
29
|
+
? []
|
|
30
|
+
: [{ depth: heading.depth, row, text: getPlainText(heading.tokens).trim() }];
|
|
31
|
+
});
|
|
32
|
+
const minDepth = Math.min(...headings.map((heading) => heading.depth));
|
|
33
|
+
return headings.map((heading) => ({ ...heading, level: heading.depth - minDepth }));
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Index of the entry for the heading that contains `row`: the last heading at
|
|
38
|
+
* or before it. Returns `-1` before the first heading or for an empty outline.
|
|
39
|
+
*/
|
|
40
|
+
export const outlineIndexAt = function outlineIndexAt(
|
|
41
|
+
entries: readonly OutlineEntry[],
|
|
42
|
+
row: number
|
|
43
|
+
): number {
|
|
44
|
+
return entries.findLastIndex((entry) => entry.row <= row);
|
|
45
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
const INDENT = " ";
|
|
16
|
+
/** Columns the panel border and the list padding take from the panel width. */
|
|
17
|
+
const OUTLINE_CHROME_WIDTH = 4;
|
|
18
|
+
|
|
19
|
+
const entryId = (entry: OutlineEntry): string => `outline-entry-${entry.row}`;
|
|
20
|
+
|
|
21
|
+
/** One outline line: indent, current-heading marker and text, cut to `width` columns. */
|
|
22
|
+
export const outlineLabel = function outlineLabel(
|
|
23
|
+
entry: OutlineEntry,
|
|
24
|
+
current: boolean,
|
|
25
|
+
closed: boolean,
|
|
26
|
+
width: number
|
|
27
|
+
): string {
|
|
28
|
+
const suffix = closed ? " ⋯" : "";
|
|
29
|
+
const label = `${INDENT.repeat(entry.level)}${current ? "▸ " : " "}${entry.text}`;
|
|
30
|
+
const room = Math.max(1, width - suffix.length);
|
|
31
|
+
const cut = label.length > room ? `${label.slice(0, Math.max(0, room - 1))}…` : label;
|
|
32
|
+
return `${cut}${suffix}`;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The panel-owned outline commands. They register on the outline panel's
|
|
37
|
+
* surface, so they only dispatch while the outline has focus, and they shadow
|
|
38
|
+
* the root commands on the same keys. `g o` closes the outline here, because
|
|
39
|
+
* the panel's `g g` holds the `g` chord. The fold keys act on the selected
|
|
40
|
+
* heading's section, so the panel registers the whole `z` set.
|
|
41
|
+
*/
|
|
42
|
+
const OutlineCommands = function OutlineCommands({
|
|
43
|
+
outline,
|
|
44
|
+
foldActions,
|
|
45
|
+
}: {
|
|
46
|
+
outline: OutlineState;
|
|
47
|
+
foldActions: FoldActions;
|
|
48
|
+
}): null {
|
|
49
|
+
const next = (): void => {
|
|
50
|
+
outline.move(1);
|
|
51
|
+
};
|
|
52
|
+
const previous = (): void => {
|
|
53
|
+
outline.move(-1);
|
|
54
|
+
};
|
|
55
|
+
const first = (): void => {
|
|
56
|
+
outline.select(0);
|
|
57
|
+
};
|
|
58
|
+
const last = (): void => {
|
|
59
|
+
outline.select(outline.entries.length - 1);
|
|
60
|
+
};
|
|
61
|
+
const jump = (): void => {
|
|
62
|
+
outline.jump(outline.selectedIndex);
|
|
63
|
+
};
|
|
64
|
+
const commands: [hotkey: string, id: string, title: string, run: () => void][] = [
|
|
65
|
+
["j", "outline.down", "Next heading", next],
|
|
66
|
+
["down", "outline.down-arrow", "Next heading", next],
|
|
67
|
+
["k", "outline.up", "Previous heading", previous],
|
|
68
|
+
["up", "outline.up-arrow", "Previous heading", previous],
|
|
69
|
+
["g g", "outline.first", "First heading", first],
|
|
70
|
+
["shift+g", "outline.last", "Last heading", last],
|
|
71
|
+
["enter", "outline.jump", "Jump to heading", jump],
|
|
72
|
+
["escape", "outline.focus-content", "Return to document", outline.focusContent],
|
|
73
|
+
["g o", "outline.close", "Close outline", outline.close],
|
|
74
|
+
];
|
|
75
|
+
const selectedRow = (): number | undefined => outline.entries[outline.selectedIndex]?.row;
|
|
76
|
+
useActions([
|
|
77
|
+
...commands.map(([hotkey, id, title, run]): ActionDefinition => ({
|
|
78
|
+
category: "Outline",
|
|
79
|
+
handler: run,
|
|
80
|
+
hotkey,
|
|
81
|
+
id,
|
|
82
|
+
modes: ["cursor"],
|
|
83
|
+
title,
|
|
84
|
+
})),
|
|
85
|
+
...foldActionDefinitions(foldActions, selectedRow, "outline.fold", "No fold at heading"),
|
|
86
|
+
]);
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const OutlineList = function OutlineList({ outline }: { outline: OutlineState }): ReactNode {
|
|
91
|
+
const { theme } = useTheme();
|
|
92
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
93
|
+
const textWidth = Math.max(1, outline.width - OUTLINE_CHROME_WIDTH);
|
|
94
|
+
const selected = outline.entries[outline.selectedIndex];
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (selected !== undefined) {
|
|
98
|
+
scrollRef.current?.scrollChildIntoView(entryId(selected));
|
|
99
|
+
}
|
|
100
|
+
}, [selected]);
|
|
101
|
+
|
|
102
|
+
if (outline.entries.length === 0) {
|
|
103
|
+
return <text content="No headings" fg={theme.textMuted} paddingLeft={1} />;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<scrollbox
|
|
108
|
+
ref={scrollRef}
|
|
109
|
+
style={{ flexGrow: 1 }}
|
|
110
|
+
focused={false}
|
|
111
|
+
verticalScrollbarOptions={{ visible: false }}
|
|
112
|
+
>
|
|
113
|
+
{outline.entries.map((entry, index): ReactNode => {
|
|
114
|
+
const current = index === outline.currentIndex;
|
|
115
|
+
const highlighted = outline.focused && index === outline.selectedIndex;
|
|
116
|
+
return (
|
|
117
|
+
<box
|
|
118
|
+
key={entry.row}
|
|
119
|
+
id={entryId(entry)}
|
|
120
|
+
paddingLeft={1}
|
|
121
|
+
paddingRight={1}
|
|
122
|
+
backgroundColor={highlighted ? theme.cursorLine : undefined}
|
|
123
|
+
onMouseDown={(event: MouseEvent) => {
|
|
124
|
+
event.stopPropagation();
|
|
125
|
+
outline.jump(index);
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
<text
|
|
129
|
+
content={outlineLabel(entry, current, outline.closedRows.has(entry.row), textWidth)}
|
|
130
|
+
fg={current ? theme.accent : theme.text}
|
|
131
|
+
/>
|
|
132
|
+
</box>
|
|
133
|
+
);
|
|
134
|
+
})}
|
|
135
|
+
</scrollbox>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The outline as a side panel. A controlled group with one panel: the panel
|
|
141
|
+
* is active only while the outline has focus, so the document keeps the root
|
|
142
|
+
* surface the rest of the time. Built-in Tab switching is off, so Tab keeps
|
|
143
|
+
* its multi-select meaning in the document.
|
|
144
|
+
*/
|
|
145
|
+
export const OutlinePanel = function OutlinePanel({
|
|
146
|
+
outline,
|
|
147
|
+
foldActions,
|
|
148
|
+
}: {
|
|
149
|
+
outline: OutlineState;
|
|
150
|
+
foldActions: FoldActions;
|
|
151
|
+
}): ReactNode {
|
|
152
|
+
if (!outline.visible) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
// The only panel asks for activation on a mouse-down inside it.
|
|
156
|
+
const handleActivePanelChange = (): void => {
|
|
157
|
+
outline.focus();
|
|
158
|
+
};
|
|
159
|
+
return (
|
|
160
|
+
<PanelGroup
|
|
161
|
+
activePanelId={outline.focused ? OUTLINE_PANEL_ID : null}
|
|
162
|
+
onActivePanelChange={handleActivePanelChange}
|
|
163
|
+
switchKeys={null}
|
|
164
|
+
>
|
|
165
|
+
<Panel id={OUTLINE_PANEL_ID} title="Outline" style={{ flexShrink: 0, width: outline.width }}>
|
|
166
|
+
<OutlineCommands outline={outline} foldActions={foldActions} />
|
|
167
|
+
<OutlineList outline={outline} />
|
|
168
|
+
</Panel>
|
|
169
|
+
</PanelGroup>
|
|
170
|
+
);
|
|
171
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
/** The outline never takes more than this share of the terminal width. */
|
|
15
|
+
const OUTLINE_MAX_SHARE = 0.4;
|
|
16
|
+
/** Below this terminal width the outline is not shown. */
|
|
17
|
+
export const OUTLINE_MIN_TERMINAL_WIDTH = 60;
|
|
18
|
+
/**
|
|
19
|
+
* Lines of the document kept above a heading after a jump. The heading lands
|
|
20
|
+
* near the top of the view, with a little of the text before it for context.
|
|
21
|
+
*/
|
|
22
|
+
export const OUTLINE_JUMP_MARGIN = 3;
|
|
23
|
+
|
|
24
|
+
/** Columns for the outline at a terminal width, or 0 when the terminal is too narrow. */
|
|
25
|
+
export const outlineWidth = function outlineWidth(terminalWidth: number): number {
|
|
26
|
+
if (terminalWidth < OUTLINE_MIN_TERMINAL_WIDTH) {
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
return Math.min(OUTLINE_WIDTH, Math.floor(terminalWidth * OUTLINE_MAX_SHARE));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** State and actions of a document outline. */
|
|
33
|
+
export interface OutlineState {
|
|
34
|
+
entries: readonly OutlineEntry[];
|
|
35
|
+
/** Entry of the heading that contains the document cursor, or -1. */
|
|
36
|
+
currentIndex: number;
|
|
37
|
+
/** Entry the outline highlights as selected: the current heading unless the outline has focus. */
|
|
38
|
+
selectedIndex: number;
|
|
39
|
+
/** Full-array rows whose own heading fold is closed. */
|
|
40
|
+
closedRows: ReadonlySet<number>;
|
|
41
|
+
/** True when the outline is open and the terminal is wide enough to show it. */
|
|
42
|
+
visible: boolean;
|
|
43
|
+
/** True when the visible outline owns keyboard input. */
|
|
44
|
+
focused: boolean;
|
|
45
|
+
/** Outline width in columns, or 0 when the terminal is too narrow. */
|
|
46
|
+
width: number;
|
|
47
|
+
focus: () => void;
|
|
48
|
+
focusContent: () => void;
|
|
49
|
+
close: () => void;
|
|
50
|
+
move: (delta: number) => void;
|
|
51
|
+
select: (index: number) => void;
|
|
52
|
+
/** Move the document cursor to an entry's heading and give focus back to the content. */
|
|
53
|
+
jump: (index: number) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface UseOutlineOptions {
|
|
57
|
+
blocks: readonly FlatBlock[];
|
|
58
|
+
folds: FoldState<FlatBlock>;
|
|
59
|
+
navigation: Pick<NavigationState, "cursor" | "setCursor">;
|
|
60
|
+
/** Scrolls a jumped-to heading near the top of the document view. */
|
|
61
|
+
revealRow: DocumentController<FlatBlock>["revealRow"];
|
|
62
|
+
initialOpen?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Outline state for a Markdown document, and the root `g o` command that
|
|
67
|
+
* opens, focuses and closes it. A jump to a heading inside closed folds opens
|
|
68
|
+
* those folds, then moves the cursor once the new rows are in place. Every
|
|
69
|
+
* jump scrolls the heading to near the top of the view.
|
|
70
|
+
*/
|
|
71
|
+
export const useMarkdownOutline = function useMarkdownOutline({
|
|
72
|
+
blocks,
|
|
73
|
+
folds,
|
|
74
|
+
navigation,
|
|
75
|
+
revealRow,
|
|
76
|
+
initialOpen = false,
|
|
77
|
+
}: UseOutlineOptions): OutlineState {
|
|
78
|
+
const entries = useMemo(() => markdownOutline(blocks), [blocks]);
|
|
79
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
80
|
+
const width = outlineWidth(terminalWidth);
|
|
81
|
+
const [open, setOpen] = useState(initialOpen);
|
|
82
|
+
const [focusRequested, setFocusRequested] = useState(false);
|
|
83
|
+
const [selected, setSelected] = useState(0);
|
|
84
|
+
|
|
85
|
+
const { closedKeys, ranges, setClosedKeys, view } = folds;
|
|
86
|
+
const { cursor, setCursor } = navigation;
|
|
87
|
+
const visible = open && width > 0;
|
|
88
|
+
const moveTo = useCallback(
|
|
89
|
+
(index: number) => {
|
|
90
|
+
setCursor(index);
|
|
91
|
+
revealRow(index, { align: "start", margin: OUTLINE_JUMP_MARGIN });
|
|
92
|
+
},
|
|
93
|
+
[setCursor, revealRow]
|
|
94
|
+
);
|
|
95
|
+
const focused = visible && focusRequested;
|
|
96
|
+
const sourceRow = cursor === null ? undefined : view.visibleToSource[cursor];
|
|
97
|
+
const currentIndex = sourceRow === undefined ? -1 : outlineIndexAt(entries, sourceRow);
|
|
98
|
+
const selectedIndex = focused ? Math.min(selected, entries.length - 1) : currentIndex;
|
|
99
|
+
const closedRows = useMemo(
|
|
100
|
+
() => new Set(ranges.filter((range) => closedKeys.has(range.key)).map((range) => range.start)),
|
|
101
|
+
[ranges, closedKeys]
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// A jump that opens folds waits for the new fold view, then moves the
|
|
105
|
+
// cursor. This effect runs after the controller's own rows effect, so the
|
|
106
|
+
// store already holds the new rows.
|
|
107
|
+
const pendingRowRef = useRef<number | null>(null);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const row = pendingRowRef.current;
|
|
110
|
+
const index = row === null ? -1 : (view.sourceToVisible[row] ?? -1);
|
|
111
|
+
if (index !== -1) {
|
|
112
|
+
pendingRowRef.current = null;
|
|
113
|
+
moveTo(index);
|
|
114
|
+
}
|
|
115
|
+
}, [view, moveTo]);
|
|
116
|
+
|
|
117
|
+
const focus = () => {
|
|
118
|
+
setSelected(Math.max(currentIndex, 0));
|
|
119
|
+
setFocusRequested(true);
|
|
120
|
+
};
|
|
121
|
+
const focusContent = () => {
|
|
122
|
+
setFocusRequested(false);
|
|
123
|
+
};
|
|
124
|
+
const close = () => {
|
|
125
|
+
setOpen(false);
|
|
126
|
+
setFocusRequested(false);
|
|
127
|
+
};
|
|
128
|
+
const select = (index: number) => {
|
|
129
|
+
setSelected(Math.max(0, Math.min(index, entries.length - 1)));
|
|
130
|
+
};
|
|
131
|
+
const move = (delta: number) => {
|
|
132
|
+
select(selectedIndex + delta);
|
|
133
|
+
};
|
|
134
|
+
const jump = (index: number) => {
|
|
135
|
+
const entry = entries[index];
|
|
136
|
+
setFocusRequested(false);
|
|
137
|
+
if (entry === undefined) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const hiding = closedFoldsHiding(ranges, closedKeys, entry.row);
|
|
141
|
+
if (hiding.length === 0) {
|
|
142
|
+
moveTo(view.sourceToVisible[entry.row] ?? 0);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const next = new Set(closedKeys);
|
|
146
|
+
for (const key of hiding) {
|
|
147
|
+
next.delete(key);
|
|
148
|
+
}
|
|
149
|
+
pendingRowRef.current = entry.row;
|
|
150
|
+
setClosedKeys(next);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
useCommand({
|
|
154
|
+
category: "Outline",
|
|
155
|
+
handler: (ctx) => {
|
|
156
|
+
if (width === 0) {
|
|
157
|
+
ctx.toast?.toast({ level: "info", message: "The outline needs a wider terminal" });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (open && focused) {
|
|
161
|
+
close();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
setOpen(true);
|
|
165
|
+
focus();
|
|
166
|
+
},
|
|
167
|
+
hotkey: "g o",
|
|
168
|
+
id: "outline.toggle",
|
|
169
|
+
modes: ["cursor"],
|
|
170
|
+
title: "Toggle outline",
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
close,
|
|
175
|
+
closedRows,
|
|
176
|
+
currentIndex,
|
|
177
|
+
entries,
|
|
178
|
+
focus,
|
|
179
|
+
focusContent,
|
|
180
|
+
focused,
|
|
181
|
+
jump,
|
|
182
|
+
move,
|
|
183
|
+
select,
|
|
184
|
+
selectedIndex,
|
|
185
|
+
visible,
|
|
186
|
+
width,
|
|
187
|
+
};
|
|
188
|
+
};
|
|
@@ -107,6 +107,7 @@ export const createStandaloneRouter = function createStandaloneRouter(
|
|
|
107
107
|
codeBlockRenderers={options.codeBlockRenderers}
|
|
108
108
|
contentProvider={provider}
|
|
109
109
|
onMarkdownLinkActivate={activate}
|
|
110
|
+
outline={options.outline}
|
|
110
111
|
renderers={options.renderers}
|
|
111
112
|
/>
|
|
112
113
|
);
|
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);
|
|
@@ -101,6 +104,7 @@ export const View = function View({
|
|
|
101
104
|
content={content}
|
|
102
105
|
codeBlockRenderers={mergedCodeBlockRenderers}
|
|
103
106
|
onLinkActivate={onMarkdownLinkActivate}
|
|
107
|
+
outline={outline}
|
|
104
108
|
{...shared}
|
|
105
109
|
/>
|
|
106
110
|
);
|