@tooee/view 0.10.3 → 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 +36 -7
- 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-decorations.d.ts +8 -0
- package/dist/folds/fold-decorations.d.ts.map +1 -0
- package/dist/folds/fold-decorations.js +24 -0
- package/dist/folds/fold-decorations.js.map +1 -0
- package/dist/folds/fold-model.d.ts +41 -0
- package/dist/folds/fold-model.d.ts.map +1 -0
- package/dist/folds/fold-model.js +60 -0
- package/dist/folds/fold-model.js.map +1 -0
- package/dist/folds/markdown-heading-folds.d.ts +13 -0
- package/dist/folds/markdown-heading-folds.d.ts.map +1 -0
- package/dist/folds/markdown-heading-folds.js +45 -0
- package/dist/folds/markdown-heading-folds.js.map +1 -0
- package/dist/folds/use-folds.d.ts +59 -0
- package/dist/folds/use-folds.d.ts.map +1 -0
- package/dist/folds/use-folds.js +126 -0
- package/dist/folds/use-folds.js.map +1 -0
- 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 +46 -8
- package/src/components/view-screen.tsx +4 -0
- package/src/folds/fold-decorations.ts +30 -0
- package/src/folds/fold-model.ts +99 -0
- package/src/folds/markdown-heading-folds.ts +55 -0
- package/src/folds/use-folds.ts +203 -0
- 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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TextBufferRenderable } from "@opentui/core";
|
|
2
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
2
3
|
import { useBuildCommandContext, useCommand } from "@tooee/commands";
|
|
3
4
|
import { MarkdownView, flattenMarkdown, getFlatBlockText } from "@tooee/renderers";
|
|
4
5
|
import type { CodeBlockRenderer, FlatBlock } from "@tooee/renderers";
|
|
@@ -6,7 +7,11 @@ import type { DocumentRowAdapter } from "@tooee/shell";
|
|
|
6
7
|
import { useMemo, useRef } from "react";
|
|
7
8
|
import type { ReactNode } from "react";
|
|
8
9
|
|
|
10
|
+
import { markdownHeadingFoldRanges } from "../../folds/markdown-heading-folds.js";
|
|
11
|
+
import { useFoldActions, useFoldCommands, useFoldState } from "../../folds/use-folds.js";
|
|
9
12
|
import { useContentDocument } from "../../hooks/use-content-document.js";
|
|
13
|
+
import { OutlinePanel } from "../../outline/outline-panel.js";
|
|
14
|
+
import { useMarkdownOutline } from "../../outline/use-outline.js";
|
|
10
15
|
import type { MarkdownContent, MarkdownLinkActivateHandler } from "../../types.js";
|
|
11
16
|
import { ViewScreen } from "../view-screen.js";
|
|
12
17
|
import type { SubviewProps } from "./types.js";
|
|
@@ -15,6 +20,8 @@ interface MarkdownSubviewProps extends SubviewProps {
|
|
|
15
20
|
content: MarkdownContent;
|
|
16
21
|
codeBlockRenderers?: Record<string, CodeBlockRenderer>;
|
|
17
22
|
onLinkActivate?: MarkdownLinkActivateHandler;
|
|
23
|
+
/** Open the heading outline beside the document at start. */
|
|
24
|
+
outline?: boolean;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/** Columns moved per h/l press when scrolling a wide block horizontally. */
|
|
@@ -23,17 +30,26 @@ const BLOCK_HSCROLL_STEP = 4;
|
|
|
23
30
|
/**
|
|
24
31
|
* Blocks are the row unit. `getFlatBlockText` keeps search/copy in step with the
|
|
25
32
|
* source mapping (notably for synthetic bullet rows), and `getSource` projects
|
|
26
|
-
* each block's Markdown provenance onto the controller's anchors.
|
|
33
|
+
* each block's Markdown provenance onto the controller's anchors. The key is
|
|
34
|
+
* the block's index in the full flattened document, so it stays the same when
|
|
35
|
+
* folds hide the blocks around it.
|
|
27
36
|
*/
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
const markdownBlockAdapter = function markdownBlockAdapter(
|
|
38
|
+
blocks: readonly FlatBlock[]
|
|
39
|
+
): DocumentRowAdapter<FlatBlock> {
|
|
40
|
+
const indices = new Map(blocks.map((block, index) => [block, index]));
|
|
41
|
+
return {
|
|
42
|
+
getKey: (block, index) => indices.get(block) ?? index,
|
|
43
|
+
getSource: (block) => block.source,
|
|
44
|
+
getText: (block) => getFlatBlockText(block),
|
|
45
|
+
};
|
|
31
46
|
};
|
|
32
47
|
|
|
33
48
|
export const MarkdownSubview = function MarkdownSubview({
|
|
34
49
|
content,
|
|
35
50
|
codeBlockRenderers,
|
|
36
51
|
onLinkActivate,
|
|
52
|
+
outline: initialOutline = false,
|
|
37
53
|
decorations,
|
|
38
54
|
actions,
|
|
39
55
|
...screen
|
|
@@ -41,19 +57,37 @@ export const MarkdownSubview = function MarkdownSubview({
|
|
|
41
57
|
const textContent = content.markdown;
|
|
42
58
|
const lineCount = useMemo(() => textContent.split("\n").length, [textContent]);
|
|
43
59
|
const blocks = useMemo(() => flattenMarkdown(content.markdown), [content.markdown]);
|
|
60
|
+
const adapter = useMemo(() => markdownBlockAdapter(blocks), [blocks]);
|
|
61
|
+
const foldRanges = useMemo(() => markdownHeadingFoldRanges(blocks), [blocks]);
|
|
62
|
+
// Closed heading folds filter `blocks`; `folds.rows` is the one array both
|
|
63
|
+
// the controller and the renderer see.
|
|
64
|
+
const folds = useFoldState(blocks, foldRanges, decorations);
|
|
44
65
|
|
|
45
66
|
const { document, showLineNumbers, statusItems } = useContentDocument<FlatBlock>(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
{ actions, content, decorations, textContent },
|
|
67
|
+
folds.rows,
|
|
68
|
+
adapter,
|
|
69
|
+
{ actions, content, decorations: folds.decorations, textContent },
|
|
49
70
|
{
|
|
50
71
|
multiSelect: true,
|
|
72
|
+
preserveCursorByKey: true,
|
|
51
73
|
statusItems: [
|
|
52
74
|
{ label: "Format:", value: content.format },
|
|
53
75
|
{ label: "Lines:", value: String(lineCount) },
|
|
54
76
|
],
|
|
55
77
|
}
|
|
56
78
|
);
|
|
79
|
+
const foldActions = useFoldActions(folds, document.navigation);
|
|
80
|
+
useFoldCommands(folds, foldActions, document.navigation);
|
|
81
|
+
// Declared after the controller, so its jump effect runs after the
|
|
82
|
+
// controller has taken the new rows.
|
|
83
|
+
const outline = useMarkdownOutline({
|
|
84
|
+
blocks,
|
|
85
|
+
folds,
|
|
86
|
+
initialOpen: initialOutline,
|
|
87
|
+
navigation: document.navigation,
|
|
88
|
+
revealRow: document.revealRow,
|
|
89
|
+
});
|
|
90
|
+
const { width: terminalWidth } = useTerminalDimensions();
|
|
57
91
|
const buildCommandContext = useBuildCommandContext();
|
|
58
92
|
const handleLinkActivate = onLinkActivate
|
|
59
93
|
? (href: string) => onLinkActivate(href, buildCommandContext())
|
|
@@ -97,17 +131,21 @@ export const MarkdownSubview = function MarkdownSubview({
|
|
|
97
131
|
controller={document}
|
|
98
132
|
actions={actions}
|
|
99
133
|
statusItems={statusItems}
|
|
134
|
+
aside={<OutlinePanel outline={outline} foldActions={foldActions} />}
|
|
100
135
|
{...screen}
|
|
101
136
|
>
|
|
102
137
|
<MarkdownView
|
|
103
138
|
content={content.markdown}
|
|
104
|
-
blocks={
|
|
139
|
+
blocks={folds.rows}
|
|
140
|
+
rowNumbers={folds.rowNumbers}
|
|
141
|
+
foldedBlocks={folds.view.hiddenCounts}
|
|
105
142
|
showLineNumbers={showLineNumbers}
|
|
106
143
|
document={document}
|
|
107
144
|
hScrollableBlocksRef={hScrollableBlocksRef}
|
|
108
145
|
codeBlockRenderers={codeBlockRenderers}
|
|
109
146
|
onLinkActivate={handleLinkActivate}
|
|
110
147
|
imageBasePath={content.imageBasePath}
|
|
148
|
+
width={outline.visible ? terminalWidth - outline.width : undefined}
|
|
111
149
|
/>
|
|
112
150
|
</ViewScreen>
|
|
113
151
|
);
|
|
@@ -21,6 +21,8 @@ export interface ViewScreenProps<T> {
|
|
|
21
21
|
setMarkSet: (set: MarkSet) => void;
|
|
22
22
|
clearMarkNamespace: (namespace: string) => void;
|
|
23
23
|
clearAllUserMarks: () => void;
|
|
24
|
+
/** Optional side region beside the content, such as the Markdown outline. */
|
|
25
|
+
aside?: ReactNode;
|
|
24
26
|
children: ReactNode;
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -41,6 +43,7 @@ export const ViewScreen = function ViewScreen<T>({
|
|
|
41
43
|
setMarkSet,
|
|
42
44
|
clearMarkNamespace,
|
|
43
45
|
clearAllUserMarks,
|
|
46
|
+
aside,
|
|
44
47
|
children,
|
|
45
48
|
}: ViewScreenProps<T>): ReactNode {
|
|
46
49
|
useProvideViewCommandContext({
|
|
@@ -71,6 +74,7 @@ export const ViewScreen = function ViewScreen<T>({
|
|
|
71
74
|
statusItems={items}
|
|
72
75
|
actions={actions}
|
|
73
76
|
context={{ kind: content.format, reload, title: content.title }}
|
|
77
|
+
aside={aside}
|
|
74
78
|
>
|
|
75
79
|
{children}
|
|
76
80
|
</DocumentScreen>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DecorationLayer, RowDecoration } from "@tooee/renderers";
|
|
2
|
+
|
|
3
|
+
import type { FoldView } from "./fold-model.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Map decoration layers written against full-array rows onto the visible rows
|
|
7
|
+
* of `view`. Decorations on rows that a closed fold hides are dropped.
|
|
8
|
+
*/
|
|
9
|
+
export const projectDecorationLayers = function projectDecorationLayers(
|
|
10
|
+
layers: readonly DecorationLayer[],
|
|
11
|
+
view: FoldView
|
|
12
|
+
): DecorationLayer[] {
|
|
13
|
+
const { visibleToSource, sourceToVisible } = view;
|
|
14
|
+
return layers.map((layer): DecorationLayer => ({
|
|
15
|
+
*forVisibleRows(from: number, to: number): Generator<RowDecoration> {
|
|
16
|
+
const sourceFrom = visibleToSource[from];
|
|
17
|
+
const sourceTo = visibleToSource[Math.min(to, visibleToSource.length - 1)];
|
|
18
|
+
if (sourceFrom === undefined || sourceTo === undefined) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
for (const decoration of layer.forVisibleRows(sourceFrom, sourceTo)) {
|
|
22
|
+
const row = sourceToVisible[decoration.row] ?? -1;
|
|
23
|
+
if (row !== -1) {
|
|
24
|
+
yield { ...decoration, row };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
priority: layer.priority,
|
|
29
|
+
}));
|
|
30
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Key } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A foldable range of rows. `start` is the header row that stays visible;
|
|
5
|
+
* `start + 1 ..= end` are the rows a closed fold hides. Indices point into the
|
|
6
|
+
* full row array, and `key` is the header row's key, so closed state survives
|
|
7
|
+
* row changes that keep keys stable.
|
|
8
|
+
*/
|
|
9
|
+
export interface FoldRange {
|
|
10
|
+
key: Key;
|
|
11
|
+
start: number;
|
|
12
|
+
end: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The visible projection of a full row array under a set of closed folds. */
|
|
16
|
+
export interface FoldView {
|
|
17
|
+
/** Full-array index of each visible row, in order. */
|
|
18
|
+
visibleToSource: readonly number[];
|
|
19
|
+
/** Visible index of each full-array row, or `-1` when a closed fold hides it. */
|
|
20
|
+
sourceToVisible: readonly number[];
|
|
21
|
+
/** Hidden row count for each closed header, keyed by the header's visible index. */
|
|
22
|
+
hiddenCounts: ReadonlyMap<number, number>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Project `rowCount` rows through the closed folds. A closed range hides its
|
|
27
|
+
* body even when it sits inside another closed range; the outermost header
|
|
28
|
+
* shown carries the count of every row hidden below it.
|
|
29
|
+
*/
|
|
30
|
+
export const computeFoldView = function computeFoldView(
|
|
31
|
+
rowCount: number,
|
|
32
|
+
ranges: readonly FoldRange[],
|
|
33
|
+
closedKeys: ReadonlySet<Key>
|
|
34
|
+
): FoldView {
|
|
35
|
+
const closedEnds = new Map<number, number>();
|
|
36
|
+
for (const range of ranges) {
|
|
37
|
+
if (range.end > range.start && closedKeys.has(range.key)) {
|
|
38
|
+
closedEnds.set(range.start, Math.max(closedEnds.get(range.start) ?? range.end, range.end));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const visibleToSource: number[] = [];
|
|
43
|
+
const sourceToVisible: number[] = Array.from({ length: rowCount }, () => -1);
|
|
44
|
+
const hiddenCounts = new Map<number, number>();
|
|
45
|
+
let hiddenUntil = -1;
|
|
46
|
+
for (let index = 0; index < rowCount; index += 1) {
|
|
47
|
+
if (index <= hiddenUntil) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const visibleIndex = visibleToSource.length;
|
|
51
|
+
visibleToSource.push(index);
|
|
52
|
+
sourceToVisible[index] = visibleIndex;
|
|
53
|
+
const end = closedEnds.get(index);
|
|
54
|
+
if (end !== undefined) {
|
|
55
|
+
hiddenUntil = Math.min(end, rowCount - 1);
|
|
56
|
+
hiddenCounts.set(visibleIndex, hiddenUntil - index);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { hiddenCounts, sourceToVisible, visibleToSource };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Ranges that contain `row` (header included), innermost first. */
|
|
64
|
+
export const foldsAt = function foldsAt(
|
|
65
|
+
ranges: readonly FoldRange[],
|
|
66
|
+
row: number
|
|
67
|
+
): readonly FoldRange[] {
|
|
68
|
+
return ranges
|
|
69
|
+
.filter((range) => range.end > range.start && range.start <= row && row <= range.end)
|
|
70
|
+
.toSorted((left, right) => right.start - left.start || left.end - right.end);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Keys of the closed ranges that hide `row`: closed ranges whose body
|
|
75
|
+
* (`start + 1 ..= end`) contains it. Opening them all makes `row` visible.
|
|
76
|
+
*/
|
|
77
|
+
export const closedFoldsHiding = function closedFoldsHiding(
|
|
78
|
+
ranges: readonly FoldRange[],
|
|
79
|
+
closedKeys: ReadonlySet<Key>,
|
|
80
|
+
row: number
|
|
81
|
+
): Key[] {
|
|
82
|
+
return ranges
|
|
83
|
+
.filter((range) => closedKeys.has(range.key) && range.start < row && row <= range.end)
|
|
84
|
+
.map((range) => range.key);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The full-array row the cursor should rest on once `view` applies: `row`
|
|
89
|
+
* itself when it stays visible, otherwise the nearest visible row before it,
|
|
90
|
+
* which is the header of the outermost closed fold that hides it.
|
|
91
|
+
*/
|
|
92
|
+
export const visibleAnchorRow = function visibleAnchorRow(view: FoldView, row: number): number {
|
|
93
|
+
for (let index = Math.min(row, view.sourceToVisible.length - 1); index >= 0; index -= 1) {
|
|
94
|
+
if ((view.sourceToVisible[index] ?? -1) !== -1) {
|
|
95
|
+
return index;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return row;
|
|
99
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { FlatBlock } from "@tooee/renderers";
|
|
2
|
+
import type { Tokens } from "marked";
|
|
3
|
+
|
|
4
|
+
import type { FoldRange } from "./fold-model.js";
|
|
5
|
+
|
|
6
|
+
/** The Marked heading token of a flattened block, or `null` when the block is not a heading. */
|
|
7
|
+
export const headingToken = function headingToken(block: FlatBlock): Tokens.Heading | null {
|
|
8
|
+
if (block.token.type !== "heading") {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
// SAFETY: Marked creates a Heading token for the checked "heading" discriminator.
|
|
12
|
+
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Marked's Generic fallback prevents discriminator narrowing
|
|
13
|
+
return block.token as Tokens.Heading;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const headingDepth = function headingDepth(block: FlatBlock): number | null {
|
|
17
|
+
return headingToken(block)?.depth ?? null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Heading fold ranges over flattened Markdown blocks. A heading at depth `d`
|
|
22
|
+
* folds every following block up to the next heading of depth `d` or less, or
|
|
23
|
+
* the end of the document. A heading with no body yields no range. Keys are
|
|
24
|
+
* the heading's block index, the same key the Markdown row adapter uses.
|
|
25
|
+
*/
|
|
26
|
+
export const markdownHeadingFoldRanges = function markdownHeadingFoldRanges(
|
|
27
|
+
blocks: readonly FlatBlock[]
|
|
28
|
+
): FoldRange[] {
|
|
29
|
+
const ranges: FoldRange[] = [];
|
|
30
|
+
const open: { depth: number; start: number }[] = [];
|
|
31
|
+
const close = (start: number, end: number) => {
|
|
32
|
+
if (end > start) {
|
|
33
|
+
ranges.push({ end, key: start, start });
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
for (const [index, block] of blocks.entries()) {
|
|
38
|
+
const depth = headingDepth(block);
|
|
39
|
+
if (depth === null) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
while (open.length > 0 && (open.at(-1)?.depth ?? 0) >= depth) {
|
|
43
|
+
const finished = open.pop();
|
|
44
|
+
if (finished) {
|
|
45
|
+
close(finished.start, index - 1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
open.push({ depth, start: index });
|
|
49
|
+
}
|
|
50
|
+
for (const finished of open) {
|
|
51
|
+
close(finished.start, blocks.length - 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return ranges.toSorted((left, right) => left.start - right.start);
|
|
55
|
+
};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { useActions, useCommandGroup } from "@tooee/commands";
|
|
2
|
+
import type { ActionDefinition, CommandContext, CommandHandler } from "@tooee/commands";
|
|
3
|
+
import type { DecorationLayer } from "@tooee/renderers";
|
|
4
|
+
import type { NavigationState } from "@tooee/shell";
|
|
5
|
+
import { useMemo, useState } from "react";
|
|
6
|
+
import type { Key } from "react";
|
|
7
|
+
|
|
8
|
+
import { projectDecorationLayers } from "./fold-decorations.js";
|
|
9
|
+
import { computeFoldView, foldsAt, visibleAnchorRow } from "./fold-model.js";
|
|
10
|
+
import type { FoldRange, FoldView } from "./fold-model.js";
|
|
11
|
+
|
|
12
|
+
const EMPTY_KEYS: ReadonlySet<Key> = new Set();
|
|
13
|
+
|
|
14
|
+
/** Fold state over a full row array and the visible projection it produces. */
|
|
15
|
+
export interface FoldState<T> {
|
|
16
|
+
/** The visible rows: pass this array to the document controller and the renderer. */
|
|
17
|
+
rows: readonly T[];
|
|
18
|
+
/** The full-array row count. */
|
|
19
|
+
rowCount: number;
|
|
20
|
+
ranges: readonly FoldRange[];
|
|
21
|
+
view: FoldView;
|
|
22
|
+
closedKeys: ReadonlySet<Key>;
|
|
23
|
+
setClosedKeys: (keys: ReadonlySet<Key>) => void;
|
|
24
|
+
/** External decoration layers projected onto the visible rows. */
|
|
25
|
+
decorations: DecorationLayer[];
|
|
26
|
+
/** One-based full-array row number for each visible row, or `undefined` when nothing is hidden. */
|
|
27
|
+
rowNumbers: readonly number[] | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Hold the closed fold keys for `rows` and project the rows, decorations and
|
|
32
|
+
* gutter numbers onto what stays visible. `ranges` and `decorations` use
|
|
33
|
+
* full-array row indices. With no closed fold, `rows` and `decorations` pass
|
|
34
|
+
* through unchanged.
|
|
35
|
+
*/
|
|
36
|
+
export const useFoldState = function useFoldState<T>(
|
|
37
|
+
rows: readonly T[],
|
|
38
|
+
ranges: readonly FoldRange[],
|
|
39
|
+
decorations: DecorationLayer[]
|
|
40
|
+
): FoldState<T> {
|
|
41
|
+
const [closedKeys, setClosedKeys] = useState<ReadonlySet<Key>>(EMPTY_KEYS);
|
|
42
|
+
const view = useMemo(
|
|
43
|
+
() => computeFoldView(rows.length, ranges, closedKeys),
|
|
44
|
+
[rows.length, ranges, closedKeys]
|
|
45
|
+
);
|
|
46
|
+
const folded = view.visibleToSource.length < rows.length;
|
|
47
|
+
|
|
48
|
+
const visibleRows = useMemo(
|
|
49
|
+
() =>
|
|
50
|
+
folded
|
|
51
|
+
? view.visibleToSource.flatMap((index) => {
|
|
52
|
+
const row = rows[index];
|
|
53
|
+
return row === undefined ? [] : [row];
|
|
54
|
+
})
|
|
55
|
+
: rows,
|
|
56
|
+
[folded, rows, view]
|
|
57
|
+
);
|
|
58
|
+
const visibleDecorations = useMemo(
|
|
59
|
+
() => (folded ? projectDecorationLayers(decorations, view) : decorations),
|
|
60
|
+
[folded, decorations, view]
|
|
61
|
+
);
|
|
62
|
+
const rowNumbers = useMemo(
|
|
63
|
+
() => (folded ? view.visibleToSource.map((index) => index + 1) : undefined),
|
|
64
|
+
[folded, view]
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
closedKeys,
|
|
69
|
+
decorations: visibleDecorations,
|
|
70
|
+
ranges,
|
|
71
|
+
rowCount: rows.length,
|
|
72
|
+
rowNumbers,
|
|
73
|
+
rows: visibleRows,
|
|
74
|
+
setClosedKeys,
|
|
75
|
+
view,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
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
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
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.
|
|
99
|
+
*/
|
|
100
|
+
export const useFoldActions = function useFoldActions<T>(
|
|
101
|
+
folds: FoldState<T>,
|
|
102
|
+
navigation: Pick<NavigationState, "cursor" | "setCursor">
|
|
103
|
+
): FoldActions {
|
|
104
|
+
const { closedKeys, ranges, rowCount, setClosedKeys, view } = folds;
|
|
105
|
+
const { cursor, setCursor } = navigation;
|
|
106
|
+
const cursorRow = cursor === null ? undefined : view.visibleToSource[cursor];
|
|
107
|
+
|
|
108
|
+
const apply = (next: ReadonlySet<Key>) => {
|
|
109
|
+
if (cursorRow !== undefined) {
|
|
110
|
+
const anchor = visibleAnchorRow(computeFoldView(rowCount, ranges, next), cursorRow);
|
|
111
|
+
const anchorIndex = view.sourceToVisible[anchor] ?? -1;
|
|
112
|
+
if (anchor !== cursorRow && anchorIndex !== -1) {
|
|
113
|
+
setCursor(anchorIndex);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
setClosedKeys(next);
|
|
117
|
+
};
|
|
118
|
+
const withKey = (key: Key | undefined, closed: boolean): boolean => {
|
|
119
|
+
if (key === undefined) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const next = new Set(closedKeys);
|
|
123
|
+
if (closed) {
|
|
124
|
+
next.add(key);
|
|
125
|
+
} else {
|
|
126
|
+
next.delete(key);
|
|
127
|
+
}
|
|
128
|
+
apply(next);
|
|
129
|
+
return true;
|
|
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);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
close,
|
|
138
|
+
closeAll: () => {
|
|
139
|
+
apply(new Set(ranges.map((range) => range.key)));
|
|
140
|
+
},
|
|
141
|
+
open,
|
|
142
|
+
openAll: () => {
|
|
143
|
+
apply(EMPTY_KEYS);
|
|
144
|
+
},
|
|
145
|
+
toggle: (row) => {
|
|
146
|
+
const [innermost] = foldsAt(ranges, row);
|
|
147
|
+
return withKey(innermost?.key, innermost !== undefined && !closedKeys.has(innermost.key));
|
|
148
|
+
},
|
|
149
|
+
toggleBlock: (row) =>
|
|
150
|
+
foldsAt(ranges, row).some((range) => closedKeys.has(range.key)) ? open(row) : close(row),
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
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]) => ({
|
|
184
|
+
category: "Fold",
|
|
185
|
+
handler,
|
|
186
|
+
hotkey,
|
|
187
|
+
id: `${idPrefix}.${id}`,
|
|
188
|
+
modes: ["cursor"],
|
|
189
|
+
title,
|
|
190
|
+
}));
|
|
191
|
+
};
|
|
192
|
+
|
|
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"));
|
|
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
|
+
};
|