@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
@@ -1,5 +1,5 @@
1
- import { useCommand, useCommandGroup } from "@tooee/commands";
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";
@@ -39,10 +39,12 @@ export const useFoldState = function useFoldState<T>(
39
39
  decorations: DecorationLayer[]
40
40
  ): FoldState<T> {
41
41
  const [closedKeys, setClosedKeys] = useState<ReadonlySet<Key>>(EMPTY_KEYS);
42
+
42
43
  const view = useMemo(
43
44
  () => computeFoldView(rows.length, ranges, closedKeys),
44
45
  [rows.length, ranges, closedKeys]
45
46
  );
47
+
46
48
  const folded = view.visibleToSource.length < rows.length;
47
49
 
48
50
  const visibleRows = useMemo(
@@ -50,15 +52,18 @@ export const useFoldState = function useFoldState<T>(
50
52
  folded
51
53
  ? view.visibleToSource.flatMap((index) => {
52
54
  const row = rows[index];
55
+
53
56
  return row === undefined ? [] : [row];
54
57
  })
55
58
  : rows,
56
59
  [folded, rows, view]
57
60
  );
61
+
58
62
  const visibleDecorations = useMemo(
59
63
  () => (folded ? projectDecorationLayers(decorations, view) : decorations),
60
64
  [folded, decorations, view]
61
65
  );
66
+
62
67
  const rowNumbers = useMemo(
63
68
  () => (folded ? view.visibleToSource.map((index) => index + 1) : undefined),
64
69
  [folded, view]
@@ -76,114 +81,141 @@ export const useFoldState = function useFoldState<T>(
76
81
  };
77
82
  };
78
83
 
79
- const warnNoFold = function warnNoFold(ctx: CommandContext): void {
80
- ctx.toast?.toast({ level: "warning", message: "No fold at cursor" });
81
- };
84
+ /** Fold operations. Row arguments are full-array row indices. */
85
+ export interface FoldActions {
86
+ /** Toggle the innermost fold that contains `row`. Returns false when no fold contains it. */
87
+ toggle: (row: number) => boolean;
88
+ /** Close the innermost open fold that contains `row`. Returns false when there is none. */
89
+ close: (row: number) => boolean;
90
+ /** Open the innermost closed fold that contains `row`. Returns false when there is none. */
91
+ open: (row: number) => boolean;
92
+ /** Open a fold at `row` when one is closed, else close the innermost open fold. */
93
+ toggleBlock: (row: number) => boolean;
94
+ closeAll: () => void;
95
+ openAll: () => void;
96
+ }
82
97
 
83
98
  /**
84
- * Register the Vim fold commands (`z a`, `z c`, `z o`, `z shift+m`,
85
- * `z shift+r`) against a fold state and the controller's navigation. When a
86
- * close hides the cursor row, the cursor moves first to the header of the
87
- * outermost closed fold, and key preservation keeps it there.
99
+ * Fold operations over a fold state. Any change that hides the document
100
+ * cursor row first moves the cursor to the header of the outermost closed
101
+ * fold that hides it, and key preservation keeps it there. This holds for
102
+ * every target row, so a fold closed from the outline also keeps the cursor
103
+ * visible.
88
104
  */
89
- export const useFoldCommands = function useFoldCommands<T>(
105
+ export const useFoldActions = function useFoldActions<T>(
90
106
  folds: FoldState<T>,
91
107
  navigation: Pick<NavigationState, "cursor" | "setCursor">
92
- ): void {
108
+ ): FoldActions {
93
109
  const { closedKeys, ranges, rowCount, setClosedKeys, view } = folds;
94
110
  const { cursor, setCursor } = navigation;
95
- const sourceRow = cursor === null ? undefined : view.visibleToSource[cursor];
96
- const atCursor = sourceRow === undefined ? [] : foldsAt(ranges, sourceRow);
111
+ const cursorRow = cursor === null ? undefined : view.visibleToSource[cursor];
97
112
 
98
113
  const apply = (next: ReadonlySet<Key>) => {
99
- if (sourceRow !== undefined) {
100
- const anchor = visibleAnchorRow(computeFoldView(rowCount, ranges, next), sourceRow);
114
+ if (cursorRow !== undefined) {
115
+ const anchor = visibleAnchorRow(computeFoldView(rowCount, ranges, next), cursorRow);
101
116
  const anchorIndex = view.sourceToVisible[anchor] ?? -1;
102
- if (anchor !== sourceRow && anchorIndex !== -1) {
117
+
118
+ if (anchor !== cursorRow && anchorIndex !== -1) {
103
119
  setCursor(anchorIndex);
104
120
  }
105
121
  }
122
+
106
123
  setClosedKeys(next);
107
124
  };
108
- const withKey = (key: Key, closed: boolean) => {
125
+
126
+ const withKey = (key: Key | undefined, closed: boolean): boolean => {
127
+ if (key === undefined) {
128
+ return false;
129
+ }
130
+
109
131
  const next = new Set(closedKeys);
132
+
110
133
  if (closed) {
111
134
  next.add(key);
112
135
  } else {
113
136
  next.delete(key);
114
137
  }
138
+
115
139
  apply(next);
140
+
141
+ return true;
116
142
  };
117
143
 
118
- useCommandGroup({ id: "fold", prefix: "z", title: "Fold" });
144
+ const close = (row: number) =>
145
+ withKey(foldsAt(ranges, row).find((range) => !closedKeys.has(range.key))?.key, true);
119
146
 
120
- useCommand({
121
- category: "Fold",
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));
147
+ const open = (row: number) =>
148
+ withKey(foldsAt(ranges, row).find((range) => closedKeys.has(range.key))?.key, false);
149
+
150
+ return {
151
+ close,
152
+ closeAll: () => {
153
+ apply(new Set(ranges.map((range) => range.key)));
129
154
  },
130
- hotkey: "z a",
131
- id: "fold.toggle",
132
- modes: ["cursor"],
133
- title: "Toggle fold",
134
- });
155
+ open,
156
+ openAll: () => {
157
+ apply(EMPTY_KEYS);
158
+ },
159
+ toggle: (row) => {
160
+ const [innermost] = foldsAt(ranges, row);
135
161
 
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);
162
+ return withKey(innermost?.key, innermost !== undefined && !closedKeys.has(innermost.key));
145
163
  },
146
- hotkey: "z c",
147
- id: "fold.close",
148
- modes: ["cursor"],
149
- title: "Close fold",
150
- });
164
+ toggleBlock: (row) =>
165
+ foldsAt(ranges, row).some((range) => closedKeys.has(range.key)) ? open(row) : close(row),
166
+ };
167
+ };
151
168
 
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;
169
+ /**
170
+ * The Vim fold commands (`z a`, `z c`, `z o`, `z z`, `z shift+m`,
171
+ * `z shift+r`) as action definitions. The row commands act on `targetRow()`:
172
+ * the document cursor row on the root surface, or the selected heading in the
173
+ * outline. `idPrefix` keeps the ids apart on each surface, and `noFoldMessage`
174
+ * is the warning when no fold contains the target row.
175
+ */
176
+ export const foldActionDefinitions = function foldActionDefinitions(
177
+ actions: FoldActions,
178
+ targetRow: () => number | undefined,
179
+ idPrefix: string,
180
+ noFoldMessage: string
181
+ ): ActionDefinition[] {
182
+ const atTarget =
183
+ (run: (row: number) => boolean) =>
184
+ (ctx: CommandContext): void => {
185
+ const row = targetRow();
186
+
187
+ if (row === undefined || !run(row)) {
188
+ ctx.toast?.toast({ level: "warning", message: noFoldMessage });
159
189
  }
160
- withKey(target.key, false);
161
- },
162
- hotkey: "z o",
163
- id: "fold.open",
164
- modes: ["cursor"],
165
- title: "Open fold",
166
- });
190
+ };
167
191
 
