@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.
Files changed (59) hide show
  1. package/dist/components/subviews/markdown-subview.d.ts +3 -1
  2. package/dist/components/subviews/markdown-subview.d.ts.map +1 -1
  3. package/dist/components/subviews/markdown-subview.js +36 -7
  4. package/dist/components/subviews/markdown-subview.js.map +1 -1
  5. package/dist/components/view-screen.d.ts +3 -1
  6. package/dist/components/view-screen.d.ts.map +1 -1
  7. package/dist/components/view-screen.js +2 -2
  8. package/dist/components/view-screen.js.map +1 -1
  9. package/dist/folds/fold-decorations.d.ts +8 -0
  10. package/dist/folds/fold-decorations.d.ts.map +1 -0
  11. package/dist/folds/fold-decorations.js +24 -0
  12. package/dist/folds/fold-decorations.js.map +1 -0
  13. package/dist/folds/fold-model.d.ts +41 -0
  14. package/dist/folds/fold-model.d.ts.map +1 -0
  15. package/dist/folds/fold-model.js +60 -0
  16. package/dist/folds/fold-model.js.map +1 -0
  17. package/dist/folds/markdown-heading-folds.d.ts +13 -0
  18. package/dist/folds/markdown-heading-folds.d.ts.map +1 -0
  19. package/dist/folds/markdown-heading-folds.js +45 -0
  20. package/dist/folds/markdown-heading-folds.js.map +1 -0
  21. package/dist/folds/use-folds.d.ts +59 -0
  22. package/dist/folds/use-folds.d.ts.map +1 -0
  23. package/dist/folds/use-folds.js +126 -0
  24. package/dist/folds/use-folds.js.map +1 -0
  25. package/dist/launch.d.ts +2 -0
  26. package/dist/launch.d.ts.map +1 -1
  27. package/dist/launch.js.map +1 -1
  28. package/dist/outline/markdown-outline.d.ts +24 -0
  29. package/dist/outline/markdown-outline.d.ts.map +1 -0
  30. package/dist/outline/markdown-outline.js +25 -0
  31. package/dist/outline/markdown-outline.js.map +1 -0
  32. package/dist/outline/outline-panel.d.ts +17 -0
  33. package/dist/outline/outline-panel.d.ts.map +1 -0
  34. package/dist/outline/outline-panel.js +106 -0
  35. package/dist/outline/outline-panel.js.map +1 -0
  36. package/dist/outline/use-outline.d.ts +53 -0
  37. package/dist/outline/use-outline.d.ts.map +1 -0
  38. package/dist/outline/use-outline.js +131 -0
  39. package/dist/outline/use-outline.js.map +1 -0
  40. package/dist/standalone-router.d.ts.map +1 -1
  41. package/dist/standalone-router.js +1 -1
  42. package/dist/standalone-router.js.map +1 -1
  43. package/dist/view.d.ts +3 -1
  44. package/dist/view.d.ts.map +1 -1
  45. package/dist/view.js +2 -2
  46. package/dist/view.js.map +1 -1
  47. package/package.json +14 -13
  48. package/src/components/subviews/markdown-subview.tsx +46 -8
  49. package/src/components/view-screen.tsx +4 -0
  50. package/src/folds/fold-decorations.ts +30 -0
  51. package/src/folds/fold-model.ts +99 -0
  52. package/src/folds/markdown-heading-folds.ts +55 -0
  53. package/src/folds/use-folds.ts +203 -0
  54. package/src/launch.tsx +2 -0
  55. package/src/outline/markdown-outline.ts +45 -0
  56. package/src/outline/outline-panel.tsx +171 -0
  57. package/src/outline/use-outline.ts +188 -0
  58. package/src/standalone-router.tsx +1 -0
  59. 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 MARKDOWN_BLOCK_ADAPTER: DocumentRowAdapter<FlatBlock> = {
29
- getSource: (block) => block.source,
30
- getText: (block) => getFlatBlockText(block),
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
- blocks,
47
- MARKDOWN_BLOCK_ADAPTER,
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={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
+ };