168
- useCommand({
169
- category: "Fold",
170
- handler: () => {
171
- apply(new Set(ranges.map((range) => range.key)));
172
- },
173
- hotkey: "z shift+m",
174
- id: "fold.close-all",
175
- modes: ["cursor"],
176
- title: "Close all folds",
177
- });
192
+ const definitions: [hotkey: string, id: string, title: string, handler: CommandHandler][] = [
193
+ ["z a", "toggle", "Toggle fold", atTarget(actions.toggle)],
194
+ ["z c", "close", "Close fold", atTarget(actions.close)],
195
+ ["z o", "open", "Open fold", atTarget(actions.open)],
196
+ ["z z", "toggle-block", "Open or close fold", atTarget(actions.toggleBlock)],
197
+ ["z shift+m", "close-all", "Close all folds", actions.closeAll],
198
+ ["z shift+r", "open-all", "Open all folds", actions.openAll],
199
+ ];
178
200
 
179
- useCommand({
201
+ return definitions.map(([hotkey, id, title, handler]) => ({
180
202
  category: "Fold",
181
- handler: () => {
182
- apply(EMPTY_KEYS);
183
- },
184
- hotkey: "z shift+r",
185
- id: "fold.open-all",
203
+ handler,
204
+ hotkey,
205
+ id: `${idPrefix}.${id}`,
186
206
  modes: ["cursor"],
187
- title: "Open all folds",
188
- });
207
+ title,
208
+ }));
209
+ };
210
+
211
+ /** Register the fold commands on the current surface, acting on the document cursor row. */
212
+ export const useFoldCommands = function useFoldCommands<T>(
213
+ folds: FoldState<T>,
214
+ actions: FoldActions,
215
+ navigation: Pick<NavigationState, "cursor">
216
+ ): void {
217
+ const { cursor } = navigation;
218
+ const cursorRow = cursor === null ? undefined : folds.view.visibleToSource[cursor];
219
+ useCommandGroup({ id: "fold", prefix: "z", title: "Fold" });
220
+ useActions(foldActionDefinitions(actions, () => cursorRow, "fold", "No fold at cursor"));
189
221
  };
@@ -1,4 +1,5 @@
1
1
  import type { ActionDefinition } from "@tooee/commands";
2
+ import { StatusPriority } from "@tooee/layout";
2
3
  import type { StatusBarItem } from "@tooee/layout";
3
4
  import type { DecorationLayer } from "@tooee/renderers";
4
5
  import { useDocumentController } from "@tooee/shell";
@@ -37,6 +38,7 @@ export const useContentDocument = function useContentDocument<T>(
37
38
  options: ContentDocumentOptions<T>
38
39
  ): ContentDocumentResult<T> {
39
40
  const { showLineNumbers } = useContentCommands({ content, textContent });
41
+
40
42
  const document = useDocumentController<T>({
41
43
  adapter,
42
44
  contextMenu: options.contextMenu === false ? false : actions,
@@ -45,10 +47,11 @@ export const useContentDocument = function useContentDocument<T>(
45
47
  preserveCursorByKey: options.preserveCursorByKey,
46
48
  rows,
47
49
  });
50
+
48
51
  const statusItems = options.buildStatusItems?.(document) ??
49
52
  options.statusItems ?? [
50
- { label: "Format:", value: content.format },
51
- { label: "Lines:", value: String(rows.length) },
53
+ { label: "Format:", priority: StatusPriority.content, value: content.format },
54
+ { label: "Lines:", priority: StatusPriority.content, value: String(rows.length) },
52
55
  ];
53
56
 
54
57
  return { document, showLineNumbers, statusItems };
@@ -39,6 +39,7 @@ export const useContentLoader = function useContentLoader(
39
39
  });
40
40
  const { requestId } = store.getSnapshot().context;
41
41
  const loaded = contentProvider.load();
42
+
42
43
  let cleanup: () => void = () => {
43
44
  // Synchronous providers have no pending work to cancel.
44
45
  };
@@ -55,16 +56,20 @@ export const useContentLoader = function useContentLoader(
55
56
  // preserve sequential stream consumption for ordered backpressure.
56
57
  // oxlint-disable-next-line no-await-in-loop -- each chunk must be consumed in iterator order
57
58
  const result = await iterator.next();
59
+
58
60
  if (result.done === true) {
59
61
  break;
60
62
  }
63
+
61
64
  store.trigger.chunkReceived({ chunk: result.value, requestId });
62
65
  }
66
+
63
67
  store.trigger.streamEnded({ requestId });
64
68
  } catch (error) {
65
69
  store.trigger.loadFailed({ error: normalizeError(error), requestId });
66
70
  }
67
71
  })();
72
+
68
73
  cleanup = () => {
69
74
  store.trigger.loadCancelled({ requestId });
70
75
  void (async () => {
@@ -84,6 +89,7 @@ export const useContentLoader = function useContentLoader(
84
89
  store.trigger.loadFailed({ error: normalizeError(error), requestId });
85
90
  }
86
91
  })();
92
+
87
93
  cleanup = () => {
88
94
  store.trigger.loadCancelled({ requestId });
89
95
  };
@@ -99,6 +105,7 @@ export const useContentLoader = function useContentLoader(
99
105
  const error = useSelector(store, (snapshot) => selectError(snapshot.context));
100
106
  const providerMarks = useSelector(store, (snapshot) => selectProviderMarks(snapshot.context));
101
107
  const status = useSelector(store, (snapshot) => selectStatus(snapshot.context));
108
+
102
109
  const reload = useCallback(() => {
103
110
  store.trigger.reloadRequested({});
104
111
  }, [store]);
@@ -72,6 +72,7 @@ export const createViewCommandContext = function createViewCommandContext({
72
72
  marks,
73
73
  }: CreateViewCommandContextOptions): ViewCommandContext {
74
74
  const resolvedFormat = format ?? content?.format ?? "custom";
75
+
75
76
  const resolvedContent: AnyContent =
76
77
  content ??
77
78
  ({
package/src/index.ts CHANGED
@@ -1,27 +1,39 @@
1
1
  export { View } from "./view.js";
2
+
2
3
  export { DirectoryView } from "./directory-view.js";
4
+
3
5
  export { launch, launchDirectory } from "./launch.js";
6
+
4
7
  export type { ViewLaunchOptions } from "./launch.js";
8
+
5
9
  export {
6
10
  createFileProvider,
7
11
  createStdinProvider,
8
12
  createTableFileProvider,
9
13
  createTableStdinProvider,
10
14
  } from "./default-provider.js";
15
+
11
16
  export type { CreateProviderOptions } from "./default-provider.js";
17
+
12
18
  export { listDirectoryFiles } from "./directory-provider.js";
19
+
13
20
  export type { DirectoryEntry } from "./directory-provider.js";
21
+
14
22
  export {
15
23
  createViewCommandContext,
16
24
  useProvideViewCommandContext,
17
25
  } from "./hooks/use-view-command-context.js";
26
+
18
27
  export type {
19
28
  CreateViewCommandContextOptions,
20
29
  ProvideViewCommandContextOptions,
21
30
  ViewCommandContext,
22
31
  } from "./hooks/use-view-command-context.js";
32
+
23
33
  export type { ContentLoaderState } from "./hooks/use-content-loader.js";
34
+
24
35
  export type { ContentCommandsResult } from "./hooks/use-content-commands.js";
36
+
25
37
  export type {
26
38
  AnyContent,
27
39
  Content,
@@ -43,8 +55,13 @@ export type {
43
55
  ColumnDef,
44
56
  TableRow,
45
57
  } from "./types.js";
58
+
46
59
  export { getTextContent, isBuiltinContent, isCustomContent } from "./types.js";
60
+
47
61
  export { CodeBlockChrome } from "@tooee/renderers";
62
+
48
63
  export type { CodeBlockRenderer, CodeBlockRendererProps, CodeBlockHScroll } from "@tooee/renderers";
64
+
49
65
  export { localFileLinkHandler } from "./link-handlers.js";
66
+
50
67
  export type { LinkHandler, LinkHandlerContext, LinkTarget } from "./link-handlers.js";
package/src/launch.tsx CHANGED
@@ -7,7 +7,7 @@ import type { ReactNode } from "react";
7
7
  import { DirectoryView } from "./directory-view.js";
8
8
  import type { LinkHandler } from "./link-handlers.js";
9
9
  import { createStandaloneRouter } from "./standalone-router.js";
10
- import type { ContentProvider, ContentRenderer } from "./types.js";
10
+ import type { ContentProvider, ContentRenderer, MarkdownLinkActivateHandler } from "./types.js";
11
11
 
12
12
  export interface ViewLaunchOptions {
13
13
  contentProvider: ContentProvider;
@@ -15,6 +15,12 @@ export interface ViewLaunchOptions {
15
15
  filePath?: string;
16
16
  /** Synchronous handlers tried before local file navigation. */
17
17
  linkHandlers?: readonly LinkHandler[];
18
+ /**
19
+ * Handles activation of a Markdown link, by mouse or by Enter on the cursor
20
+ * line. When set, it replaces the default handling: `linkHandlers`, local
21
+ * file navigation, and the stdin "Links need a source file" toast.
22
+ */
23
+ onMarkdownLinkActivate?: MarkdownLinkActivateHandler;
18
24
  actions?: ActionDefinition[];
19
25
  renderers?: Record<string, ContentRenderer>;
20
26
  /**
@@ -23,6 +29,8 @@ export interface ViewLaunchOptions {
23
29
  * Unmatched types fall back to the default syntax-highlighted code block.
24
30
  */
25
31
  codeBlockRenderers?: Record<string, CodeBlockRenderer>;
32
+ /** Open the heading outline beside Markdown content at start. */
33
+ outline?: boolean;
26
34
  }
27
35
 
28
36
  export interface DirectoryLaunchOptions {
@@ -33,9 +41,11 @@ export interface DirectoryLaunchOptions {
33
41
  export const launch = async function launch(options: ViewLaunchOptions): Promise<void> {
34
42
  const { router } = createStandaloneRouter(options);
35
43
  const startup = await router.start();
44
+
36
45
  if (startup.status !== "committed") {
37
46
  throw new Error(`View router startup ${startup.status}`);
38
47
  }
48
+
39
49
  await runCliSession<undefined>(
40
50
  (): ReactNode => (
41
51
  <RouterProvider router={router}>
@@ -24,22 +24,29 @@ export type LinkHandler = (link: LinkTarget, context: LinkHandlerContext) => boo
24
24
 
25
25
  export const localFileLinkHandler: LinkHandler = function localFileLinkHandler(link, context) {
26
26
  const result = resolveMarkdownLink(link.href, link.baseDir, link.currentPath);
27
+
27
28
  if (result.status === "unsupported") {
28
29
  return false;
29
30
  }
31
+
30
32
  if (result.status === "file") {
31
33
  void context.navigate.push(context.documentRoute, { path: result.path });
34
+
32
35
  return true;
33
36
  }
37
+
34
38
  const relative = path.relative(link.baseDir, result.path);
39
+
35
40
  const target =
36
41
  relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
37
42
  ? result.path
38
43
  : relative || ".";
44
+
39
45
  context.command.toast?.toast({
40
46
  level: "warning",
41
47
  message: `${result.status === "missing" ? "File not found" : "Not a file"}: ${target}`,
42
48
  });
49
+
43
50
  return true;
44
51
  };
45
52
 
@@ -54,9 +61,12 @@ export const runLinkHandlers = function runLinkHandlers(
54
61
  return true;
55
62
  }
56
63
  }
64
+
57
65
  if (localFileLinkHandler(link, context)) {
58
66
  return true;
59
67
  }
68
+
60
69
  context.command.toast?.toast({ level: "warning", message: `Unsupported link: ${link.href}` });
70
+
61
71
  return true;
62
72
  };
@@ -19,39 +19,53 @@ export const resolveMarkdownLink = function resolveMarkdownLink(
19
19
  ): LinkResolution {
20
20
  try {
21
21
  const decoded = decodeURIComponent(href);
22
+
22
23
  if (/[\p{Cc}\\]/u.test(decoded)) {
23
24
  return { status: "unsupported" };
24
25
  }
26
+
25
27
  const target = href.trim();
28
+
26
29
  if (!target || target.startsWith("//")) {
27
30
  return { status: "unsupported" };
28
31
  }
32
+
29
33
  const [rawPath] = target.split("#", 1);
34
+
30
35
  if (rawPath.includes("?")) {
31
36
  return { status: "unsupported" };
32
37
  }
38
+
33
39
  let path: string;
40
+
34
41
  if (/^[a-zA-Z][a-zA-Z\d+.-]*:/u.test(rawPath)) {
35
42
  // Check the authority before URL normalizes localhost into an empty host.
36
43
  if (!/^file:\/\/\//iu.test(rawPath)) {
37
44
  return { status: "unsupported" };
38
45
  }
46
+
39
47
  const url = new URL(rawPath);
48
+
40
49
  if (url.hostname || url.search) {
41
50
  return { status: "unsupported" };
42
51
  }
52
+
43
53
  path = fileURLToPath(url);
44
54
  } else {
45
55
  path = rawPath
46
56
  ? nodePath.resolve(baseDir, decodeURIComponent(rawPath))
47
57
  : (currentPath ?? baseDir);
48
58
  }
59
+
49
60
  const absolute = nodePath.resolve(path);
61
+
50
62
  try {
51
63
  const stat = statSync(absolute);
64
+
52
65
  if (stat.isFile()) {
53
66
  return { path: absolute, status: "file" };
54
67
  }
68
+
55
69
  return stat.isDirectory()
56
70
  ? { path: absolute, status: "directory" }
57
71
  : { status: "unsupported" };
@@ -82,8 +96,10 @@ const collect = (tokens: Token[], links: MarkdownLink[]): void => {
82
96
  }
83
97
  }
84
98
  };
99
+
85
100
  export const markdownLinks = function markdownLinks(line: string): MarkdownLink[] {
86
101
  const links: MarkdownLink[] = [];
87
102
  collect(Lexer.lexInline(line), links);
103
+
88
104
  return links;
89
105
  };
@@ -0,0 +1,48 @@
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
+
29
+ return heading === null
30
+ ? []
31
+ : [{ depth: heading.depth, row, text: getPlainText(heading.tokens).trim() }];
32
+ });
33
+
34
+ const minDepth = Math.min(...headings.map((heading) => heading.depth));
35
+
36
+ return headings.map((heading) => ({ ...heading, level: heading.depth - minDepth }));
37
+ };
38
+
39
+ /**
40
+ * Index of the entry for the heading that contains `row`: the last heading at
41
+ * or before it. Returns `-1` before the first heading or for an empty outline.
42
+ */
43
+ export const outlineIndexAt = function outlineIndexAt(
44
+ entries: readonly OutlineEntry[],
45
+ row: number
46
+ ): number {
47
+ return entries.findLastIndex((entry) => entry.row <= row);
48
+ };